Merge remote-tracking branch 'origin/main'

This commit is contained in:
qian committed 2026-07-18 00:26:46 +08:00
commit 6c83076dad
75 files changed
+9779 -149

No files matched your search

+36
View File
@@ -333,6 +333,42 @@ docker build -t team-api:v1.0.0 --build-arg VERSION=v1.0.0 -f manifest/docker/Do
| `Dockerfile` | 多阶段构建(bun 前端 → Go 编译 → Alpine 运行) |
| `docker-compose.yaml` | 服务编排(PostgreSQL + Redis + App) |
### 审计日志独立数据库(可选)
大模型请求审计日志(`aud_request_logs`)体量大、写入频繁,可将其存到独立的 PostgreSQL 实例,与主业务库物理隔离,避免影响主库响应速度。其余审核表(操作日志、登录历史、敏感数据访问、内容过滤)数据量小,留在主库即可。
**1. 在独立库中建表:**
```bash
# 连接审计数据库执行建表脚本
psql -h host -p port -U user -d team-api-audit -f docs/audit-db-schema.sql
```
**2. 配置 `manifest/config/config.yaml`:**
取消 `database.audit` 分组的注释并填写连接信息:
```yaml
database:
audit:
type: "pgsql"
link: "pgsql:user:password@tcp(127.0.0.1:5432)/team-api-audit?sslmode=disable"
timezone: "Asia/Shanghai"
maxIdle: 10
maxOpen: 50
```
**3. 重启服务:**
```bash
make run
```
**工作原理:**
- 仅 `aud_request_logs`(大模型请求审计)走独立库,其余审计表仍在主库
- 配置了 `database.audit.link` 时,`aud_request_logs` 自动写入独立库;未配置时一切照常写入主库
## 开发指南
### 代码生成
+5
View File
@@ -105,6 +105,11 @@ type IAdminV1 interface {
FeedbackReply(ctx context.Context, req *v1.FeedbackReplyReq) (res *v1.FeedbackReplyRes, err error)
FeedbackUpdateStatus(ctx context.Context, req *v1.FeedbackUpdateStatusReq) (res *v1.FeedbackUpdateStatusRes, err error)
FeedbackStats(ctx context.Context, req *v1.FeedbackStatsReq) (res *v1.FeedbackStatsRes, err error)
FileList(ctx context.Context, req *v1.FileListReq) (res *v1.FileListRes, err error)
FileStats(ctx context.Context, req *v1.FileStatsReq) (res *v1.FileStatsRes, err error)
FileDownload(ctx context.Context, req *v1.FileDownloadReq) (res *v1.FileDownloadRes, err error)
FileDelete(ctx context.Context, req *v1.FileDeleteReq) (res *v1.FileDeleteRes, err error)
FileCleanup(ctx context.Context, req *v1.FileCleanupReq) (res *v1.FileCleanupRes, err error)
HelpCategoryCreate(ctx context.Context, req *v1.HelpCategoryCreateReq) (res *v1.HelpCategoryCreateRes, err error)
HelpCategoryUpdate(ctx context.Context, req *v1.HelpCategoryUpdateReq) (res *v1.HelpCategoryUpdateRes, err error)
HelpCategoryDelete(ctx context.Context, req *v1.HelpCategoryDeleteReq) (res *v1.HelpCategoryDeleteRes, err error)
+105
View File
@@ -0,0 +1,105 @@
package v1
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// === 文件存储管理(管理后台) ===
// FileListReq 文件列表(分页 + 筛选)。
type FileListReq struct {
g.Meta `path:"/files" method:"get" mime:"json" tags:"管理后台-文件管理" summary:"文件列表"`
Page int `json:"page" in:"query" d:"1"`
PageSize int `json:"page_size" in:"query" d:"20"`
TenantId int64 `json:"tenant_id" in:"query" dc:"按租户筛选"`
UserId int64 `json:"user_id" in:"query" dc:"按上传者筛选"`
Provider string `json:"provider" in:"query" dc:"存储供应商 s3/minio/r2/oss/cos"`
Category string `json:"category" in:"query" v:"in:|image|export|other" dc:"分类:image/export/other"`
Keyword string `json:"keyword" in:"query" dc:"按原始文件名 / 存储路径模糊匹配"`
StartDate string `json:"start_date" in:"query" dc:"创建时间起 YYYY-MM-DD"`
EndDate string `json:"end_date" in:"query" dc:"创建时间止 YYYY-MM-DD"`
}
type FileItem struct {
Id int64 `json:"id"`
TenantId int64 `json:"tenant_id"`
UserId int64 `json:"user_id"`
OriginalName string `json:"original_name"`
MimeType string `json:"mime_type"`
Size int64 `json:"size"`
StorageProvider string `json:"storage_provider"`
StoragePath string `json:"storage_path"`
Category string `json:"category"` // image | export | other
VirusScanStatus string `json:"virus_scan_status"`
CreatedAt *gtime.Time `json:"created_at"`
}
type FileListRes struct {
List []*FileItem `json:"list"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// FileStatsReq 存储占用统计(用于 KPI 卡片)。
type FileStatsReq struct {
g.Meta `path:"/files/stats" method:"get" mime:"json" tags:"管理后台-文件管理" summary:"文件存储统计"`
TopN int `json:"top_n" in:"query" d:"10" v:"min:1|max:50" dc:"Top 租户数量"`
}
type FileProviderStat struct {
Provider string `json:"provider"`
Count int64 `json:"count"`
Bytes int64 `json:"bytes"`
}
type FileCategoryStat struct {
Category string `json:"category"`
Count int64 `json:"count"`
Bytes int64 `json:"bytes"`
}
type FileTenantStat struct {
TenantId int64 `json:"tenant_id"`
Count int64 `json:"count"`
Bytes int64 `json:"bytes"`
}
type FileStatsRes struct {
TotalCount int64 `json:"total_count"`
TotalBytes int64 `json:"total_bytes"`
ByProvider []FileProviderStat `json:"by_provider"`
ByCategory []FileCategoryStat `json:"by_category"`
TopTenants []FileTenantStat `json:"top_tenants"`
}
// FileDownloadReq 生成临时预览/下载链接。variant=thumb 返回缩略图(仅图片,OSS/COS 服务端裁剪,其余供应商回退原图),空/original 返回原图。
type FileDownloadReq struct {
g.Meta `path:"/files/{id}/download" method:"get" mime:"json" tags:"管理后台-文件管理" summary:"生成文件预览/下载链接"`
Id int64 `json:"id" in:"path" v:"required|min:1"`
Variant string `json:"variant" in:"query" v:"in:thumb,original" dc:"thumb=缩略图预览,空/original=原图"`
Width int `json:"width" in:"query" d:"400" v:"min:0|max:2048" dc:"缩略图宽度像素,仅 variant=thumb 生效"`
}
type FileDownloadRes struct {
Url string `json:"url"`
}
// FileDeleteReq 删除单个文件(对象 + 记录)。
type FileDeleteReq struct {
g.Meta `path:"/files/{id}" method:"delete" mime:"json" tags:"管理后台-文件管理" summary:"删除文件"`
Id int64 `json:"id" in:"path" v:"required|min:1"`
}
type FileDeleteRes struct{}
// FileCleanupReq 手动触发一次保留期清理(导出 + 图片)。
type FileCleanupReq struct {
g.Meta `path:"/files/cleanup" method:"post" mime:"json" tags:"管理后台-文件管理" summary:"手动清理过期文件"`
}
type FileCleanupRes struct {
ExportsDeleted int `json:"exports_deleted"`
ImagesDeleted int `json:"images_deleted"`
}
+3
View File
@@ -41,4 +41,7 @@ type TenantAvailableModelItem struct {
CacheReadPrice *float64 `json:"cache_read_price"`
CacheCreationPrice *float64 `json:"cache_creation_price"`
PricingTiers []PricingTierItem `json:"pricing_tiers"`
// AsyncImage 图片模型是否必须走异步端点(提交 /v1/images/generations/async 后轮询取图)。
// 仅图片分类模型有意义;DashScope 等异步图片 provider 为 true。
AsyncImage bool `json:"async_image"`
}
+89
View File
@@ -0,0 +1,89 @@
-- 审计日志独立数据库建表脚本
--
-- 用途:将大模型请求审计日志(aud_request_logs)存储到独立的 PostgreSQL 实例,
-- 与主业务库物理隔离,避免审计写入影响主库响应速度。
--
-- 使用方法:
-- 1. 在 PostgreSQL 中创建独立数据库(如 team-api-audit)
-- 2. 连接该数据库执行本脚本:
-- psql -h host -p port -U user -d team-api-audit -f docs/audit-db-schema.sql
-- 3. 在 config.yaml 中配置 database.audit 连接信息
-- 4. 重启服务
--
-- 注意:仅 aud_request_logs 表走独立库,其余审计表(aud_operation_logs、
-- aud_login_history、aud_sensitive_access_logs、aud_content_filter_logs)
-- 仍留在主库,无需迁移。
CREATE TABLE IF NOT EXISTS aud_request_logs (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
api_key_id BIGINT,
request_id VARCHAR(64) NOT NULL,
method VARCHAR(10) NOT NULL,
path VARCHAR(500) NOT NULL,
query_params TEXT,
status_code INTEGER,
client_ip VARCHAR(45),
user_agent TEXT,
request_body TEXT,
response_body TEXT,
latency_ms INTEGER,
audit_level VARCHAR(20) DEFAULT 'full' NOT NULL,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
tenant_request_body TEXT,
tenant_response_body TEXT,
tenant_audit_level VARCHAR(20) DEFAULT 'full' NOT NULL,
project_id BIGINT,
first_token_ms INTEGER,
request_headers JSONB,
response_headers JSONB,
forwarding_trace JSONB,
task_id VARCHAR(64),
task_status VARCHAR(20),
task_result TEXT,
task_upstream_headers JSONB,
task_completed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_aud_request_logs_created_brin
ON aud_request_logs USING brin (created_at);
CREATE INDEX IF NOT EXISTS idx_aud_request_logs_tenant
ON aud_request_logs USING btree (tenant_id, created_at);
CREATE INDEX IF NOT EXISTS idx_aud_request_logs_project
ON aud_request_logs USING btree (project_id) WHERE project_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_aud_request_logs_task_id
ON aud_request_logs (task_id) WHERE task_id IS NOT NULL;
COMMENT ON TABLE aud_request_logs IS '请求审计日志(记录所有 API 代理请求)';
COMMENT ON COLUMN aud_request_logs.id IS '主键ID';
COMMENT ON COLUMN aud_request_logs.tenant_id IS '租户ID';
COMMENT ON COLUMN aud_request_logs.user_id IS '用户ID';
COMMENT ON COLUMN aud_request_logs.api_key_id IS '使用的 API Key ID';
COMMENT ON COLUMN aud_request_logs.request_id IS '请求唯一ID(关联全链路追踪)';
COMMENT ON COLUMN aud_request_logs.method IS 'HTTP 方法(GET/POST/PUT/DELETE)';
COMMENT ON COLUMN aud_request_logs.path IS '请求路径';
COMMENT ON COLUMN aud_request_logs.query_params IS '查询参数(URL Query String)';
COMMENT ON COLUMN aud_request_logs.status_code IS 'HTTP 响应状态码';
COMMENT ON COLUMN aud_request_logs.client_ip IS '客户端 IP';
COMMENT ON COLUMN aud_request_logs.user_agent IS '客户端 User-Agent';
COMMENT ON COLUMN aud_request_logs.request_body IS '请求体(脱敏后存储)';
COMMENT ON COLUMN aud_request_logs.response_body IS '响应体(截断后存储)';
COMMENT ON COLUMN aud_request_logs.latency_ms IS '请求延迟(毫秒)';
COMMENT ON COLUMN aud_request_logs.audit_level IS '审计级别:full(完整记录)/ full_text(全量文本)/ masked(脱敏记录)/ question_only(仅提问)/ none(不记录)';
COMMENT ON COLUMN aud_request_logs.created_at IS '创建时间';
COMMENT ON COLUMN aud_request_logs.updated_at IS '更新时间';
COMMENT ON COLUMN aud_request_logs.tenant_request_body IS '租户级请求体(按租户审计级别处理)';
COMMENT ON COLUMN aud_request_logs.tenant_response_body IS '租户级响应体(按租户审计级别处理)';
COMMENT ON COLUMN aud_request_logs.tenant_audit_level IS '租户审计级别:full / full_text / masked / question_only / none';
COMMENT ON COLUMN aud_request_logs.project_id IS '关联项目ID(通过API Key关联,NULL表示个人密钥无项目)';
COMMENT ON COLUMN aud_request_logs.first_token_ms IS '首个 Token 出现的用时(毫秒),仅流式请求有值';
COMMENT ON COLUMN aud_request_logs.request_headers IS '请求头信息(仅审计级别为 full 时记录,管理后台调试用)';
COMMENT ON COLUMN aud_request_logs.response_headers IS '响应头信息(仅审计级别为 full 时记录,管理后台调试用)';
COMMENT ON COLUMN aud_request_logs.forwarding_trace IS '请求转发路径追踪(仅管理员可见)';
COMMENT ON COLUMN aud_request_logs.task_id IS '异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id';
COMMENT ON COLUMN aud_request_logs.task_status IS '异步任务终态:SUCCESS / FAILURE';
COMMENT ON COLUMN aud_request_logs.task_result IS '异步任务完成时上游返回的原始响应体';
COMMENT ON COLUMN aud_request_logs.task_upstream_headers IS '异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)';
COMMENT ON COLUMN aud_request_logs.task_completed_at IS '异步任务达到终态的时间';
+20
View File
@@ -531,3 +531,23 @@ if err != nil {
- 查询多行记录 → `Scan(&[]struct{})`
- 查询单个标量值(`COUNT`、`SUM`、单个字段)→ `Value()` 返回 `*gvar.Var`,用 `.String()/.Int64()/.Bool()` 取值,**不要用 `Scan(&scalar)` 或 `Scan(&scalarPtr)`**
### 2026-07-16:CAS 更新用「已改写的目标状态」当 WHERE 谓词导致超时兜底网永久失效
**问题**:异步任务超时兜底 `handleTimedOutTasks`(`internal/logic/task/async_polling.go`)把任务标记失败时,先执行 `t.Status = "FAILURE"`,随后又把 `t.Status` 传给 `UpdateTaskCAS(ctx, t, t.Status)` 作为 CAS 的 oldStatus。`GetTimedOutTasks` 只返回非终态行(`status NOT IN ('SUCCESS','FAILURE')`),于是 CAS 生成的 SQL 是 `WHERE id=? AND status='FAILURE' SET status='FAILURE'`,对任何非终态行匹配 0 行 → `RowsAffected()==0` → 返回 CAS conflict → `continue`。结果:**超时任务从未被真正 FAILURE、预扣永不退款(资损)、active 计数永不递减**,且影响所有异步平台(sora/kling/suno/mj/ali/gemini/sync_image)。
**原因**:GoFrame 的「CAS 更新」惯用法 `Where("id", id).Where("status", oldStatus).Update(...)` + `RowsAffected()` 依赖 oldStatus 是**变更前**的真实值。此处在计算 oldStatus 之前就地改写了同一个 `t.Status` 字段,使谓词退化为「新值 == 旧值」,永不成立。
**修复方式**:把「计算 oldStatus + 改写为 FAILURE」抽成纯函数 `buildTimeoutFailure(t, now) (oldStatus string)`,在覆盖前捕获真实状态返回,供 CAS 使用;并补 DB-free 回归单测 `TestBuildTimeoutFailure_UsesOriginalStatusAsCASPredicate` 锁定「oldStatus 必须是覆盖前状态、绝不为 FAILURE」。
**正确做法**:任何 CAS 式条件更新,**先快照旧值再改写**,用快照做 WHERE 谓词:
```go
// 错误 — 先改写再拿改写后的值当谓词,CAS 永不匹配
t.Status = "FAILURE"
dao.Xxx.Ctx(ctx).Where("id", t.ID).Where("status", t.Status).Update(...) // WHERE status='FAILURE'
// 正确 — 先快照旧状态
oldStatus := t.Status
t.Status = "FAILURE"
dao.Xxx.Ctx(ctx).Where("id", t.ID).Where("status", oldStatus).Update(...) // WHERE status='IN_PROGRESS'
```
@@ -0,0 +1,94 @@
Z-Image 是一款轻量级文生图模型,可快速生成图像,支持中英文字渲染,并灵活适配多种分辨率与宽高比例。
**快速入口**:在线体验([北京](https://bailian.console.aliyun.com/cn-beijing/?tab=model#/efm/model_experience_center/vision?currentTab=imageGenerate&modelId=z-image-turbo) | [新加坡](https://modelstudio.console.aliyun.com/ap-southeast-1?tab=dashboard#/efm/model_experience_center/vision?currentTab=imageGenerate&modelId=z-image-turbo)) | [技术博客](https://tongyi-mai.github.io/Z-Image-blog/)
## **效果展示**
| **输入提示词** | **输出图像** |
| --- | --- |
| film grain, analog film texture, soft film lighting, Kodak Portra 400 style, cinematic grainy texture, photorealistic details, subtle noise, (film grain:1.2)。采用近景特写镜头拍摄的东亚年轻女性,呈现户外雪地场景。她体型纤瘦,呈站立姿势,身体微微向右侧倾斜,头部抬起看向画面上方,姿态自然放松。她的面部是典型东亚长相,肤色白皙,脸颊带有自然的红润感,五官清秀:眼睛是深棕色,眼型偏圆,眼神略带惊讶地望向上方,眼白部分可见;眉毛是深黑色,形状自然弯长;鼻子小巧挺直,嘴唇涂有红色口红,唇瓣微张,表情带着轻微的惊讶或好奇。她的头发是深黑色长直发,发丝被风吹得略显凌乱,部分垂在脸颊两侧,头顶佩戴一顶深灰色的头盔,头盔边缘露出少量发丝。服装是蓝白拼接的厚重外套,外套材质看起来是毛绒与布料结合,显得温暖厚实,适合雪地环境。背景是被白雪覆盖的户外场景,远处可见模糊的树木轮廓,天空是明亮的浅蓝色,带有少量白云,光线是强烈的自然日光,照亮人物面部与头发,形成清晰的光影,色调以蓝、白、黑为主,整体风格清新自然。画面顶部有黑色提示框,内有“**Press esc to exit full screen**”的白色文字。镜头的近景视角放大了人物的表情与细节,营造出户外雪地的真实氛围。 | ![image](https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/6915736671/p1036890.png) |
## 模型概览
| **模型名称** | **模型简介** | **输出图像规格** |
| --- | --- | --- |
| z-image-turbo | 轻量模型,快速生图 | 图像分辨率:总像素在\\[512\\*512, 2048\\*2048\\]之间,推荐分辨率请参见[size参数设置](#46b942a420o4e) 图像格式:png 图像张数:固定1张 |
**说明**
调用前,请查阅各地域支持的模型列表。
## **前提条件**
您需要[获取API Key](https://help.aliyun.com/zh/model-studio/get-api-key),并[配置API Key到环境变量](https://help.aliyun.com/zh/model-studio/configure-api-key-through-environment-variables)。
**重要**
阿里云百炼为华北2(北京)、新加坡地域推出了业务空间专属域名,**能够为推理请求提供卓越的性能和更高的稳定性**,建议迁移至新域名:
- 华北2(北京)地域:从 `https://dashscope.aliyuncs.com` 迁移至 `https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com`
- 新加坡地域:从 `https://dashscope-intl.aliyuncs.com` 迁移至 `https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com`
其中 `{WorkspaceId}` 为您的业务空间 ID,可在阿里云百炼控制台的**业务空间详情**页面查看。现有域名仍可正常使用。
## **HTTP同步调用**
**北京地域**:`POST https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation`
**新加坡地域**:`POST https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation`
调用时请将`{WorkspaceId}`替换为真实的[业务空间ID](https://help.aliyun.com/zh/model-studio/obtain-the-app-id-and-workspace-id#d3eb3cd37b7fu)。
| #### 请求参数 | ## **文生图** 以下示例直接返回图片,响应速度较快。 若想开启“智能思考”能力,请设置`prompt_extend=true` 。开启后,系统将在返回图片的同时,返回优化后的提示词及其推理过程,但会增加响应时间。 ``` curl --location 'https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation' \\ --header 'Content-Type: application/json' \\ --header "Authorization: Bearer $DASHSCOPE_API_KEY" \\ --data '{ "model": "z-image-turbo", "input": { "messages": [ { "role": "user", "content": [ { "text": "film grain, analog film texture, soft film lighting, Kodak Portra 400 style, cinematic grainy texture, photorealistic details, subtle noise, (film grain:1.2)。采用近景特写镜头拍摄的东亚年轻女性,呈现户外雪地场景。她体型纤瘦,呈站立姿势,身体微微向右侧倾斜,头部抬起看向画面上方,姿态自然放松。她的面部是典型东亚长相,肤色白皙,脸颊带有自然的红润感,五官清秀:眼睛是深棕色,眼型偏圆,眼神略带惊讶地望向上方,眼白部分可见;眉毛是深黑色,形状自然弯长;鼻子小巧挺直,嘴唇涂有红色口红,唇瓣微张,表情带着轻微的惊讶或好奇。她的头发是深黑色长直发,发丝被风吹得略显凌乱,部分垂在脸颊两侧,头顶佩戴一顶深灰色的头盔,头盔边缘露出少量发丝。服装是蓝白拼接的厚重外套,外套材质看起来是毛绒与布料结合,显得温暖厚实,适合雪地环境。背景是被白雪覆盖的户外场景,远处可见模糊的树木轮廓,天空是明亮的浅蓝色,带有少量白云,光线是强烈的自然日光,照亮人物面部与头发,形成清晰的光影,色调以蓝、白、黑为主,整体风格清新自然。画面顶部有黑色提示框,内有“Press esc to exit full screen”的白色文字。镜头的近景视角放大了人物的表情与细节,营造出户外雪地的真实氛围。" } ] } ] }, "parameters": { "prompt_extend": false, "size": "1120*1440" } }' ``` |
| --- | --- |
| ##### 请求头(Headers) |
| **Content-Type** `*string*` **(必选)** 请求内容类型。此参数必须设置为`application/json`。 |
| **Authorization** `*string*`**(必选)** 请求身份认证。接口使用阿里云百炼API Key进行身份认证。示例值:Bearer sk-xxxx。 |
| ##### 请求体(Request Body) |
| **model** `*string*` **(必选)** 模型名称。必须为:z-image-turbo。 |
| **input** `*object*` **(必选)** 输入的基本信息。 **属性** **messages** `*array*` **(必选)** 请求内容数组。当前**仅支持单轮对话**,即传入一组role、content参数,不支持多轮对话。 **属性** **role** `*string*` **(必选)** 消息的角色。此参数必须设置为`user`。 **content** `*array*` **(必选)** 消息内容数组。必须包含且仅包含 **1 个 text 对象**。 **属性** **text** `*string*` **(必选)** 正向提示词用于描述期望生成的图像内容、风格和构图。 支持中英文,长度不超过800个字符,每个汉字、字母、数字或符号计为一个字符,超过部分会自动截断。 示例值:一只坐着的橘黄色的猫,表情愉悦,活泼可爱,逼真准确。 **注意**:仅支持传入一个text,不传或传入多个将报错。 |
| **parameters** `*object*` (可选) 图像处理参数。 **属性** **size** `*string*` (可选) 输出图像的分辨率,格式为`**宽*高**`。 - 默认值:`1024*1536`。 - 总像素范围限制:总像素在 \\[512\\*512, 2048\\*2048\\]之间。 - 推荐分辨率范围:总像素在 \\[1024\\*1024, 1536\\*1536\\]之间,出图效果更佳。 示例值:1024\\*1536。 **总像素为1024\\*1024的推荐分辨率:** - 1:1: 1024\\*1024 - 2:3: 832\\*1248 - 3:2: 1248\\*832 - 3:4: 864\\*1152 - 4:3: 1152\\*864 - 7:9: 896\\*1152 - 9:7: 1152\\*896 - 9:16: 720\\*1280 - 9:21: 576\\*1344 - 16:9: 1280\\*720 - 21:9:1344\\*576 **总像素为1280\\*1280的推荐分辨率:** - 1:1: 1280\\*1280 - 2:3: 1024\\*1536 - 3:2: 1536\\*1024 - 3:4: 1104\\*1472 - 4:3: 1472\\*1104 - 7:9: 1120\\*1440 - 9:7: 1440\\*1120 - 9:16: 864\\*1536 - 9:21: 720\\*1680 - 16:9: 1536\\*864 - 21:9: 1680\\*720 **总像素为1536\\*1536的推荐分辨率:** - 1:1:1536\\*1536 - 2:3: 1248\\*1872 - 3:2: 1872\\*1248 - 3:4: 1296\\*1728 - 4:3: 1728\\*1296 - 7:9: 1344\\*1728 - 9:7: 1728\\*1344 - 9:16: 1152\\*2048 - 9:21: 864\\*2016 - 16:9: 2048\\*1152 - 21:9: 2016\\*864 **prompt\\_extend** `*bool*` (可选) **重要** prompt\\_extend直接影响费用。设为 `true` 时价格高于 `false`,具体见参见[模型价格](https://help.aliyun.com/zh/model-studio/model-pricing#6713612f55h4l)。 是否启用智能提示词(text)改写。开启后,将使用大模型优化提示词,并输出思考过程。 - false:默认值,关闭智能改写。输出图像和原始文本提示词。 - true:开启智能改写。输出图像、优化后的文本提示词、思考过程。 **seed** `*integer*` (可选) 随机数种子,取值范围`[0,2147483647]`。 使用相同的`seed`参数值可使生成内容保持相对稳定。若不提供,算法将自动使用随机数种子。 **注意**:模型生成过程具有概率性,即使使用相同的`seed`,也不能保证每次生成结果完全一致。 |
| #### 响应参数 | ## 任务执行成功 任务数据(如任务状态、图像URL等)仅保留24小时,超时后会被自动清除。请您务必及时保存生成的图像。 ``` { "output": { "choices": [ { "finish_reason": "stop", "message": { "content": [ { "image": "https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/xxx.png?Expires=xxx" }, { "text": "film grain, analog film texture, soft film lighting, Kodak Portra 400 style, cinematic grainy texture, photorealistic details, subtle noise, (film grain:1.2)。采用近景特写镜头拍摄的东亚年轻女性,呈现户外雪地场景。她体型纤瘦,呈站立姿势,身体微微向右侧倾斜,头部抬起看向画面上方,姿态自然放松。她的面部是典型东亚长相,肤色白皙,脸颊带有自然的红润感,五官清秀:眼睛是深棕色,眼型偏圆,眼神略带惊讶地望向上方,眼白部分可见;眉毛是深黑色,形状自然弯长;鼻子小巧挺直,嘴唇涂有红色口红,唇瓣微张,表情带着轻微的惊讶或好奇。她的头发是深黑色长直发,发丝被风吹得略显凌乱,部分垂在脸颊两侧,头顶佩戴一顶深灰色的头盔,头盔边缘露出少量发丝。服装是蓝白拼接的厚重外套,外套材质看起来是毛绒与布料结合,显得温暖厚实,适合雪地环境。背景是被白雪覆盖的户外场景,远处可见模糊的树木轮廓,天空是明亮的浅蓝色,带有少量白云,光线是强烈的自然日光,照亮人物面部与头发,形成清晰的光影,色调以蓝、白、黑为主,整体风格清新自然。画面顶部有黑色提示框,内有“Press esc to exit full screen”的白色文字。镜头的近景视角放大了人物的表情与细节,营造出户外雪地的真实氛围。" } ], "reasoning_content": "", "role": "assistant" } } ] }, "usage": { "height": 1440, "image_count": 1, "input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "width": 1120 }, "request_id": "8a0809b4-a796-47f4-a095-394b02b62xxx" } ``` ## 任务执行异常 如果因为某种原因导致任务执行失败,将返回相关信息,可以通过code和message字段明确指示错误原因。请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)进行解决。 ``` { "request_id": "a4d78a5f-655f-9639-8437-xxxxxx", "code": "InvalidParameter", "message": "num_images_per_prompt must be 1" } ``` |
| --- | --- |
| **output** `*object*` 任务输出信息。 **属性** **choices** `*array*` 模型生成的输出内容。此数组仅包含1个元素。 **属性** **finish\\_reason** `*string*` 任务停止原因,正常完成时为 `stop`。 **message** `*object*` 模型返回的消息。 **属性** **role** `*string*` 消息的角色,固定为`assistant`。 **content** `*array*` **属性** **image** `*string*` 生成图像的 URL,图像格式为PNG。**链接有效期为24小时**,请及时下载并保存图像。 **text** `*string*` - 当prompt\\_extend=false时,为输入的提示词。 - 当prompt\\_extend=true时,为改写后的提示词。 **reasoning\\_content** `*string*` 模型的思考过程,仅在prompt\\_extend=true时返回思考文本。 |
| **usage** `*object*` 输出信息统计。只对成功的结果计数。 **属性** **width** `*integer*` 生成图像的宽度(像素)。 **height** `*integer*` 生成图像的高度(像素)。 **image\\_count** `*integer*` 生成图像的数量,固定为1。 **input\\_tokens** `*integer*` 输入token数量,prompt\\_extend=false时固定为0。 **output\\_tokens** `*integer*` 输出token数量,prompt\\_extend=false时固定为0。 **output\\_tokens\\_details** `*object*` 输出 token 详情,仅当prompt\\_extend=true时返回。 **属性** **reasoning\\_tokens** `*integer*` 推理思考使用的 token 数量。 **total\\_tokens** `*integer*` 总token数量,prompt\\_extend=false时固定为0。 |
| **request\\_id** `*string*` 请求唯一标识。可用于请求明细溯源和问题排查。 |
| **code** `*string*` 请求失败的错误码。请求成功时不会返回此参数,详情请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)。 | |
| **message** `*string*` 请求失败的详细信息。请求成功时不会返回此参数,详情请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)。 | |
## **使用限制**
- 图像`url`均只保留 24 小时,请及时下载。
- **内容审核**:输入的 `prompt` 和输出的图像均会经过内容安全审核,包含违规内容的请求将报错“IPInfringementSuspect”或“DataInspectionFailed”,具体参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)。
## **计费与限流**
- 模型免费额度和计费单价请参见[模型价格](https://help.aliyun.com/zh/model-studio/model-pricing#6713612f55h4l)。
- 模型限流请参见[Z-Image](https://help.aliyun.com/zh/model-studio/rate-limit#e17ad85fd8wwl)。
- 根据是否开启智能按成功生成的 **图像张数** 计费。模型调用失败或处理错误不产生任何费用,也不消耗[免费额度](https://help.aliyun.com/zh/model-studio/new-free-quota)。
## **错误码**
如果模型调用失败并返回报错信息,请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)进行解决。
## **常见问题**
### **Q: 如何查看模型调用量?**
A: 模型调用完一小时后,请在[**模型监控**(北京)](https://bailian.console.aliyun.com/cn-beijing/?tab=model#/model-telemetry)或[**模型监控**(新加坡)](https://modelstudio.console.aliyun.com/ap-southeast-1?tab=dashboard#/model-telemetry) 页面,查看模型的调用次数、成功率等指标。详情请参见[账单查询与成本管理](https://help.aliyun.com/zh/model-studio/bill-query-and-cost-management)。
### **Q:如何获取图像存储的访问域名白名单?**
A: 模型生成的图像存储于阿里云OSS,API将返回一个临时的公网URL。**若需要对该下载地址进行防火墙白名单配置**,请注意:由于底层存储会根据业务情况进行动态变更,为避免过期信息影响访问,文档不提供固定的OSS域名白名单。如有安全管控需求,请联系客户经理获取最新OSS域名列表。
.tab-item { font-size: 13px !important; /\* 你可以根据需要调整字体大小 \*/ padding: 0px 7px !important; } /\*表格图片设置为块元素(独占一行),居中展示,鼠标放在图片上可以点击查看原图\*/ .unionContainer .markdown-body .image.break { margin: 0px; display: inline-block; vertical-align: middle }
/\* 调整 table 宽度 \*/ .aliyun-docs-content table.medium-width { max-width: 1018px; width: 100%; } .aliyun-docs-content table.table-no-border tr td:first-child { padding-left: 0; } .aliyun-docs-content table.table-no-border tr td:last-child { padding-right: 0; } /\* 支持吸顶 \*/ div:has(.aliyun-docs-content), .aliyun-docs-content .markdown-body { overflow: visible; } .stick-top { position: sticky; top: 46px; } /\*\*代码块字体\*\*/ /\* 减少表格中的代码块 margin,让表格信息显示更紧凑 \*/ .unionContainer .markdown-body table .help-code-block { margin: 0 !important; } /\* 减少表格中的代码块字号,让表格信息显示更紧凑 \*/ .unionContainer .markdown-body .help-code-block pre { font-size: 12px !important; } /\* 减少表格中的代码块字号,让表格信息显示更紧凑 \*/ .unionContainer .markdown-body .help-code-block pre code { font-size: 12px !important; } /\*\* API Reference 表格 \*\*/ .aliyun-docs-content table.api-reference tr td:first-child { margin: 0px; border-bottom: 1px solid #d8d8d8; } .aliyun-docs-content table.api-reference tr:last-child td:first-child { border-bottom: none; } .aliyun-docs-content table.api-reference p { color: #6e6e80; } .aliyun-docs-content table.api-reference b, i { color: #181818; } .aliyun-docs-content table.api-reference .collapse { border: none; margin-top: 4px; margin-bottom: 4px; } .aliyun-docs-content table.api-reference .collapse .expandable-title-bold { padding: 0; } .aliyun-docs-content table.api-reference .collapse .expandable-title { padding: 0; } .aliyun-docs-content table.api-reference .collapse .expandable-title-bold .title { margin-left: 16px; } .aliyun-docs-content table.api-reference .collapse .expandable-title .title { margin-left: 16px; } .aliyun-docs-content table.api-reference .collapse .expandable-title-bold i.icon { position: absolute; color: #777; font-weight: 100; } .aliyun-docs-content table.api-reference .collapse .expandable-title i.icon { position: absolute; color: #777; font-weight: 100; } .aliyun-docs-content table.api-reference .collapse.expanded .expandable-content { padding: 10px 14px 10px 14px !important; margin: 0; border: 1px solid #e9e9e9; } .aliyun-docs-content table.api-reference .collapse .expandable-title-bold b { font-size: 13px; font-weight: normal; color: #6e6e80; } .aliyun-docs-content table.api-reference .collapse .expandable-title b { font-size: 13px; font-weight: normal; color: #6e6e80; } .aliyun-docs-content table.api-reference .tabbed-content-box { border: none; } .aliyun-docs-content table.api-reference .tabbed-content-box section { padding: 8px 0 !important; } .aliyun-docs-content table.api-reference .tabbed-content-box.mini .tab-box { /\* position: absolute; left: 40px; right: 0; \*/ } .aliyun-docs-content .margin-top-33 { margin-top: 33px !important; } .aliyun-docs-content .two-codeblocks pre { max-height: calc(50vh - 136px) !important; height: auto; } .expandable-content section { border-bottom: 1px solid #e9e9e9; padding-top: 6px; padding-bottom: 4px; } .expandable-content section:last-child { border-bottom: none; } .expandable-content section:first-child { padding-top: 0; }
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,577 @@
本文介绍万相-图像局部重绘模型的输入输出参数。
**相关指南**:[图像局部重绘](https://help.aliyun.com/zh/model-studio/vary-region)
**重要**
- 本文档仅适用于华北2(北京)地域,且必须使用该地域的[API Key](https://bailian.console.aliyun.com/?tab=model#/api-key)。
- wanx-x-painting 模型当前仅提供**免费体验**,免费额度用完后不可调用且不支持付费,推荐参考[图像编辑-千问](https://help.aliyun.com/zh/model-studio/qwen-image-edit-guide)或[图像编辑-万相2.1](https://help.aliyun.com/zh/model-studio/wanx-image-edit)获取替代方案。
**重要**
百炼为华北2(北京)地域推出了业务空间专属域名 `https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com`,**能够为推理请求提供卓越的性能和更高的稳定性**,建议从 `https://dashscope.aliyuncs.com` 迁移至新域名。
其中 `{WorkspaceId}` 为您的业务空间 ID,可在百炼控制台的**业务空间详情**页面查看。现有域名仍可正常使用。
## **模型概览**
**模型效果示意**
![image](https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/1300704371/p885604.png)
**模型简介**
| **模型名称** | **模型简介** |
| --- | --- |
| wanx-x-painting | 万相-图像局部重绘根据用户输入的原始图片、局部区域涂抹图和prompt提示词文字内容,在涂抹区域生成与文字描述相对应的内容,而涂抹区域外的部分则基本保持不变。 |
**模型说明**
| **模型名称** | **计费单价** | **限流(主账号与RAM子账号共用)** | | **免费额度**[(查看)](https://help.aliyun.com/zh/model-studio/new-free-quota) |
| --- | --- | --- | --- | --- |
| **任务下发接口QPS限制** | **同时处理中任务数量** |
| wanx-x-painting | 目前仅供免费体验。 > 免费额度用完后不可调用,推荐参考[图像编辑-千问](https://help.aliyun.com/zh/model-studio/qwen-image-edit-guide)或[图像编辑-万相2.1](https://help.aliyun.com/zh/model-studio/wanx-image-edit)获取替代方案。 | 2 | 1 | 500张 |
更多说明请参见[模型计费及限流说明](#b8457b7223zhp)。
## **前提条件**
图像局部重绘API支持通过HTTP和DashScope SDK进行调用。
在调用前,您需要[获取API Key](https://help.aliyun.com/zh/model-studio/get-api-key),再[配置API Key到环境变量](https://help.aliyun.com/zh/model-studio/configure-api-key-through-environment-variables)。
如需通过SDK进行调用,请[安装DashScope SDK](https://help.aliyun.com/zh/model-studio/install-sdk)。目前,该SDK已支持Python和Java。
## **HTTP调用**
图像模型处理时间较长,为了避免请求超时,HTTP调用仅支持异步获取模型结果。您需要发起两个请求:
1. **创建任务获取任务ID**:首先发起创建任务请求,该请求会返回任务ID(task\_id)。
2. **根据任务ID查询结果**:使用上一步获得的任务ID,查询任务状态及结果。任务成功执行时将返回图像URL,有效期24小时。
**说明**
创建任务后,该任务将被加入到排队队列,等待调度执行。后续需要调用“根据任务ID查询结果接口”获取任务状态及结果。
### **步骤1:创建任务获取任务ID**
`POST https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/image2image/image-synthesis`
| #### **请求参数** | ## 图像局部重绘 ``` curl --location 'https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/image2image/image-synthesis' \\ --header 'X-DashScope-Async: enable' \\ --header "Authorization: Bearer $DASHSCOPE_API_KEY" \\ --header 'Content-Type: application/json' \\ --data '{ "model": "wanx-x-painting", "input": { "prompt": "一只狗戴着红色眼镜", "base_image_url": "http://synthesis-source.oss-accelerate.aliyuncs.com/lingji/validation/mask2img/demo/source3.jpg", "mask_image_url": "http://synthesis-source.oss-accelerate.aliyuncs.com/lingji/validation/mask2img/demo/glasses.png" }, "parameters": { "size": "1024*1024", "n": 1 } }' ``` |
| --- | --- |
| ##### **请求头(Headers)** |
| **Content-Type** `*string*` **(必选)** 请求内容类型。此参数必须设置为`application/json`。 |
| **Authorization** `*string*`**(必选)** 请求身份认证。接口使用阿里云百炼API Key进行身份认证。示例值:Bearer sk-xxxx。 |
| **X-DashScope-Async** `*string*` **(必选)** 异步处理配置参数。HTTP请求只支持异步,**必须设置为**`**enable**`。 **重要** 缺少此请求头将报错:“current user api does not support synchronous calls”。 |
| ##### **请求体(Request Body)** |
| **model** `*string*` **(必选)** 模型名称。示例值:wanx-x-painting。 |
| **input** `*object*` **(必选)** 输入的基本信息,如提示词、图像URL等。 **属性** **prompt** `*string*` **(必选)** 正向提示词,用来描述生成图像中期望包含的元素和视觉特点。 支持中英文,长度不超过75个字符,超过部分会自动截断。 **base\\_image\\_url** `*string*` **(必选)** 输入图像URL地址,不支持填写图像Base64数据。 URL 需为公网可访问的地址,并支持 HTTP 或 HTTPS 协议。您也可在此[获取临时公网URL](https://help.aliyun.com/zh/model-studio/get-temporary-file-url)。 图像限制: - 图像格式:JPG、JPEG、PNG、BMP。 - 图像分辨率:大于256×256像素,小于4096×4096像素。 - 图像大小:不超过10MB。 - URL地址中不能包含中文字符。 **mask\\_image\\_url** `*string*` **(必选)** 用户标记涂抹区域的图像URL地址,需要和base\\_image\\_url图像分辨率保持一致。不支持填写图像Base64数据。 URL 需为公网可访问的地址,并支持 HTTP 或 HTTPS 协议。您也可在此[获取临时公网URL](https://help.aliyun.com/zh/model-studio/get-temporary-file-url)。 图像限制: - 图像格式:JPG、JPEG、PNG、BMP。 - 图像分辨率:大于256×256像素,小于4096×4096像素。 - 图像大小:不超过10MB。 - URL地址中不能包含中文字符。 您可以使用PS或者其他图像编辑工具在原图上涂抹获得掩码图像。 |
| **parameters** `*object*` (可选) 图像处理参数。 **属性** **style** `*string*` (可选) 输出图像的风格。目前支持以下风格取值: - <auto>: 默认。 - <3d cartoon>:3D卡通。 - <anime>:动画。 - <oil painting>: 油画。 - <watercolor>:水彩。 - <sketch>:素描。 - <chinese painting>:中国画。 - <flat illustration>:扁平插画。 **size** `*string*` (可选) 输出图像的分辨率。目前支持3种图像分辨率: - 1024\\*1024:默认值。 - 720\\*1280 - 1280\\*720 **n** `*integer*` (可选) 生成图片的数量。取值范围为1~4张,默认为1。 **mask\\_color** `*array*` (可选) RGB颜色数值列表,用于指定掩码图片中表示涂改区域的颜色。默认值为\\[\\]。 当该字段为空,默认对掩码图片进行二值化处理(处理后白色代表涂抹区域)。当该字段非空时,表示RGB颜色数值列表,代表一种或多种颜色。这些RGB颜色所描绘的区域即为涂改区域,如\\[0,0,0\\]和\\[134,134,134\\],而未被指定的颜色则视为背景色。 示例值:\\[\\[0, 0, 0\\], \\[134, 134, 134\\]\\]。 | |
| #### **响应参数** | #### 成功响应 请保存 task\\_id,用于查询任务状态与结果。 ``` { "output": { "task_status": "PENDING", "task_id": "0385dc79-5ff8-4d82-bcb6-xxxxxx" }, "request_id": "4909100c-7b5a-9f92-bfe5-xxxxxx" } ``` #### 异常响应 创建任务失败,请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)进行解决。 ``` { "code": "InvalidApiKey", "message": "No API-key provided.", "request_id": "7438d53d-6eb8-4596-8835-xxxxxx" } ``` |
| --- | --- |
| **output** `*object*` 任务输出信息。 属性 **task\\_id** `*string*` 任务ID。查询有效期24小时。 **task\\_status** `*string*` 任务状态。 **枚举值** - PENDING:任务排队中 - RUNNING:任务处理中 - SUCCEEDED:任务执行成功 - FAILED:任务执行失败 - CANCELED:任务已取消 - UNKNOWN:任务不存在或状态未知 |
| **request\\_id** `*string*` 请求唯一标识。可用于请求明细溯源和问题排查。 |
| **code** `*string*` 请求失败的错误码。请求成功时不会返回此参数,详情请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)。 |
| **message** `*string*` 请求失败的详细信息。请求成功时不会返回此参数,详情请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)。 |
### 步骤2:根据任务ID查询结果
`GET https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/tasks/{task_id}`
| #### 请求参数 | #### 查询任务结果 请将`86ecf553-d340-4e21-xxxxxxxxx`替换为真实的task\\_id。 > 若使用新加坡地域的模型,需将base\\_url替换为https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api/v1/tasks/86ecf553-d340-4e21-xxxxxxxxx,其中WorkspaceId需替换为真实的业务空间ID。 ``` curl -X GET https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/tasks/86ecf553-d340-4e21-xxxxxxxxx \\ --header "Authorization: Bearer $DASHSCOPE_API_KEY" ``` |
| --- | --- |
| #### **请求头(Headers)** |
| **Authorization** `*string*`**(必选)** 请求身份认证。接口使用阿里云百炼API Key进行身份认证。示例值:Bearer sk-xxxx。 |
| #### **URL路径参数(Path parameters)** |
| **task\\_id** `*string*`**(必选)** 任务ID。 |
| #### **响应参数** | #### 任务执行成功 任务数据(如任务状态、图像URL等)仅保留24小时,超时后会被自动清除。请您务必及时保存生成的图像。 ``` { "request_id": "85eaba38-0185-99d7-8d16-4d9135238846", "output": { "task_id": "86ecf553-d340-4e21-af6e-a0c6a421c010", "task_status": "SUCCEEDED", "results": [ { "url": "https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/123/a1.png" }, { "url": "https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/123/b2.png" } ], "task_metrics": { "TOTAL": 2, "SUCCEEDED": 2, "FAILED": 0 } }, "usage": { "image_count": 2 } } ``` #### 任务执行失败 若任务执行失败,task\\_status将置为 FAILED,并提供错误码和信息。请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)进行解决。 ``` { "request_id": "e5d70b02-ebd3-98ce-9fe8-759d7d7b107d", "output": { "task_id": "86ecf553-d340-4e21-af6e-a0c6a421c010", "task_status": "FAILED", "code": "InvalidParameter", "message": "The size is not match the allowed size ['1024*1024', '720*1280', '1280*720']", "task_metrics": { "TOTAL": 4, "SUCCEEDED": 0, "FAILED": 4 } } } ``` #### 任务部分失败 模型可以在一次任务中生成多张图片。只要有一张图片生成成功,任务状态将标记为`SUCCEEDED`,并且返回相应的图像URL。对于生成失败的图片,结果中会返回相应的失败原因。同时在usage统计中,只会对成功的结果计数。请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)进行解决。 ``` { "request_id": "85eaba38-0185-99d7-8d16-4d9135238846", "output": { "task_id": "86ecf553-d340-4e21-af6e-a0c6a421c010", "task_status": "SUCCEEDED", "results": [ { "url": "https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/123/a1.png" }, { "code": "InternalError.Timeout", "message": "An internal timeout error has occured during execution, please try again later or contact service support." } ], "task_metrics": { "TOTAL": 2, "SUCCEEDED": 1, "FAILED": 1 } }, "usage": { "image_count": 1 } } ``` |
| --- | --- |
| **output** `*object*` 任务输出信息。 **属性** **task\\_id** `*string*` 任务ID。查询有效期24小时。 **task\\_status** `*string*` 任务状态。 **枚举值** - PENDING:任务排队中 - RUNNING:任务处理中 - SUCCEEDED:任务执行成功 - FAILED:任务执行失败 - CANCELED:任务已取消 - UNKNOWN:任务不存在或状态未知 **task\\_metrics** `*object*` 任务结果统计。 **属性** **TOTAL** `*integer*` 总的任务数。 **SUCCEEDED** `*integer*` 任务状态为成功的任务数。 **FAILED** `*integer*` 任务状态为失败的任务数。 **results** `*array of object*` 任务结果列表,包括图像URL、部分任务执行失败报错信息等。 **数据结构** ``` { "results": [ { "url": "" }, { "code": "", "message": "" } ] } ``` **code** `*string*` 请求失败的错误码。请求成功时不会返回此参数,详情请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)。 **message** `*string*` 请求失败的详细信息。请求成功时不会返回此参数,详情请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)。 |
| **usage** `*object*` 输出信息统计。只对成功的结果计数。 **属性** **image\\_count** `*integer*` 模型成功生成图片的数量。计费公式:费用 = 图片数量 × 单价。 | |
| **request\\_id** `*string*` 请求唯一标识。可用于请求明细溯源和问题排查。 | |
## DashScope SDK调用
请先确认已安装最新版DashScope SDK:[安装SDK](https://help.aliyun.com/zh/model-studio/install-sdk)。
DashScope SDK目前已支持Python和Java。
SDK与HTTP接口的参数名基本一致,参数结构根据不同语言的SDK封装而定。参数说明可参考[HTTP调用](https://help.aliyun.com/zh/model-studio/text-to-image-api-reference#42703589880ts)。
由于图像模型处理时间较长,底层服务采用异步方式提供。SDK在上层进行了封装,支持同步、异步两种调用方式。
### Python SDK调用
## 同步调用
##### **请求示例**
```
from http import HTTPStatus
from urllib.parse import urlparse, unquote
from pathlib import PurePosixPath
import requests
from dashscope import ImageSynthesis
# 以下为华北2(北京)地域的配置,调用时请将WorkspaceId替换为真实的业务空间ID,各地域的配置不同。
dashscope.base_http_api_url = "https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1"
prompt = "一只狗戴着红色眼镜"
model = "wanx-x-painting"
task = "image2image"
extra_input = {
"base_image_url": "http://synthesis-source.oss-accelerate.aliyuncs.com/lingji/validation/mask2img/demo/source3.jpg",
"mask_image_url": "http://synthesis-source.oss-accelerate.aliyuncs.com/lingji/validation/mask2img/demo/glasses.png"
}
print('----sync call, please wait a moment----')
rsp = ImageSynthesis.call(model=model,
prompt=prompt,
n=1,
size='1024*1024',
task=task,
extra_input=extra_input)
if rsp.status_code == HTTPStatus.OK:
print(rsp)
# save file to current directory
for result in rsp.output.results:
file_name = PurePosixPath(unquote(urlparse(result.url).path)).parts[-1]
with open('./%s' % file_name, 'wb+') as f:
f.write(requests.get(result.url).content)
else:
print('sync_call Failed, status_code: %s, code: %s, message: %s' %
(rsp.status_code, rsp.code, rsp.message))
```
##### **响应示例**
```
{
"status_code": 200,
"request_id": "4126d9dd-e037-9f32-8d56-6d29ab3f9a06",
"code": null,
"message": "",
"output": {
"task_id": "b476bc4e-35c1-4c4e-a4d9-xxxxxxx",
"task_status": "SUCCEEDED",
"results": [{
"url": "https://dashscope-result-sh.oss-cn-shanghai.aliyuncs.com/xxxx.png"
}],
"submit_time": "2024-11-01 09:50:56.081",
"scheduled_time": "2024-11-01 09:50:56.104",
"end_time": "2024-11-01 09:51:22.740",
"task_metrics": {
"TOTAL": 1,
"SUCCEEDED": 1,
"FAILED": 0
}
},
"usage": {
"image_count": 1
}
}
```
## 异步调用
##### **请求示例**
```
from http import HTTPStatus
from urllib.parse import urlparse, unquote
from pathlib import PurePosixPath
import requests
from dashscope import ImageSynthesis
# 以下为华北2(北京)地域的配置,调用时请将WorkspaceId替换为真实的业务空间ID,各地域的配置不同。
dashscope.base_http_api_url = "https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1"
prompt = "一只狗戴着红色眼镜"
model = "wanx-x-painting"
task = "image2image"
extra_input = {
"base_image_url": "http://synthesis-source.oss-accelerate.aliyuncs.com/lingji/validation/mask2img/demo/source3.jpg",
"mask_image_url": "http://synthesis-source.oss-accelerate.aliyuncs.com/lingji/validation/mask2img/demo/glasses.png"
}
def async_call():
print('----create task----')
task_info = create_async_task()
print('----wait task done then save image----')
wait_async_task(task_info)
# 创建异步任务
def create_async_task():
rsp = ImageSynthesis.async_call(model=model,
prompt=prompt,
n=1,
size='1024*1024',
task=task,
extra_input=extra_input)
print(rsp)
if rsp.status_code == HTTPStatus.OK:
print(rsp.output)
else:
print('create_async_task Failed, status_code: %s, code: %s, message: %s' %
(rsp.status_code, rsp.code, rsp.message))
return rsp
# 等待异步任务结束
def wait_async_task(task):
rsp = ImageSynthesis.wait(task)
print(rsp)
if rsp.status_code == HTTPStatus.OK:
print(rsp.output.task_status)
# save file to current directory
for result in rsp.output.results:
file_name = PurePosixPath(unquote(urlparse(result.url).path)).parts[-1]
with open('./%s' % file_name, 'wb+') as f:
f.write(requests.get(result.url).content)
else:
print('Failed, status_code: %s, code: %s, message: %s' %
(rsp.status_code, rsp.code, rsp.message))
if __name__ == '__main__':
async_call()
```
##### **响应示例**
**1、创建任务的响应示例**
```
{
"status_code": 200,
"request_id": "31b04171-011c-96bd-ac00-f0383b669cc7",
"code": "",
"message": "",
"output": {
"task_id": "4f90cf14-a34e-4eae-xxxxxxxx",
"task_status": "PENDING",
"results": []
},
"usage": null
}
```
**2、查询任务结果的响应示例**
```
{
"status_code": 200,
"request_id": "d861d3ba-4b29-9491-abad-266ef4fb2f08",
"code": null,
"message": "",
"output": {
"task_id": "4f90cf14-a34e-4eae-xxxxxxxx",
"task_status": "SUCCEEDED",
"results": [{
"url": "https://dashscope-result-hz.oss-cn-hangzhou.aliyuncs.com/xxxx.png"
}],
"submit_time": "2024-10-31 20:40:35.631",
"scheduled_time": "2024-10-31 20:40:35.684",
"end_time": "2024-10-31 20:41:02.700",
"task_metrics": {
"TOTAL": 1,
"SUCCEEDED": 1,
"FAILED": 0
}
},
"usage": {
"image_count": 1
}
}
```
### Java SDK调用
## 同步调用
##### 请求示例
```
// Copyright (c) Alibaba, Inc. and its affiliates.
import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesis;
import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesisParam;
import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesisResult;
import com.alibaba.dashscope.exception.ApiException;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.alibaba.dashscope.utils.JsonUtils;
import java.util.HashMap;
import com.alibaba.dashscope.utils.Constants;
public class Main {
static {Constants.baseHttpApiUrl="https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1";}
public void syncCall() {
String task = "image2image";
ImageSynthesis imageSynthesis = new ImageSynthesis(task);
ImageSynthesisParam param = genImageSynthesis();
ImageSynthesisResult result = null;
try {
System.out.println("---sync call, please wait a moment----");
result = imageSynthesis.call(param);
} catch (ApiException | NoApiKeyException e){
throw new RuntimeException(e.getMessage());
}
System.out.println(JsonUtils.toJson(result));
}
private ImageSynthesisParam genImageSynthesis(){
HashMap<String,Object> extraInputMap = new HashMap<>();
extraInputMap.put("base_image_url", "http://synthesis-source.oss-accelerate.aliyuncs.com/lingji/validation/mask2img/demo/source3.jpg");
extraInputMap.put("mask_image_url", "http://synthesis-source.oss-accelerate.aliyuncs.com/lingji/validation/mask2img/demo/glasses.png");
String prompt = "一只狗戴着红色眼镜";
String model = "wanx-x-painting";
return ImageSynthesisParam.builder()
.model(model)
.prompt(prompt)
.n(1)
.size("1024*1024")
.extraInputs(extraInputMap)
.build();
}
public static void main(String[] args){
Main text2Image = new Main();
text2Image.syncCall();
}
}
```
##### **响应示例**
```
{
"request_id": "150edcda-05d5-9ffe-8803-84626d1db623",
"output": {
"task_id": "f2098ff0-146e-404c-bb25-xxxxxxxx",
"task_status": "SUCCEEDED",
"results": [{
"url": "https://dashscope-result-hz.oss-cn-hangzhou.aliyuncs.com/xxxx.png"
}],
"task_metrics": {
"TOTAL": 1,
"SUCCEEDED": 1,
"FAILED": 0
}
},
"usage": {
"image_count": 1
}
}
```
## 异步调用
##### **请求示例**
```
// Copyright (c) Alibaba, Inc. and its affiliates.
import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesis;
import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesisParam;
import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesisResult;
import com.alibaba.dashscope.exception.ApiException;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.alibaba.dashscope.utils.JsonUtils;
import java.util.HashMap;
import com.alibaba.dashscope.utils.Constants;
public class Main {
static {Constants.baseHttpApiUrl="https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1";}
public void asyncCall() {
System.out.println("---create task----");
String taskId = this.createAsyncTask();
System.out.println("---wait task done then return image url----");
this.waitAsyncTask(taskId);
}
/**
* 创建异步任务
* @return taskId
*/
public String createAsyncTask() {
String task = "image2image";
ImageSynthesis imageSynthesis = new ImageSynthesis(task);
ImageSynthesisParam param = genImageSynthesis();
ImageSynthesisResult result = null;
try {
result = imageSynthesis.asyncCall(param);
} catch (ApiException | NoApiKeyException e){
throw new RuntimeException(e.getMessage());
}
String taskId = result.getOutput().getTaskId();
System.out.println("taskId=" + taskId);
return taskId;
}
private ImageSynthesisParam genImageSynthesis(){
String prompt = "一只狗戴着红色眼镜";
String model = "wanx-x-painting";
HashMap<String,Object> extraInputMap = new HashMap<>();
extraInputMap.put("base_image_url", "http://synthesis-source.oss-accelerate.aliyuncs.com/lingji/validation/mask2img/demo/source3.jpg");
extraInputMap.put("mask_image_url", "http://synthesis-source.oss-accelerate.aliyuncs.com/lingji/validation/mask2img/demo/glasses.png");
return ImageSynthesisParam.builder()
.model(model)
.prompt(prompt)
.n(1)
.size("1024*1024")
.extraInputs(extraInputMap)
.build();
}
/**
* 等待异步任务结束
* @param taskId 任务id
* */
public void waitAsyncTask(String taskId) {
ImageSynthesis imageSynthesis = new ImageSynthesis();
ImageSynthesisResult result = null;
try {
// If you have set the DASHSCOPE_API_KEY in the system environment variable, the apiKey can be null.
result = imageSynthesis.wait(taskId, null);
} catch (ApiException | NoApiKeyException e){
throw new RuntimeException(e.getMessage());
}
System.out.println(JsonUtils.toJson(result.getOutput()));
System.out.println(JsonUtils.toJson(result.getUsage()));
}
public static void main(String[] args){
Main text2Image = new Main();
text2Image.asyncCall();
}
}
```
##### **响应示例**
**1、创建任务的响应示例**
```
{
"request_id": "5dbf9dc5-4f4c-9605-85ea-542f97709ba8",
"output": {
"task_id": "7277e20e-aa01-4709-xxxxxxxx",
"task_status": "PENDING"
}
}
```
**2、查询任务结果的响应示例**
**output字段内容:**
```
{
"task_id": "7277e20e-aa01-4709-xxxxxxxx",
"task_status": "SUCCEEDED",
"results": [{
"url": "https://dashscope-result-hz.oss-cn-hangzhou.aliyuncs.com/xxxx.png"
}],
"task_metrics": {
"TOTAL": 1,
"SUCCEEDED": 1,
"FAILED": 0
}
}
```
**usage字段内容:**
```
{
"image_count": 1
}
```
## **错误码**
如果模型调用失败并返回报错信息,请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)进行解决。
## 常见问题
### 模型计费及限流说明
**免费额度**
- 额度说明:免费额度是指模型成功生成的输出图片数量。输入图片及模型处理失败的情况不占用免费额度。
- 领取方式:开通阿里云百炼大模型服务后自动发放,有效期90天。
- 使用账号:阿里云主账号与其RAM子账号共享免费额度。
- 更多详情请参见[新人免费额度](https://help.aliyun.com/zh/model-studio/new-free-quota)。
**限时免费**
- 当计费为限时免费时,表示该模型处于公测阶段,免费额度用尽后不可使用。
**计费说明**
- 当计费有明确单价时,如0.2元/秒,表示该模型已商业化,免费额度用尽或过期后需付费使用。
- 计费项:只对模型成功生成的输出图片进行收费,其余情况暂不计费。
- 付费方式:由阿里云主账号统一付费。RAM子账号不能独立计量计费,必须由所属的主账号付费。如果您需要查询账单信息,请前往阿里云控制台[账单概览](https://billing-cost.console.aliyun.com/finance/month-bill/account)。
- 充值途径:您可以在阿里云控制台[费用与成本](https://billing-cost.console.aliyun.com/home?spm=a2c4g.11186623.0.0.2d543048F4KRQP)页面进行充值。
- 模型调用情况:您可以前往阿里云百炼的[模型观测](https://bailian.console.aliyun.com/#/model-telemetry)查看模型调用量及调用次数。
- 更多计费问题请参见[计费项](https://help.aliyun.com/zh/model-studio/billing-for-model-studio)。
**限流**
- 限流说明:阿里云主账号与其RAM子账号共享限流限制。
### **图像局部重绘API通用问题**
1. **输入图像的掩码图像(mask\_image)有什么限制?**
- 只能使用图像URL地址,暂不支持填写图像Base64数据。
- 需要和输入图像的分辨率保持一致。
- 文档中的示例掩码图像为白色背景,实际上,掩码图像并不要求必须为白色背景,可以通过`mask_color`字段指定其他颜色。
2. **如何获得输入图像的掩码图像?**
您可以使用PS或者其他图像编辑工具在原图上涂抹获得掩码图像。
3. **使用****图像局部重绘****功能时,若涂抹区域外的图片输出效果不理想,应如何处理?**
建议您使用[通用图像编辑](https://help.aliyun.com/zh/model-studio/wanx-image-edit-api-reference)的局部重绘功能重新生成。
/\* 调整 table 宽度 \*/ .aliyun-docs-content table.medium-width { max-width: 1018px; width: 100%; } .aliyun-docs-content table.table-no-border tr td:first-child { padding-left: 0; } .aliyun-docs-content table.table-no-border tr td:last-child { padding-right: 0; } /\* 支持吸顶 \*/ div:has(.aliyun-docs-content), .aliyun-docs-content .markdown-body { overflow: visible; } .stick-top { position: sticky; top: 46px; } /\*\*代码块字体\*\*/ /\* 减少表格中的代码块 margin,让表格信息显示更紧凑 \*/ .unionContainer .markdown-body table .help-code-block { margin: 0 !important; } /\* 减少表格中的代码块字号,让表格信息显示更紧凑 \*/ .unionContainer .markdown-body .help-code-block pre { font-size: 12px !important; } /\* 减少表格中的代码块字号,让表格信息显示更紧凑 \*/ .unionContainer .markdown-body .help-code-block pre code { font-size: 12px !important; } /\*\* API Reference 表格 \*\*/ .aliyun-docs-content table.api-reference tr td:first-child { margin: 0px; border-bottom: 1px solid #d8d8d8; } .aliyun-docs-content table.api-reference tr:last-child td:first-child { border-bottom: none; } .aliyun-docs-content table.api-reference p { color: #6e6e80; } .aliyun-docs-content table.api-reference b, i { color: #181818; } .aliyun-docs-content table.api-reference .collapse { border: none; margin-top: 4px; margin-bottom: 4px; } .aliyun-docs-content table.api-reference .collapse .expandable-title-bold { padding: 0; } .aliyun-docs-content table.api-reference .collapse .expandable-title { padding: 0; } .aliyun-docs-content table.api-reference .collapse .expandable-title-bold .title { margin-left: 16px; } .aliyun-docs-content table.api-reference .collapse .expandable-title .title { margin-left: 16px; } .aliyun-docs-content table.api-reference .collapse .expandable-title-bold i.icon { position: absolute; color: #777; font-weight: 100; } .aliyun-docs-content table.api-reference .collapse .expandable-title i.icon { position: absolute; color: #777; font-weight: 100; } .aliyun-docs-content table.api-reference .collapse.expanded .expandable-content { padding: 10px 14px 10px 14px !important; margin: 0; border: 1px solid #e9e9e9; } .aliyun-docs-content table.api-reference .collapse .expandable-title-bold b { font-size: 13px; font-weight: normal; color: #6e6e80; } .aliyun-docs-content table.api-reference .collapse .expandable-title b { font-size: 13px; font-weight: normal; color: #6e6e80; } .aliyun-docs-content table.api-reference .tabbed-content-box { border: none; } .aliyun-docs-content table.api-reference .tabbed-content-box section { padding: 8px 0 !important; } .aliyun-docs-content table.api-reference .tabbed-content-box.mini .tab-box { /\* position: absolute; left: 40px; right: 0; \*/ } .aliyun-docs-content .margin-top-33 { margin-top: 33px !important; } .aliyun-docs-content .two-codeblocks pre { max-height: calc(50vh - 136px) !important; height: auto; } .expandable-content section { border-bottom: 1px solid #e9e9e9; padding-top: 6px; padding-bottom: 4px; } .expandable-content section:last-child { border-bottom: none; } .expandable-content section:first-child { padding-top: 0; }
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,545 @@
本文介绍万相-涂鸦作画模型的API输入输出参数。
**相关指南**:[涂鸦作画](https://help.aliyun.com/zh/model-studio/sketch-to-image)
**重要**
本文档仅适用于华北2(北京)地域,且必须使用该地域的[API Key](https://bailian.console.aliyun.com/?tab=model#/api-key)。
**重要**
百炼为华北2(北京)地域推出了业务空间专属域名 `https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com`,**能够为推理请求提供卓越的性能和更高的稳定性**,建议从 `https://dashscope.aliyuncs.com` 迁移至新域名。
其中 `{WorkspaceId}` 为您的业务空间 ID,可在百炼控制台的**业务空间详情**页面查看。现有域名仍可正常使用。
## **模型概览**
**模型效果示意**
![image](https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/2400704371/p883780.png)
**模型简介**
| **模型名称** | **模型简介** |
| --- | --- |
| wanx-sketch-to-image-lite | 万相-涂鸦作画通过手绘图案和文字描述,生成精美的涂鸦绘画作品。 |
**模型说明**
| **模型名称** | **计费单价** | **限流(主账号与RAM子账号共用)** | | **免费额度**[(查看)](https://help.aliyun.com/zh/model-studio/new-free-quota) |
| --- | --- | --- | --- | --- |
| **任务下发接口QPS限制** | **同时处理中任务数量** |
| wanx-sketch-to-image-lite | 0.06元/张 | 2 | 1 | 500张 |
更多说明请参见[模型计费及限流](#b8457b7223zhp)。
## **前提条件**
涂鸦作画API支持通过HTTP和DashScope SDK进行调用。
在调用前,您需要[获取API Key](https://help.aliyun.com/zh/model-studio/get-api-key),再[配置API Key到环境变量](https://help.aliyun.com/zh/model-studio/configure-api-key-through-environment-variables)。
如需通过SDK进行调用,请[安装DashScope SDK](https://help.aliyun.com/zh/model-studio/install-sdk)。目前,该SDK已支持Python和Java。
## HTTP调用
图像模型处理时间较长,为了避免请求超时,HTTP调用仅支持异步获取模型结果。您需要发起两个请求:
1. **创建任务获取任务ID**:首先发起创建任务请求,该请求会返回任务ID(task\_id)。
2. **根据任务ID查询结果**:使用上一步获得的任务ID,查询任务状态及结果。任务成功执行时将返回图像URL,有效期24小时。
**说明**
创建任务后,该任务将被加入到排队队列,等待调度执行。后续需要调用“根据任务ID查询结果接口”获取任务状态及结果。
### **步骤1:创建任务获取任务ID**
`POST https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/image2image/image-synthesis/`
| #### 请求参数 | ## curl ``` curl --location 'https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/image2image/image-synthesis' \\ --header 'X-DashScope-Async: enable' \\ --header "Authorization: Bearer $DASHSCOPE_API_KEY" \\ --header 'Content-Type: application/json' \\ --data '{ "model": "wanx-sketch-to-image-lite", "input": { "sketch_image_url": "https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/6609471071/p743851.jpg", "prompt": "一棵参天大树" }, "parameters": { "size": "768*768", "n": 2, "sketch_weight": 3, "style": "<watercolor>" } }' ``` |
| --- | --- |
| ##### **请求头(Headers)** |
| **Content-Type** `*string*` **(必选)** 请求内容类型。此参数必须设置为`application/json`。 |
| **Authorization** `*string*`**(必选)** 请求身份认证。接口使用阿里云百炼API Key进行身份认证。示例值:Bearer sk-xxxx。 |
| **X-DashScope-Async** `*string*` **(必选)** 异步处理配置参数。HTTP请求只支持异步,**必须设置为**`**enable**`。 **重要** 缺少此请求头将报错:“current user api does not support synchronous calls”。 |
| **X-DashScope-WorkSpace** `*string*` (可选) 阿里云百炼业务空间ID。示例值:llm-xxxx。 您可以在此[获取Workspace ID](https://help.aliyun.com/zh/model-studio/obtain-the-app-id-and-workspace-id#d3eb3cd37b7fu)。 **详细说明** 此参数根据阿里云百炼API Key进行填写。 - 若为主账号API Key,可不填。不填则使用主账号权限,填写则使用对应的业务空间权限。 - 若为RAM子账号API Key,则必填。RAM子账号一定归属于某个业务空间。 业务空间必须具备访问模型的权限,才能调用API。若无权限,请参考[授权子业务空间模型调用、训练和部署](https://help.aliyun.com/zh/model-studio/use-workspace#f2e68d7ba7ubk)。 > 关于如何区分阿里云百炼主账号和RAM子账号,请参考[主账号管理](https://help.aliyun.com/zh/model-studio/business-space-management)。 |
| ##### **请求体(Request Body)** |
| **model** `*string*` **(必选)** 调用模型。 |
| **input** `*object*` **(必选)** 输入的基本信息,比如提示词、图像URL地址。 **属性** **prompt** `*string*` **(必选)** 提示词,用来描述生成图像中期望包含的元素和视觉特点。 支持中英文,长度不超过75个字符,超过部分会自动截断。 示例值:一棵参天大树。 **sketch\\_image\\_url** `*string*` **(必选)** 输入草图的URL地址。输入草图需要与输出图像的分辨率比例保持一致,否则会导致图片拉伸变形,建议使用白色背景图。 URL 需为公网可访问的地址,并支持 HTTP 或 HTTPS 协议。您也可在此[获取临时公网URL](https://help.aliyun.com/zh/model-studio/get-temporary-file-url)。 图像限制: - 图像格式:JPG、JPEG、PNG、TIFF、WEBP。 - 图像分辨率:不小于256×256像素且不超过2048×2048像素。 - 图像大小:不超过10 MB。 - URL地址中不能包含中文字符。 草图示例: ![image](https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/9289386271/p850798.png) |
| **parameters** `*object*` (可选) 图像处理参数。 **属性** **style** `*string*` (可选) 输出图像的风格,目前支持以下风格取值: - <auto>:默认值,由模型随机输出图像风格。 - <3d cartoon>:3D卡通。 - <anime>:二次元。 - <oil painting>:油画。 - <watercolor>:水彩。 - <sketch>:素描。 - <chinese painting>:中国画。 - <flat illustration>:扁平插画。 **size** `*string*` (可选) 输出图像的分辨率。目前仅支持一种图像分辨率:768\\*768,且为默认值。 **n** `*integer*` (可选) 生成图片的数量。取值范围为1~4张,默认为4。 **sketch\\_weight** `*integer*` (可选) 输入草图对输出图像的约束程度。 取值范围为0-10,取值间隔为1, 默认值为10。取值越大表示输出图像跟输入草图越相似。 **sketch\\_extraction** `*boolean*` (可选) 如果上传图片是RGB图片,而非草图(sketch线稿),此参数可控制是否对输入图片进行sketch边缘提取。 默认值为False,表示不进行提取。设置为True时,表示进行提取,此时,`sketch_color`字段失效。 **sketch\\_color** `*array*` (可选) 此字段在`sketch_extraction=false`时生效,所包含数值均被视为画笔色,其余数值均会视为背景色。模型会基于一种或多种画笔色描绘的区域生成新的画作。默认值为\\[\\]。 当sketch\\_image\\_url线稿中的线条不是黑色,而是包含其他一种或多种颜色时,可以指定一个或多个RGB颜色数值作为画笔色。 示例值:\\[\\[134, 134, 134\\], \\[0, 0, 0\\]\\] |
| #### **响应参数** | #### 成功响应 请保存 task\\_id,用于查询任务状态与结果。 ``` { "output": { "task_status": "PENDING", "task_id": "0385dc79-5ff8-4d82-bcb6-xxxxxx" }, "request_id": "4909100c-7b5a-9f92-bfe5-xxxxxx" } ``` #### 异常响应 创建任务失败,请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)进行解决。 ``` { "code": "InvalidApiKey", "message": "No API-key provided.", "request_id": "7438d53d-6eb8-4596-8835-xxxxxx" } ``` |
| --- | --- |
| **output** `*object*` 任务输出信息。 **属性** **task\\_id** `*string*` 任务ID。查询有效期24小时。 **task\\_status** `*string*` 任务状态。 **枚举值** - PENDING:任务排队中 - RUNNING:任务处理中 - SUCCEEDED:任务执行成功 - FAILED:任务执行失败 - CANCELED:任务已取消 - UNKNOWN:任务不存在或状态未知 |
| **request\\_id** `*string*` 请求唯一标识。可用于请求明细溯源和问题排查。 |
| **code** `*string*` 请求失败的错误码。请求成功时不会返回此参数,详情请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)。 |
| **message** `*string*` 请求失败的详细信息。请求成功时不会返回此参数,详情请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)。 |
### 步骤2:根据任务ID查询结果
`GET https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/tasks/{task_id}`
| #### 请求参数 | #### 查询任务结果 请将`86ecf553-d340-4e21-xxxxxxxxx`替换为真实的task\\_id。 > 若使用新加坡地域的模型,需将base\\_url替换为https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api/v1/tasks/86ecf553-d340-4e21-xxxxxxxxx,其中WorkspaceId需替换为真实的业务空间ID。 ``` curl -X GET https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/tasks/86ecf553-d340-4e21-xxxxxxxxx \\ --header "Authorization: Bearer $DASHSCOPE_API_KEY" ``` |
| --- | --- |
| #### **请求头(Headers)** |
| **Authorization** `*string*`**(必选)** 请求身份认证。接口使用阿里云百炼API Key进行身份认证。示例值:Bearer sk-xxxx。 |
| #### **URL路径参数(Path parameters)** |
| **task\\_id** `*string*`**(必选)** 任务ID。 |
| #### **响应参数** | #### 任务执行成功 任务数据(如任务状态、图像URL等)仅保留24小时,超时后会被自动清除。请您务必及时保存生成的图像。 ``` { "request_id": "85eaba38-0185-99d7-8d16-4d9135238846", "output": { "task_id": "86ecf553-d340-4e21-af6e-a0c6a421c010", "task_status": "SUCCEEDED", "results": [ { "url": "https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/123/a1.png" }, { "url": "https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/123/b2.png" } ], "task_metrics": { "TOTAL": 2, "SUCCEEDED": 2, "FAILED": 0 } }, "usage": { "image_count": 2 } } ``` #### 任务执行失败 若任务执行失败,task\\_status将置为 FAILED,并提供错误码和信息。请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)进行解决。 ``` { "request_id": "e5d70b02-ebd3-98ce-9fe8-759d7d7b107d", "output": { "task_id": "86ecf553-d340-4e21-af6e-a0c6a421c010", "task_status": "FAILED", "code": "InvalidParameter", "message": "The size is not match the allowed size ['1024*1024', '720*1280', '1280*720']", "task_metrics": { "TOTAL": 4, "SUCCEEDED": 0, "FAILED": 4 } } } ``` #### 任务部分失败 模型可以在一次任务中生成多张图片。只要有一张图片生成成功,任务状态将标记为`SUCCEEDED`,并且返回相应的图像URL。对于生成失败的图片,结果中会返回相应的失败原因。同时在usage统计中,只会对成功的结果计数。请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)进行解决。 ``` { "request_id": "85eaba38-0185-99d7-8d16-4d9135238846", "output": { "task_id": "86ecf553-d340-4e21-af6e-a0c6a421c010", "task_status": "SUCCEEDED", "results": [ { "url": "https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/123/a1.png" }, { "code": "InternalError.Timeout", "message": "An internal timeout error has occured during execution, please try again later or contact service support." } ], "task_metrics": { "TOTAL": 2, "SUCCEEDED": 1, "FAILED": 1 } }, "usage": { "image_count": 1 } } ``` |
| --- | --- |
| **output** `*object*` 任务输出信息。 **属性** **task\\_id** `*string*` 任务ID。查询有效期24小时。 **task\\_status** `*string*` 任务状态。 **枚举值** - PENDING:任务排队中 - RUNNING:任务处理中 - SUCCEEDED:任务执行成功 - FAILED:任务执行失败 - CANCELED:任务已取消 - UNKNOWN:任务不存在或状态未知 **task\\_metrics** `*object*` 任务结果统计。 **属性** **TOTAL** `*integer*` 总的任务数。 **SUCCEEDED** `*integer*` 任务状态为成功的任务数。 **FAILED** `*integer*` 任务状态为失败的任务数。 **results** `*array of object*` 任务结果列表,包括图像URL、部分任务执行失败报错信息等。 **数据结构** ``` { "results": [ { "url": "" }, { "code": "", "message": "" } ] } ``` **code** `*string*` 请求失败的错误码。请求成功时不会返回此参数,详情请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)。 **message** `*string*` 请求失败的详细信息。请求成功时不会返回此参数,详情请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)。 |
| **usage** `*object*` 输出信息统计。只对成功的结果计数。 **属性** **image\\_count** `*integer*` 模型成功生成图片的数量。计费公式:费用 = 图片数量 × 单价。 | |
| **request\\_id** `*string*` 请求唯一标识。可用于请求明细溯源和问题排查。 | |
## DashScope SDK调用
请先确认已安装最新版DashScope SDK:[安装SDK](https://help.aliyun.com/zh/model-studio/install-sdk)。
DashScope SDK目前已支持Python和Java。
SDK与HTTP接口的参数名基本一致,参数结构根据不同语言的SDK封装而定。参数说明可参考[HTTP调用](https://help.aliyun.com/zh/model-studio/text-to-image-api-reference#42703589880ts)。
由于图像模型处理时间较长,底层服务采用异步方式提供。SDK在上层进行了封装,支持同步、异步两种调用方式。
### Python SDK调用
## 同步调用
##### **请求示例**
```
from http import HTTPStatus
from urllib.parse import urlparse, unquote
from pathlib import PurePosixPath
import requests
import dashscope
from dashscope import ImageSynthesis
import os
dashscope.base_http_api_url = 'https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1'
prompt = "一棵参天大树"
sketch_image_url = "https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/6609471071/p743851.jpg"
model = "wanx-sketch-to-image-lite"
task = "image2image"
print('----sync call, please wait a moment----')
rsp = ImageSynthesis.call(api_key=os.getenv("DASHSCOPE_API_KEY"),
model=model,
prompt=prompt,
n=1,
style='<watercolor>',
size='768*768',
sketch_image_url=sketch_image_url,
task=task)
print('response: %s' % rsp)
if rsp.status_code == HTTPStatus.OK:
print(rsp.output)
# save file to current directory
for result in rsp.output.results:
file_name = PurePosixPath(unquote(urlparse(result.url).path)).parts[-1]
with open('./%s' % file_name, 'wb+') as f:
f.write(requests.get(result.url).content)
else:
print('sync_call Failed, status_code: %s, code: %s, message: %s' %
(rsp.status_code, rsp.code, rsp.message))
```
##### **响应示例**
```
{
"status_code": 200,
"request_id": "4126d9dd-e037-9f32-8d56-6d29ab3f9a06",
"code": null,
"message": "",
"output": {
"task_id": "b476bc4e-35c1-4c4e-a4d9-xxxxxxx",
"task_status": "SUCCEEDED",
"results": [{
"url": "https://dashscope-result-sh.oss-cn-shanghai.aliyuncs.com/xxxx.png"
}],
"submit_time": "2024-11-01 09:50:56.081",
"scheduled_time": "2024-11-01 09:50:56.104",
"end_time": "2024-11-01 09:51:22.740",
"task_metrics": {
"TOTAL": 1,
"SUCCEEDED": 1,
"FAILED": 0
}
},
"usage": {
"image_count": 1
}
}
```
## 异步调用
##### **请求示例**
```
from http import HTTPStatus
from urllib.parse import urlparse, unquote
from pathlib import PurePosixPath
import requests
import dashscope
from dashscope import ImageSynthesis
import os
dashscope.base_http_api_url = 'https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1'
prompt = "一棵参天大树"
sketch_image_url = "https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/6609471071/p743851.jpg"
model = "wanx-sketch-to-image-lite"
task = "image2image"
# 异步调用
def async_call():
print('----create task----')
task_info = create_async_task()
print('----wait task done then save image----')
wait_async_task(task_info)
# 创建异步任务
def create_async_task():
rsp = ImageSynthesis.async_call(api_key=os.getenv("DASHSCOPE_API_KEY"),
model=model,
prompt=prompt,
n=1,
style='<watercolor>',
size='768*768',
sketch_image_url=sketch_image_url,
task=task)
print(rsp)
if rsp.status_code == HTTPStatus.OK:
print(rsp.output)
else:
print('create_async_task Failed, status_code: %s, code: %s, message: %s' %
(rsp.status_code, rsp.code, rsp.message))
return rsp
# 等待异步任务结束
def wait_async_task(task):
rsp = ImageSynthesis.wait(task)
print(rsp)
if rsp.status_code == HTTPStatus.OK:
print(rsp.output.task_status)
# save file to current directory
for result in rsp.output.results:
file_name = PurePosixPath(unquote(urlparse(result.url).path)).parts[-1]
with open('./%s' % file_name, 'wb+') as f:
f.write(requests.get(result.url).content)
else:
print('Failed, status_code: %s, code: %s, message: %s' %
(rsp.status_code, rsp.code, rsp.message))
if __name__ == '__main__':
async_call()
```
##### **响应示例**
**1、创建任务的响应示例**
```
{
"status_code": 200,
"request_id": "31b04171-011c-96bd-ac00-f0383b669cc7",
"code": "",
"message": "",
"output": {
"task_id": "4f90cf14-a34e-4eae-xxxxxxxx",
"task_status": "PENDING",
"results": []
},
"usage": null
}
```
**2、查询任务结果的响应示例**
```
{
"status_code": 200,
"request_id": "d861d3ba-4b29-9491-abad-266ef4fb2f08",
"code": null,
"message": "",
"output": {
"task_id": "4f90cf14-a34e-4eae-xxxxxxxx",
"task_status": "SUCCEEDED",
"results": [{
"url": "https://dashscope-result-hz.oss-cn-hangzhou.aliyuncs.com/xxxx.png"
}],
"submit_time": "2024-10-31 20:40:35.631",
"scheduled_time": "2024-10-31 20:40:35.684",
"end_time": "2024-10-31 20:41:02.700",
"task_metrics": {
"TOTAL": 1,
"SUCCEEDED": 1,
"FAILED": 0
}
},
"usage": {
"image_count": 1
}
}
```
### Java SDK调用
## 同步调用
##### 请求示例
```
// Copyright (c) Alibaba, Inc. and its affiliates.
import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesis;
import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesisParam;
import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesisResult;
import com.alibaba.dashscope.exception.ApiException;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.alibaba.dashscope.utils.JsonUtils;
import com.alibaba.dashscope.utils.Constants;
public class Main {
static {Constants.baseHttpApiUrl="https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1";}
public void syncCall() {
String prompt = "一棵参天大树";
String sketchImageUrl = "https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/6609471071/p743851.jpg";
String model = "wanx-sketch-to-image-lite";
ImageSynthesisParam param = ImageSynthesisParam.builder()
.model(model)
.prompt(prompt)
.n(1)
.size("768*768")
.sketchImageUrl(sketchImageUrl)
.style("<watercolor>")
.build();
String task = "image2image";
ImageSynthesis imageSynthesis = new ImageSynthesis(task);
ImageSynthesisResult result = null;
try {
System.out.println("---sync call, please wait a moment----");
result = imageSynthesis.call(param);
} catch (ApiException | NoApiKeyException e){
throw new RuntimeException(e.getMessage());
}
System.out.println(JsonUtils.toJson(result));
}
public static void main(String[] args){
Main text2Image = new Main();
text2Image.syncCall();
}
}
```
##### **响应示例**
```
{
"request_id": "150edcda-05d5-9ffe-8803-84626d1db623",
"output": {
"task_id": "f2098ff0-146e-404c-bb25-xxxxxxxx",
"task_status": "SUCCEEDED",
"results": [{
"url": "https://dashscope-result-hz.oss-cn-hangzhou.aliyuncs.com/xxxx.png"
}],
"task_metrics": {
"TOTAL": 1,
"SUCCEEDED": 1,
"FAILED": 0
}
},
"usage": {
"image_count": 1
}
}
```
## 异步调用
##### **请求示例**
```
// Copyright (c) Alibaba, Inc. and its affiliates.
import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesis;
import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesisParam;
import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesisResult;
import com.alibaba.dashscope.exception.ApiException;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.alibaba.dashscope.utils.JsonUtils;
import com.alibaba.dashscope.utils.Constants;
public class Main {
static {Constants.baseHttpApiUrl="https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1";}
public void asyncCall() {
System.out.println("---create task----");
String taskId = this.createAsyncTask();
System.out.println("---wait task done then return image url----");
this.waitAsyncTask(taskId);
}
/**
* 创建异步任务
* @return taskId
*/
public String createAsyncTask() {
String prompt = "一棵参天大树";
String sketchImageUrl = "https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/6609471071/p743851.jpg";
String model = "wanx-sketch-to-image-lite";
ImageSynthesisParam param = ImageSynthesisParam.builder()
.model(model)
.prompt(prompt)
.n(1)
.size("768*768")
.sketchImageUrl(sketchImageUrl)
.style("<watercolor>")
.build();
String task = "image2image";
ImageSynthesis imageSynthesis = new ImageSynthesis(task);
ImageSynthesisResult result = null;
try {
result = imageSynthesis.asyncCall(param);
} catch (Exception e){
throw new RuntimeException(e.getMessage());
}
String taskId = result.getOutput().getTaskId();
System.out.println("taskId=" + taskId);
return taskId;
}
/**
* 等待异步任务结束
* @param taskId 任务id
* */
public void waitAsyncTask(String taskId) {
ImageSynthesis imageSynthesis = new ImageSynthesis();
ImageSynthesisResult result = null;
try {
// If you have set the DASHSCOPE_API_KEY in the system environment variable, the apiKey can be null.
result = imageSynthesis.wait(taskId, null);
} catch (ApiException | NoApiKeyException e){
throw new RuntimeException(e.getMessage());
}
System.out.println(JsonUtils.toJson(result.getOutput()));
System.out.println(JsonUtils.toJson(result.getUsage()));
}
public static void main(String[] args){
Main text2Image = new Main();
text2Image.asyncCall();
}
}
```
##### **响应示例**
**1、步骤1:创建任务获取任务ID的响应示例**
```
{
"request_id": "5dbf9dc5-4f4c-9605-85ea-542f97709ba8",
"output": {
"task_id": "7277e20e-aa01-4709-xxxxxxxx",
"task_status": "PENDING"
}
}
```
**2、步骤2:根据任务ID查询结果的响应示例**
```
{
"request_id": "c44213ba-7aa3-91e4-97c1-c527ade82597",
"output": {
"task_id": "7277e20e-aa01-4709-xxxxxxxx",
"task_status": "SUCCEEDED",
"results": [{
"url": "https://dashscope-result-hz.oss-cn-hangzhou.aliyuncs.com/xxxx.png"
}],
"task_metrics": {
"TOTAL": 1,
"SUCCEEDED": 1,
"FAILED": 0
}
},
"usage": {
"image_count": 1
}
}
```
## 错误码
如果模型调用失败并返回报错信息,请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)进行解决。
此API还有特定状态码,具体如下所示。
| **HTTP状态码** | **接口错误码(code)** | **接口错误信息(message)** | **含义说明** |
| --- | --- | --- | --- |
| 400 | InvalidParameter.DataInspection | Unable to download the media resource during the data inspection process. | 输入图片无法下载,请检查URL地址是否正确且可访问。 |
| 400 | InvalidParameter | Value error, format of image {url} is not valid : payload.input.sketch | 输入图片格式不合法,请确认图片格式为JPG、JPEG、PNG、TIFF或WEBP。 |
## **常见问题**
### 模型计费及限流
**免费额度**
- 额度说明:免费额度是指模型成功生成的输出图片数量。输入图片及模型处理失败的情况不占用免费额度。
- 领取方式:开通阿里云百炼大模型服务后自动发放,有效期90天。
- 使用账号:阿里云主账号与其RAM子账号共享免费额度。
- 更多详情请参见[新人免费额度](https://help.aliyun.com/zh/model-studio/new-free-quota)。
**限时免费**
- 当计费为限时免费时,表示该模型处于公测阶段,免费额度用尽后不可使用。
**计费说明**
- 当计费有明确单价时,如0.2元/秒,表示该模型已商业化,免费额度用尽或过期后需付费使用。
- 计费项:只对模型成功生成的输出图片进行收费,其余情况暂不计费。
- 付费方式:由阿里云主账号统一付费。RAM子账号不能独立计量计费,必须由所属的主账号付费。如果您需要查询账单信息,请前往阿里云控制台[账单概览](https://billing-cost.console.aliyun.com/finance/month-bill/account)。
- 充值途径:您可以在阿里云控制台[费用与成本](https://billing-cost.console.aliyun.com/home?spm=a2c4g.11186623.0.0.2d543048F4KRQP)页面进行充值。
- 模型调用情况:您可以前往阿里云百炼的[模型观测](https://bailian.console.aliyun.com/#/model-telemetry)查看模型调用量及调用次数。
- 更多计费问题请参见[计费项](https://help.aliyun.com/zh/model-studio/billing-for-model-studio)。
**限流**
- 限流说明:阿里云主账号与其RAM子账号共享限流限制。
/\* 调整 table 宽度 \*/ .aliyun-docs-content table.medium-width { max-width: 1018px; width: 100%; } .aliyun-docs-content table.table-no-border tr td:first-child { padding-left: 0; } .aliyun-docs-content table.table-no-border tr td:last-child { padding-right: 0; } /\* 支持吸顶 \*/ div:has(.aliyun-docs-content), .aliyun-docs-content .markdown-body { overflow: visible; } .stick-top { position: sticky; top: 46px; } /\*\*代码块字体\*\*/ /\* 减少表格中的代码块 margin,让表格信息显示更紧凑 \*/ .unionContainer .markdown-body table .help-code-block { margin: 0 !important; } /\* 减少表格中的代码块字号,让表格信息显示更紧凑 \*/ .unionContainer .markdown-body .help-code-block pre { font-size: 12px !important; } /\* 减少表格中的代码块字号,让表格信息显示更紧凑 \*/ .unionContainer .markdown-body .help-code-block pre code { font-size: 12px !important; } /\*\* API Reference 表格 \*\*/ .aliyun-docs-content table.api-reference tr td:first-child { margin: 0px; border-bottom: 1px solid #d8d8d8; } .aliyun-docs-content table.api-reference tr:last-child td:first-child { border-bottom: none; } .aliyun-docs-content table.api-reference p { color: #6e6e80; } .aliyun-docs-content table.api-reference b, i { color: #181818; } .aliyun-docs-content table.api-reference .collapse { border: none; margin-top: 4px; margin-bottom: 4px; } .aliyun-docs-content table.api-reference .collapse .expandable-title-bold { padding: 0; } .aliyun-docs-content table.api-reference .collapse .expandable-title { padding: 0; } .aliyun-docs-content table.api-reference .collapse .expandable-title-bold .title { margin-left: 16px; } .aliyun-docs-content table.api-reference .collapse .expandable-title .title { margin-left: 16px; } .aliyun-docs-content table.api-reference .collapse .expandable-title-bold i.icon { position: absolute; color: #777; font-weight: 100; } .aliyun-docs-content table.api-reference .collapse .expandable-title i.icon { position: absolute; color: #777; font-weight: 100; } .aliyun-docs-content table.api-reference .collapse.expanded .expandable-content { padding: 10px 14px 10px 14px !important; margin: 0; border: 1px solid #e9e9e9; } .aliyun-docs-content table.api-reference .collapse .expandable-title-bold b { font-size: 13px; font-weight: normal; color: #6e6e80; } .aliyun-docs-content table.api-reference .collapse .expandable-title b { font-size: 13px; font-weight: normal; color: #6e6e80; } .aliyun-docs-content table.api-reference .tabbed-content-box { border: none; } .aliyun-docs-content table.api-reference .tabbed-content-box section { padding: 8px 0 !important; } .aliyun-docs-content table.api-reference .tabbed-content-box.mini .tab-box { /\* position: absolute; left: 40px; right: 0; \*/ } .aliyun-docs-content .margin-top-33 { margin-top: 33px !important; } .aliyun-docs-content .two-codeblocks pre { max-height: calc(50vh - 136px) !important; height: auto; } .expandable-content section { border-bottom: 1px solid #e9e9e9; padding-top: 6px; padding-bottom: 4px; } .expandable-content section:last-child { border-bottom: none; } .expandable-content section:first-child { padding-top: 0; }
@@ -0,0 +1,581 @@
千问-图像编辑模型支持多图输入和多图输出,可精确修改图内文字、增删或移动物体、改变主体动作、迁移图片风格及增强画面细节。
| **快速入口:**[使用指南](https://help.aliyun.com/zh/model-studio/qwen-image-edit-guide) **\\|** [技术博客](https://qwen.ai/blog?id=1675c295dc29dd31073e5b3f72876e9d684e41c6&from=research.research-list) \\| [在线体验](https://bailian.console.aliyun.com/?tab=model#/efm/model_experience_center/vision?currentTab=imageGenerate&modelId=qwen-image-edit) |
| --- | --- | --- |
## **模型概览**
| **提示词** | **输入图** | **输出图像** |
| --- | --- | --- |
| 在画面右下角石板路旁、靠近树干根部的位置,以浅灰墨色手写体题写一首七言绝句,字体为行楷风格,笔触自然流畅、略带飞白,大小适中(约占画面高度1/10),与整体水墨淡雅氛围协调。诗文内容为:“青石桥畔柳风轻, 素手拈花闭目听。 一水碧痕浮旧梦, 半篙烟雨入空舲。”诗句横向排列,四句分两行书写(前两句一行,后两句一行),末句“舲”字右下角钤一枚朱红小印,印文为“江南”二字篆书,尺寸约等于单字高度的1/3。 | ![image (18)-2026-03-10-16-39-59](https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/3279833771/p1058430.webp) | ![image (19)](https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/3279833771/p1058432.png) |
| 使用图一的城市照片作为底图。请勿更改照片中的真实建筑、街道、车辆或人物。保持照片的真实性。三个图二中的卡通形象在建筑物周围,一个趴在建筑物上方,一个从建筑物的右边探出头来,一个坐在建筑物前的空地上。该形象应采用扁平化的图形风格绘制,轮廓清晰,类似于壁画或海报插图。 | ![image (15)-combine](https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/3279833771/p1058424.webp) | ![image (17)](https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/3279833771/p1058425.png) |
| **模型名称** | **模型简介** | **输出图像规格** |
| --- | --- | --- |
| qwen-image-2.0-pro `**推荐**` > 当前与qwen-image-2.0-pro-2026-04-22能力相同 | 千问图像生成与编辑模型Pro系列。文字渲染、真实质感、语义遵循能力更强。 > 图像生成请参考[千问-文生图](https://help.aliyun.com/zh/model-studio/qwen-image-api)。 | 图像分辨率: - **可指定**:图像总像素需在512\\*512至2048\\*2048之间。 - **默认**:总像素数接近 1024\\*1024,宽高比与输入图(多图输入时为最后一张)相近。 图像格式:png 图像张数:1-6张 |
| qwen-image-2.0-pro-2026-06-22 `**推荐**` |
| qwen-image-2.0-pro-2026-04-22 |
| qwen-image-2.0-pro-2026-03-03 |
| qwen-image-2.0 `**推荐**` > 当前与qwen-image-2.0-2026-03-03能力相同 | 千问图像生成与编辑模型加速版,兼顾效果与响应速度。 > 图像生成请参考[千问-文生图](https://help.aliyun.com/zh/model-studio/qwen-image-api)。 |
| qwen-image-2.0-2026-03-03 `**推荐**` |
| qwen-image-edit-max > 当前与qwen-image-edit-max-2026-01-16能力相同 | 千问图像编辑Max系列。工业设计、几何推理、角色一致性更强。 | 图像分辨率: - **可指定**:宽和高的取值范围均为`[512, 2048]`像素。 - **默认**:总像素数接近 1024\\*1024,宽高比与输入图(多图输入时为最后一张)相近。 图像格式:png 图像张数:1-6张 |
| qwen-image-edit-max-2026-01-16 |
| qwen-image-edit-plus > 当前与qwen-image-edit-plus-2025-10-30能力相同 | 千问图像编辑Plus系列,支持多图输出与自定义分辨率。 |
| qwen-image-edit-plus-2025-12-15 |
| qwen-image-edit-plus-2025-10-30 |
| qwen-image-edit | 支持单图编辑和多图融合。 | 图像分辨率:**不可指定**。生成规则同上方的**默认**规则。 图像格式:png 图像张数:固定1张 |
各地域支持的模型请参见[百炼控制台](https://bailian.console.aliyun.com/cn-beijing?tab=model#/model-market/all)。
## **前提条件**
在调用前,您需要[获取API Key](https://help.aliyun.com/zh/model-studio/get-api-key),再[配置API Key到环境变量](https://help.aliyun.com/zh/model-studio/configure-api-key-through-environment-variables)。
如需通过SDK进行调用,请[安装DashScope SDK](https://help.aliyun.com/zh/model-studio/install-sdk)。目前,该SDK已支持Python和Java。
**重要**
华北2(北京)和新加坡地域拥有独立的 **API Key** 与**请求地址**,不可混用,跨地域调用将导致鉴权失败或服务报错。
**重要**
阿里云百炼为华北2(北京)、新加坡地域推出了业务空间专属域名,**能够为推理请求提供卓越的性能和更高的稳定性**,建议迁移至新域名:
- 华北2(北京)地域:从 `https://dashscope.aliyuncs.com` 迁移至 `https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com`
- 新加坡地域:从 `https://dashscope-intl.aliyuncs.com` 迁移至 `https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com`
其中 `{WorkspaceId}` 为您的业务空间 ID,可在阿里云百炼控制台的**业务空间详情**页面查看。现有域名仍可正常使用。
## HTTP调用
**北京地域**:`POST https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation`
**新加坡地域**:`POST https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation`
调用时请将`{WorkspaceId}`替换为真实的[Workspace ID](https://help.aliyun.com/zh/model-studio/obtain-the-app-id-and-workspace-id#d3eb3cd37b7fu)。
| #### 请求参数 | ## 单图编辑 ``` curl --location 'https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation' \\ --header 'Content-Type: application/json' \\ --header "Authorization: Bearer $DASHSCOPE_API_KEY" \\ --data '{ "model": "qwen-image-2.0-pro", "input": { "messages": [ { "role": "user", "content": [ { "image": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20260310/jiydyi/image+%2818%29-2026-03-10-16-39-59.webp" }, { "text": "在画面右下角石板路旁、靠近树干根部的位置,以浅灰墨色手写体题写一首七言绝句,字体为行楷风格,笔触自然流畅、略带飞白,大小适中(约占画面高度1/10),与整体水墨淡雅氛围协调。诗文内容为:“青石桥畔柳风轻, 素手拈花闭目听。 一水碧痕浮旧梦, 半篙烟雨入空舲。”诗句横向排列,四句分两行书写(前两句一行,后两句一行),末句“舲”字右下角钤一枚朱红小印,印文为“江南”二字篆书,尺寸约等于单字高度的1/3。" } ] } ] }, "parameters": { "n": 1, "negative_prompt": " ", "prompt_extend": true, "watermark": false, "size": "2048*2048" } }' ``` ## 多图融合 ``` curl --location 'https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation' \\ --header 'Content-Type: application/json' \\ --header "Authorization: Bearer $DASHSCOPE_API_KEY" \\ --data '{ "model": "qwen-image-2.0-pro", "input": { "messages": [ { "role": "user", "content": [ { "image": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20260310/rdsgaa/image+%2815%29.png" }, { "image": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20260310/qokhtl/image+%2816%29.png" }, { "text": "使用图一的城市照片作为底图。请勿更改照片中的真实建筑、街道、车辆或人物。保持照片的真实性。三个图二中的卡通形象在建筑物周围,一个趴在建筑物上方,一个从建筑物的右边探出头来,一个坐在建筑物前的空地上。该形象应采用扁平化的图形风格绘制,轮廓清晰,类似于壁画或海报插图。" } ] } ] }, "parameters": { "n": 1, "negative_prompt": " ", "prompt_extend": true, "watermark": false, "size": "2048*2048" } }' ``` |
| --- | --- |
| ##### 请求头(Headers) |
| **Content-Type** `*string*` **(必选)** 请求内容类型。此参数必须设置为`application/json`。 |
| **Authorization** `*string*`**(必选)** 请求身份认证。接口使用阿里云百炼API Key进行身份认证。示例值:Bearer sk-xxxx。 |
| ##### 请求体(Request Body) |
| **model** `*string*` **(必选)** 模型名称,示例值qwen-image-2.0-pro。 |
| **input** `*object*` **(必选)** 输入参数对象,包含以下字段: **属性** **messages** `*array*` **(必选)** 请求内容数组。**当前仅支持单轮对话**,因此数组内**有且只有一个对象**,该对象包含`role`和`content`两个属性。 **属性** **role** `*string*` **(必选)** 消息发送者角色,必须设置为`user`。 **content** `*array*` **(必选)** 消息内容,包含1-3张图像,格式为 `{"image": "..."}`;以及单个编辑指令,格式为 `{"text": "..."}`。 **属性** **image** `*string*` **(必选)** 输入图像的 URL 或 Base64 编码数据。支持传入1-3张图像。 多图输入时,按照数组顺序定义图像顺序,输出图像的比例以最后一张为准。 **图像要求:** - 图像格式:JPG、JPEG、PNG、BMP、TIFF、WEBP和GIF。 > 输出图像为PNG格式,对于GIF动图,仅处理其第一帧。 - 图像分辨率:为获得最佳效果,建议图像的宽和高均在384像素至3072像素之间。分辨率过低可能导致生成效果模糊,过高则会增加处理时长。 - 图像大小:不超过10MB。 **支持的输入格式** 1. 公网URL: - 支持 HTTP 和 HTTPS 协议。 - 示例值:`https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20250925/fpakfo/image36.webp`。 2. 临时URL: - 支持OSS协议,必须通过[上传文件获取临时 URL](https://help.aliyun.com/zh/model-studio/get-temporary-file-url)。 - 示例值:`oss://dashscope-instant/xxx/2024-07-18/xxx/cat.png`。 3. 传入 Base64 编码图像后的字符串 - 示例值:`data:image/jpeg;base64,GDU7MtCZz...`(示例已截断,仅做演示) - Base64 编码规范请参见[通过Base64编码传入图片](#907c84c1a6wrm)。 **text** `*string*` **(必选)** 正向提示词用于描述您期望生成的图像内容、风格和构图。 支持中英文,qwen-image-2.0系列模型长度上限为 1300 Token,其他模型为 800 Token,超出部分将自动截断。 **注意**:仅支持传入一个text,不传或传入多个将报错。 |
| **parameters** `*object*` (可选) 控制图像生成的附加参数。 **属性** **n** `*integer*` (可选) 输出图像的数量,默认值为1。 对于qwen-image-2.0系列、qwen-image-edit-max、qwen-image-edit-plus系列模型,可选择输出1-6张图片。 对于`qwen-image-edit`,仅支持输出1张图片。 **negative\\_prompt** `*string*` (可选) 反向提示词,用来描述不希望在画面中看到的内容,可以对画面进行限制。 支持中英文,长度上限500个字符,每个汉字、字母、数字或符号计为一个字符,超过部分会自动截断。 示例值:低分辨率、错误、最差质量、低质量、残缺、多余的手指、比例不良等。 **size** `*string*` (可选) 设置输出图像的分辨率,格式为`宽*高`,例如`"1024*1536"`。 **qwen-image-2.0系列模型**: - 图像总像素需在512\\*512至2048\\*2048之间。 - 默认总像素数接近 `1024*1024`,宽高比与输入图(多图输入时为最后一张)相近。 **qwen-image-edit-max、qwen-image-edit-plus系列模型**: - 宽和高的取值范围均为\\[512, 2048\\]像素。 - 默认总像素数接近 `1024*1024`,宽高比与输入图(多图输入时为最后一张)相近。 > 指定 `size` 参数,系统会以 `size`指定的宽高为目标,将实际输出图像的宽高调整为最接近的16的倍数。例如,设置`1033*1032`,输出图像尺寸为`1040*1024`。 常见比例推荐分辨率 - 1:1: 1024\\*1024、1536\\*1536 - 2:3: 768\\*1152、1024\\*1536 - 3:2: 1152\\*768、1536\\*1024 - 3:4: 960\\*1280、1080\\*1440 - 4:3: 1280\\*960、1440\\*1080 - 9:16: 720\\*1280、1080\\*1920 - 16:9: 1280\\*720、1920\\*1080 - 21:9: 1344\\*576、2048\\*872 **支持模型**:除`qwen-image-edit`以外的模型。 **prompt\\_extend** `*boolean*` (可选) 是否开启提示词智能改写,默认值为 `true`。开启后,模型会优化正向提示词(`text`),对描述较简单的提示词效果提升明显。 **支持模型**:除`qwen-image-edit`以外的模型。 **watermark** `*boolean*` (可选) 是否在图像右下角添加 "Qwen-Image" 水印。默认值为 `false`。 **seed** `*integer*` (可选) 随机数种子,取值范围`[0,2147483647]`。 使用相同的`seed`参数值可使生成内容保持相对稳定。若不提供,算法将自动使用随机数种子。 **注意**:模型生成过程具有概率性,即使使用相同的`seed`,也不能保证每次生成结果完全一致。 |
| #### 响应参数 | ## 任务执行成功 任务数据(如任务状态、图像URL等)仅保留24小时,超时后会被自动清除。请您务必及时保存生成的图像。 ``` { "output": { "choices": [ { "finish_reason": "stop", "message": { "content": [ { "image": "https://dashscope-result-sz.oss-cn-shenzhen.aliyuncs.com/xxx.png?Expires=xxx" } ], "role": "assistant" } } ] }, "usage": { "height": 2048, "image_count": 1, "width": 2048 }, "request_id": "571ae02f-5c9d-436c-83c2-f221e6df0xxx" } ``` ## 任务执行异常 如果因为某种原因导致任务执行失败,将返回相关信息,可以通过code和message字段明确指示错误原因。请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)进行解决。 ``` { "request_id": "31f808fd-8eef-9004-xxxxx", "code": "InvalidApiKey", "message": "Invalid API-key provided." } ``` |
| --- | --- |
| **output** `*object*` 包含模型生成结果。 **属性** **choices** `*array*` 结果选项列表。 **属性** **finish\\_reason** `*string*` 任务停止原因,自然停止时为`stop`。 **message** `*object*` 模型返回的消息。 **属性** **role** `*string*` 消息的角色,固定为`assistant`。 **content** `*array*` 消息内容,包含生成的图像信息。 **属性** **image** `*string*` 生成图像的 URL,格式为PNG。**链接有效期为24小时**,请及时下载并保存图像。 |
| **usage** `*object*` 本次调用的资源使用情况,仅调用成功时返回。 **属性** **image\\_count** `*integer*` 生成图像的张数。 **width** `*integer*` 生成图像的宽度(像素)。 **height** `*integer*` 生成图像的高度(像素)。 |
| **request\\_id** `*string*` 请求唯一标识。可用于请求明细溯源和问题排查。 |
| **code** `*string*` 请求失败的错误码。请求成功时不会返回此参数,详情请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)。 |
| **message** `*string*` 请求失败的详细信息。请求成功时不会返回此参数,详情请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)。 |
## DashScope SDK调用
SDK 的参数命名与[HTTP接口](#42703589880ts)基本一致,参数结构根据语言特性进行封装,完整参数列表请参见[千问 API 参考](https://help.aliyun.com/zh/model-studio/qwen-api-reference/)。
### Python SDK调用
**说明**
- 推荐安装最新版DashScope Python SDK,否则可能运行报错:[安装或升级SDK](https://help.aliyun.com/zh/model-studio/install-sdk)。
- 不支持异步接口。
#### **请求示例**
## 通过公网URL传入图片
```
import json
import os
from dashscope import MultiModalConversation
import dashscope
# 以下为华北2(北京)地域的URL。请将 {WorkspaceId} 替换为您的百炼业务空间ID,各地域的URL不同。
dashscope.base_http_api_url = 'https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1'
# 模型支持输入1-3张图片
messages = [
{
"role": "user",
"content": [
{"image": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20260310/rdsgaa/image+%2815%29.png"},
{"image": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20260310/qokhtl/image+%2816%29.png"},
{"text": "使用图一的城市照片作为底图。请勿更改照片中的真实建筑、街道、车辆或人物。保持照片的真实性。三个图二中的卡通形象在建筑物周围,一个趴在建筑物上方,一个从建筑物的右边探出头来,一个坐在建筑物前的空地上。该形象应采用扁平化的图形风格绘制,轮廓清晰,类似于壁画或海报插图。"}
]
}
]
# 新加坡和北京地域的API Key不同。获取API Key:https://help.aliyun.com/zh/model-studio/get-api-key
# 若没有配置环境变量,请用百炼 API Key 将下行替换为:api_key="sk-xxx"
api_key = os.getenv("DASHSCOPE_API_KEY")
# qwen-image-2.0系列、qwen-image-edit-max、qwen-image-edit-plus系列支持输出1-6张图片
response = MultiModalConversation.call(
api_key=api_key,
model="qwen-image-2.0-pro",
messages=messages,
stream=False,
n=1,
watermark=False,
negative_prompt=" ",
prompt_extend=True,
size="2048*2048",
)
if response.status_code == 200:
# 如需查看完整响应,请取消下行注释
# print(json.dumps(response, ensure_ascii=False))
for i, content in enumerate(response.output.choices[0].message.content):
print(f"输出图像{i+1}的URL:{content['image']}")
else:
print(f"HTTP返回码:{response.status_code}")
print(f"错误码:{response.code}")
print(f"错误信息:{response.message}")
print("请参考文档:https://help.aliyun.com/zh/model-studio/error-code")
```
## 通过Base64编码传入图片
```
import json
import os
from dashscope import MultiModalConversation
import base64
import mimetypes
import dashscope
# 以下为华北2(北京)地域的URL。请将 {WorkspaceId} 替换为您的百炼业务空间ID,各地域的URL不同。
dashscope.base_http_api_url = 'https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1'
# ---用于 Base64 编码 ---
# 格式为 data:{mime_type};base64,{base64_data}
def encode_file(file_path):
mime_type, _ = mimetypes.guess_type(file_path)
if not mime_type or not mime_type.startswith("image/"):
raise ValueError("不支持或无法识别的图像格式")
try:
with open(file_path, "rb") as image_file:
encoded_string = base64.b64encode(
image_file.read()).decode('utf-8')
return f"data:{mime_type};base64,{encoded_string}"
except IOError as e:
raise IOError(f"读取文件时出错: {file_path}, 错误: {str(e)}")
# 获取图像的 Base64 编码
# 调用编码函数,请将 "/path/to/your/image.png" 替换为您的本地图片文件路径,否则无法运行
image = encode_file("/path/to/your/image.png")
messages = [
{
"role": "user",
"content": [
{"image": image},
{"text": "在画面右下角石板路旁、靠近树干根部的位置,以浅灰墨色手写体题写一首七言绝句,字体为行楷风格,笔触自然流畅、略带飞白,大小适中(约占画面高度1/10),与整体水墨淡雅氛围协调。诗文内容为:“青石桥畔柳风轻, 素手拈花闭目听。 一水碧痕浮旧梦, 半篙烟雨入空舲。”诗句横向排列,四句分两行书写(前两句一行,后两句一行),末句“舲”字右下角钤一枚朱红小印,印文为“江南”二字篆书,尺寸约等于单字高度的1/3。"}
]
}
]
# 新加坡和北京地域的API Key不同。获取API Key:https://help.aliyun.com/zh/model-studio/get-api-key
# 若没有配置环境变量,请用百炼 API Key 将下行替换为:api_key="sk-xxx"
api_key = os.getenv("DASHSCOPE_API_KEY")
# qwen-image-2.0系列、qwen-image-edit-max、qwen-image-edit-plus系列支持输出1-6张图片
response = MultiModalConversation.call(
api_key=api_key,
model="qwen-image-2.0-pro",
messages=messages,
stream=False,
n=1,
watermark=False,
negative_prompt=" ",
prompt_extend=True,
size="2048*2048",
)
if response.status_code == 200:
# 如需查看完整响应,请取消下行注释
# print(json.dumps(response, ensure_ascii=False))
for i, content in enumerate(response.output.choices[0].message.content):
print(f"输出图像{i+1}的URL:{content['image']}")
else:
print(f"HTTP返回码:{response.status_code}")
print(f"错误码:{response.code}")
print(f"错误信息:{response.message}")
print("请参考文档:https://help.aliyun.com/zh/model-studio/error-code")
```
## 通过URL下载图像
```
# 需要安装requests以下载图像: pip install requests
import requests
def download_image(image_url, save_path='output.png'):
try:
response = requests.get(image_url, stream=True, timeout=300) # 设置超时
response.raise_for_status() # 如果HTTP状态码不是200,则引发异常
with open(save_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
print(f"图像已成功下载到: {save_path}")
except requests.exceptions.RequestException as e:
print(f"图像下载失败: {e}")
image_url = "https://dashscope-result-sz.oss-cn-shenzhen.aliyuncs.com/xxx.png?Expires=xxx"
download_image(image_url, save_path='output.png')
```
#### **响应示例**
图像链接的有效期为24小时,请及时下载图像。
> `input_tokens`、`output_tokens`和`characters`为兼容字段,当前固定为0。
```
{
"status_code": 200,
"request_id": "959afba6-544e-487e-b58a-6bd9fea97xxx",
"code": "",
"message": "",
"output": {
"text": null,
"finish_reason": null,
"choices": [
{
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": [
{
"image": "https://dashscope-result-hz.oss-cn-hangzhou.aliyuncs.com/xxx.png?Expires=xxx"
}
]
}
}
],
"audio": null
},
"usage": {
"input_tokens": 0,
"output_tokens": 0,
"characters": 0,
"height": 2048,
"image_count": 1,
"width": 2048
}
}
```
### Java SDK调用
**说明**
推荐安装最新版DashScope Java SDK,否则可能运行报错:[安装或升级SDK](https://help.aliyun.com/zh/model-studio/install-sdk)。
#### **请求示例**
## 通过公网URL传入图片
```
import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversation;
import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationParam;
import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationResult;
import com.alibaba.dashscope.common.MultiModalMessage;
import com.alibaba.dashscope.common.Role;
import com.alibaba.dashscope.exception.ApiException;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.alibaba.dashscope.exception.UploadFileException;
import com.alibaba.dashscope.utils.Constants;
import com.alibaba.dashscope.utils.JsonUtils;
import java.io.IOException;
import java.util.*;
public class QwenImageEdit {
static {
// 以下为华北2(北京)地域的URL。请将 {WorkspaceId} 替换为您的百炼业务空间ID,各地域的URL不同。
Constants.baseHttpApiUrl = "https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1";
}
// 新加坡和北京地域的API Key不同。获取API Key:https://help.aliyun.com/zh/model-studio/get-api-key
// 若没有配置环境变量,请用百炼 API Key 将下行替换为:apiKey="sk-xxx"
static String apiKey = System.getenv("DASHSCOPE_API_KEY");
public static void call() throws ApiException, NoApiKeyException, UploadFileException, IOException {
MultiModalConversation conv = new MultiModalConversation();
// 模型支持输入1-3张图片
MultiModalMessage userMessage = MultiModalMessage.builder().role(Role.USER.getValue())
.content(Arrays.asList(
Collections.singletonMap("image", "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20260310/rdsgaa/image+%2815%29.png"),
Collections.singletonMap("image", "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20260310/qokhtl/image+%2816%29.png"),
Collections.singletonMap("text", "使用图一的城市照片作为底图。请勿更改照片中的真实建筑、街道、车辆或人物。保持照片的真实性。三个图二中的卡通形象在建筑物周围,一个趴在建筑物上方,一个从建筑物的右边探出头来,一个坐在建筑物前的空地上。该形象应采用扁平化的图形风格绘制,轮廓清晰,类似于壁画或海报插图。")
)).build();
// qwen-image-2.0系列、qwen-image-edit-max、qwen-image-edit-plus系列支持输出1-6张图片
Map<String, Object> parameters = new HashMap<>();
parameters.put("watermark", false);
parameters.put("negative_prompt", " ");
parameters.put("n", 1);
parameters.put("prompt_extend", true);
parameters.put("size", "2048*2048");
MultiModalConversationParam param = MultiModalConversationParam.builder()
.apiKey(apiKey)
.model("qwen-image-2.0-pro")
.messages(Collections.singletonList(userMessage))
.parameters(parameters)
.build();
MultiModalConversationResult result = conv.call(param);
// 如需查看完整响应,请取消下行注释
// System.out.println(JsonUtils.toJson(result));
List<Map<String, Object>> contentList = result.getOutput().getChoices().get(0).getMessage().getContent();
int imageIndex = 1;
for (Map<String, Object> content : contentList) {
if (content.containsKey("image")) {
System.out.println("输出图像" + imageIndex + "的URL:" + content.get("image"));
imageIndex++;
}
}
}
public static void main(String[] args) {
try {
call();
} catch (ApiException | NoApiKeyException | UploadFileException | IOException e) {
System.out.println(e.getMessage());
}
}
}
```
## 通过Base64编码传入图片
```
import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversation;
import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationParam;
import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationResult;
import com.alibaba.dashscope.common.MultiModalMessage;
import com.alibaba.dashscope.common.Role;
import com.alibaba.dashscope.exception.ApiException;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.alibaba.dashscope.exception.UploadFileException;
import com.alibaba.dashscope.utils.Constants;
import com.alibaba.dashscope.utils.JsonUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
public class QwenImageEdit {
static {
// 以下为华北2(北京)地域的URL。请将 {WorkspaceId} 替换为您的百炼业务空间ID,各地域的URL不同。
Constants.baseHttpApiUrl = "https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1";
}
// 新加坡和北京地域的API Key不同。获取API Key:https://help.aliyun.com/zh/model-studio/get-api-key
// 若没有配置环境变量,请用百炼 API Key 将下行替换为:apiKey="sk-xxx"
static String apiKey = System.getenv("DASHSCOPE_API_KEY");
public static void call() throws ApiException, NoApiKeyException, UploadFileException, IOException {
// 请将 "/path/to/your/image.png" 替换为您的本地图片文件路径,否则无法运行
String image = encodeFile("/path/to/your/image.png");
MultiModalConversation conv = new MultiModalConversation();
MultiModalMessage userMessage = MultiModalMessage.builder().role(Role.USER.getValue())
.content(Arrays.asList(
Collections.singletonMap("image", image),
Collections.singletonMap("text", "在画面右下角石板路旁、靠近树干根部的位置,以浅灰墨色手写体题写一首七言绝句,字体为行楷风格,笔触自然流畅、略带飞白,大小适中(约占画面高度1/10),与整体水墨淡雅氛围协调。诗文内容为:“青石桥畔柳风轻, 素手拈花闭目听。 一水碧痕浮旧梦, 半篙烟雨入空舲。”诗句横向排列,四句分两行书写(前两句一行,后两句一行),末句“舲”字右下角钤一枚朱红小印,印文为“江南”二字篆书,尺寸约等于单字高度的1/3。")
)).build();
// qwen-image-2.0系列、qwen-image-edit-max、qwen-image-edit-plus系列支持输出1-6张图片
Map<String, Object> parameters = new HashMap<>();
parameters.put("watermark", false);
parameters.put("negative_prompt", " ");
parameters.put("n", 1);
parameters.put("prompt_extend", true);
parameters.put("size", "2048*2048");
MultiModalConversationParam param = MultiModalConversationParam.builder()
.apiKey(apiKey)
.model("qwen-image-2.0-pro")
.messages(Collections.singletonList(userMessage))
.parameters(parameters)
.build();
MultiModalConversationResult result = conv.call(param);
// 如需查看完整响应,请取消下行注释
// System.out.println(JsonUtils.toJson(result));
List<Map<String, Object>> contentList = result.getOutput().getChoices().get(0).getMessage().getContent();
int imageIndex = 1;
for (Map<String, Object> content : contentList) {
if (content.containsKey("image")) {
System.out.println("输出图像" + imageIndex + "的URL:" + content.get("image"));
imageIndex++;
}
}
}
/**
* 将文件编码为Base64字符串
* @param filePath 文件路径
* @return Base64字符串,格式为 data:{mime_type};base64,{base64_data}
*/
public static String encodeFile(String filePath) {
Path path = Paths.get(filePath);
if (!Files.exists(path)) {
throw new IllegalArgumentException("文件不存在: " + filePath);
}
// 检测MIME类型
String mimeType = null;
try {
mimeType = Files.probeContentType(path);
} catch (IOException e) {
throw new IllegalArgumentException("无法检测文件类型: " + filePath);
}
if (mimeType == null || !mimeType.startsWith("image/")) {
throw new IllegalArgumentException("不支持或无法识别的图像格式");
}
// 读取文件内容并编码
byte[] fileBytes = null;
try{
fileBytes = Files.readAllBytes(path);
} catch (IOException e) {
throw new IllegalArgumentException("无法读取文件内容: " + filePath);
}
String encodedString = Base64.getEncoder().encodeToString(fileBytes);
return "data:" + mimeType + ";base64," + encodedString;
}
public static void main(String[] args) {
try {
call();
} catch (ApiException | NoApiKeyException | UploadFileException | IOException e) {
System.out.println(e.getMessage());
}
}
}
```
## 通过URL下载图像
```
import java.io.FileOutputStream;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
public class ImageDownloader {
public static void downloadImage(String imageUrl, String savePath) {
try {
URL url = new URL(imageUrl);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setConnectTimeout(5000);
connection.setReadTimeout(300000);
connection.setRequestMethod("GET");
InputStream inputStream = connection.getInputStream();
FileOutputStream outputStream = new FileOutputStream(savePath);
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
inputStream.close();
outputStream.close();
System.out.println("图像已成功下载到: " + savePath);
} catch (Exception e) {
System.err.println("图像下载失败: " + e.getMessage());
}
}
public static void main(String[] args) {
String imageUrl = "http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/xxx?Expires=xxx";
String savePath = "output.png";
downloadImage(imageUrl, savePath);
}
}
```
#### **响应示例**
图像链接的有效期为24小时,请及时下载图像。
```
{
"requestId": "5d5c3260-fc6c-4b4f-8b35-c06366effxxx",
"usage": {
"image_count": 1,
"width": 2048,
"height": 2048
},
"output": {
"choices": [
{
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": [
{
"image": "https://dashscope-result-sz.oss-cn-shenzhen.aliyuncs.com/xxx.png?Expires=xxx"
}
]
}
}
]
},
"status_code": 200,
"code": "",
"message": ""
}
```
## **错误码**
如果模型调用失败并返回报错信息,请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)进行解决。
## **计费与限流**
- 模型免费额度和计费单价请参见[选择模型](https://help.aliyun.com/zh/model-studio/models#bfe15d8aa2lxh)。
- 模型限流请参见[千问(Qwen-Image)](https://help.aliyun.com/zh/model-studio/rate-limit#f812e7c63axvx)。
- 计费说明:按成功生成的 **图像张数** 计费。模型调用失败或处理错误不产生任何费用,也不消耗[新人免费额度](https://help.aliyun.com/zh/model-studio/new-free-quota)。
## **常见问题**
#### **Q:千问图像编辑模型支持哪些语言?**
A:目前正式支持**简体中文和英文**;其他语言可自行尝试,但效果存在不确定性。
##### **Q: 如何查看模型调用量?**
A: 模型调用完一小时后,请在[**模型监控**(北京)](https://bailian.console.aliyun.com/cn-beijing/?tab=model#/model-telemetry)或[**模型监控**(新加坡)](https://modelstudio.console.aliyun.com/ap-southeast-1?tab=dashboard#/model-telemetry) 页面,查看模型的调用次数、成功率等指标。详情请参见[账单查询与成本管理](https://help.aliyun.com/zh/model-studio/bill-query-and-cost-management)。
##### **Q:如何获取图像存储的访问域名白名单?**
A: 模型生成的图像存储于阿里云OSS,API将返回一个临时的公网URL。**若需要对该下载地址进行防火墙白名单配置**,请注意:由于底层存储会根据业务情况进行动态变更,为避免过期信息影响访问,文档不提供固定的OSS域名白名单。如有安全管控需求,请联系客户经理获取最新OSS域名列表。
/\*表格图片设置为块元素(独占一行),居中展示,鼠标放在图片上可以点击查看原图\*/ .unionContainer .markdown-body .image.break { margin: 0px; display: inline-block; vertical-align: middle } /\* 让引用上下间距调小,避免内容显示过于稀疏 \*/ .unionContainer .markdown-body blockquote { margin: 4px 0; } .aliyun-docs-content table.qwen blockquote { border-left: none; /\* 添加这一行来移除表格里的引用文字的左侧边框 \*/ padding-left: 5px; /\* 左侧内边距 \*/ margin: 4px 0; } .gray-text { color: gray !important; font-size: 0.75em !important; /\* 字体减小一半 \*/ }
/\* 调整 table 宽度 \*/ .aliyun-docs-content table.medium-width { max-width: 1018px; width: 100%; } .aliyun-docs-content table.table-no-border tr td:first-child { padding-left: 0; } .aliyun-docs-content table.table-no-border tr td:last-child { padding-right: 0; } /\* 支持吸顶 \*/ div:has(.aliyun-docs-content), .aliyun-docs-content .markdown-body { overflow: visible; } .stick-top { position: sticky; top: 46px; } /\*\*代码块字体\*\*/ /\* 减少表格中的代码块 margin,让表格信息显示更紧凑 \*/ .unionContainer .markdown-body table .help-code-block { margin: 0 !important; } /\* 减少表格中的代码块字号,让表格信息显示更紧凑 \*/ .unionContainer .markdown-body .help-code-block pre { font-size: 12px !important; } /\* 减少表格中的代码块字号,让表格信息显示更紧凑 \*/ .unionContainer .markdown-body .help-code-block pre code { font-size: 12px !important; } /\*\* API Reference 表格 \*\*/ .aliyun-docs-content table.api-reference tr td:first-child { margin: 0px; border-bottom: 1px solid #d8d8d8; } .aliyun-docs-content table.api-reference tr:last-child td:first-child { border-bottom: none; } .aliyun-docs-content table.api-reference p { color: #6e6e80; } .aliyun-docs-content table.api-reference b, i { color: #181818; } .aliyun-docs-content table.api-reference .collapse { border: none; margin-top: 4px; margin-bottom: 4px; } .aliyun-docs-content table.api-reference .collapse .expandable-title-bold { padding: 0; } .aliyun-docs-content table.api-reference .collapse .expandable-title { padding: 0; } .aliyun-docs-content table.api-reference .collapse .expandable-title-bold .title { margin-left: 16px; } .aliyun-docs-content table.api-reference .collapse .expandable-title .title { margin-left: 16px; } .aliyun-docs-content table.api-reference .collapse .expandable-title-bold i.icon { position: absolute; color: #777; font-weight: 100; } .aliyun-docs-content table.api-reference .collapse .expandable-title i.icon { position: absolute; color: #777; font-weight: 100; } .aliyun-docs-content table.api-reference .collapse.expanded .expandable-content { padding: 10px 14px 10px 14px !important; margin: 0; border: 1px solid #e9e9e9; } .aliyun-docs-content table.api-reference .collapse .expandable-title-bold b { font-size: 13px; font-weight: normal; color: #6e6e80; } .aliyun-docs-content table.api-reference .collapse .expandable-title b { font-size: 13px; font-weight: normal; color: #6e6e80; } .aliyun-docs-content table.api-reference .tabbed-content-box { border: none; } .aliyun-docs-content table.api-reference .tabbed-content-box section { padding: 8px 0 !important; } .aliyun-docs-content table.api-reference .tabbed-content-box.mini .tab-box { /\* position: absolute; left: 40px; right: 0; \*/ } .aliyun-docs-content .margin-top-33 { margin-top: 33px !important; } .aliyun-docs-content .two-codeblocks pre { max-height: calc(50vh - 136px) !important; height: auto; } .expandable-content section { border-bottom: 1px solid #e9e9e9; padding-top: 6px; padding-bottom: 4px; } .expandable-content section:last-child { border-bottom: none; } .expandable-content section:first-child { padding-top: 0; }
@@ -0,0 +1,174 @@
千问-图像翻译模型(Qwen-MT-Image)可精准翻译图像中的文字,并保留原始排版。该模型还支持领域提示、敏感词过滤、术语干预等自定义功能。
**重要**
本文档描述的功能仅在华北2(北京)地域可用,必须使用该地域的[API Key](https://bailian.console.aliyun.com/cn-beijing/?tab=model#/api-key)。
**重要**
阿里云百炼为华北2(北京)地域推出了业务空间专属域名 `https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com`,**能够为推理请求提供卓越的性能和更高的稳定性**,建议从 `https://dashscope.aliyuncs.com` 迁移至新域名。
其中 `{WorkspaceId}` 为您的业务空间 ID,可在阿里云百炼控制台的**业务空间详情**页面查看。现有域名仍可正常使用。
## **模型概览**
| ![1](https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/6962921671/p1007156.webp) 源语种:中文 | ![2](https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/6962921671/p1006389.webp) 英文 | ![3](https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/6962921671/p1006396.webp) 日文 |
| --- | --- | --- |
| ![4](https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/6962921671/p1006438.webp) 韩语 | ![es](https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/6962921671/p1006451.webp) 西班牙语 | ![fr](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=) 法语 |
| **模型名称** | **模型简介** | **输出图像规格** |
| --- | --- | --- |
| qwen-mt-image | 千问-图像翻译模型 支持中/英文与其他语种之间的互译,但不支持在非中/英语种之间直接翻译(例如,从日语翻译为韩语)。详情请参见[支持的语种](#d2aa4b03d2kco)。 | 图片格式:JPG。 |
## 前提条件
您需要已[获取API Key](https://help.aliyun.com/zh/model-studio/get-api-key)并[配置API Key到环境变量](https://help.aliyun.com/zh/model-studio/configure-api-key-through-environment-variables)。
## HTTP调用
`POST https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/image2image/image-synthesis`,调用时请将`{WorkspaceId}`替换为真实的[Workspace ID](https://help.aliyun.com/zh/model-studio/obtain-the-app-id-and-workspace-id#d3eb3cd37b7fu)。
HTTP API 采用异步模式,调用流程分两步:
1. **创建任务获取任务ID**:发送一个请求创建任务,该请求会返回**任务ID(task\_id)**。
2. **根据任务ID查询结果**:使用task\_id轮询任务状态,直到任务完成并获得图像URL。
### 步骤1:创建任务获取任务ID
**说明**
- 创建成功后,使用接口返回的 `task_id` 查询结果,task\_id 有效期为 24 小时。**请勿重复创建任务**,轮询获取即可。
- 新手指引请参见[Postman](https://help.aliyun.com/zh/model-studio/first-call-to-image-and-video-api)。
| #### 请求参数 | ## 图像翻译 ``` # 以下为华北2(北京)地域的URL。请将 {WorkspaceId} 替换为您的百炼业务空间ID,各地域的URL不同。 curl --location 'https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/image2image/image-synthesis' \\ --header 'X-DashScope-Async: enable' \\ --header "Authorization: Bearer $DASHSCOPE_API_KEY" \\ --header 'Content-Type: application/json' \\ --data '{ "model": "qwen-mt-image", "input": { "image_url": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20250916/ordhsk/1.webp", "source_lang": "zh", "target_lang": "en", "ext": { "config": { "imageSegment": false } } } }' ``` |
| --- | --- |
| ##### 请求头(Headers) |
| **Content-Type** `*string*` **(必选)** 请求内容类型。此参数必须设置为`application/json`。 |
| **Authorization** `*string*`**(必选)** 请求身份认证。接口使用阿里云百炼API Key进行身份认证。示例值:Bearer sk-xxxx。 |
| **X-DashScope-Async** `*string*` **(必选)** 异步处理配置参数。HTTP请求只支持异步,**必须设置为**`**enable**`。 **重要** 缺少此请求头将报错:“current user api does not support synchronous calls”。 |
| ##### 请求体(Request Body) |
| **model** `*string*` **(必选)** 模型名称,必须设置为`qwen-mt-image`。 |
| **input** `*object*` **(必选)** 输入参数对象,包含以下字段: **属性** **image\\_url** `*string*` **(必选)** 图像的公网可访问的URL,支持 HTTP 和 HTTPS 协议。如需获取本地文件的公网URL,请参见[上传文件获取临时URL](https://help.aliyun.com/zh/model-studio/get-temporary-file-url) 。 - **格式限制**:JPG、JPEG、PNG、BMP、PNM、PPM、TIFF、WEBP - **尺寸限制**:图像的宽度和高度均需在15-8192像素范围内,宽高比在1:10至10:1范围内。 - **大小限制**:不超过100MB - URL地址中不能包含中文字符。 - **示例**:`https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20250916/ordhsk/1.webp` **source\\_lang** `*string*` **(必选)** [源语种](#d2aa4b03d2kco)。 - **支持值**:语种全称、语种编码或`auto`(自动检测),对大小写不敏感 - **限制**:与`target_lang`不同,且至少有一项为中文或英文 - **示例**:`Chinese`、`en`、`auto` **target\\_lang** `*string*` **(必选)** [目标语种](#d2aa4b03d2kco)。 - **支持值**:语种全称或语种编码,对大小写不敏感 - **限制**:与`source_lang`不同,且至少有一项为中文或英文 - **示例**:`Chinese`、`en` **ext** `*object*` (可选) 可选拓展字段。 **属性** **domainHint** `*string*` (可选) 领域提示,为使译文风格更贴合特定领域,可以使用英文描述使用场景、译文风格等需求。 为确保翻译效果,建议不超过200个英文单词。 **重要** 领域提示语句当前**只支持英文**。 **示例:**These sentences are from seller-buyer conversations on a B2C ecommerce platform. Translate them into clear, engaging customer service language, ensuring the translation is appropriate for handling potential issues or disputes. **sensitives** `*array*` (可选) 配置敏感词,以在翻译前过滤图片中**完全匹配**的文本,**对大小写敏感**。 敏感词的语种可与源语种不一致,支持全部的[源语种](#ffcebd6c57dfn)和[目标语种](#d2aa4b03d2kco)。为确保翻译效果,建议单次请求添加的敏感词不超过50个。 **示例:**\\["全场9折", "七天无理由退换"\\] **terminologies** `*array*` (可选) 术语干预,为特定术语设定译文,以满足特定领域的翻译需求,术语对的语种需要与`source_lang`和`target_lang`对应。 **属性** **src** `*string*` **(必选)** 术语的源文本,语种需要与源语种`source_lang`一致。 **tgt** `*string*` **(必选)** 术语的目标文本,语种需要与目标语种`target_lang`一致。 **示例**:\\[{"src": "应用程序接口", "tgt": "API"}, {"src": "机器学习", "tgt": "ML"}\\] **config** `*object*` (可选) **属性** **imageSegment** `*bool*` (可选) 是否开启图像主体分割。开启后,将跳过对图像中主体(如人物、商品、Logo)上文字的翻译。 - `false`:(默认值)翻译图像中的所有文字。 - `true`:不翻译图像主体的文字。 > **注意**:旧版本参数名为`skipImgSegment`(是否跳过图像主体分割)。为保持兼容,该参数仍受支持,但建议使用新的 `imageSegment`参数。 |
| #### 响应参数 | ### 成功响应 请保存 task\\_id,用于查询任务状态与结果。 ``` { "output": { "task_status": "PENDING", "task_id": "0385dc79-5ff8-4d82-bcb6-xxxxxx" }, "request_id": "4909100c-7b5a-9f92-bfe5-xxxxxx" } ``` ### 异常响应 创建任务失败,请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)进行解决。 ``` { "code": "InvalidApiKey", "message": "No API-key provided.", "request_id": "7438d53d-6eb8-4596-8835-xxxxxx" } ``` |
| --- | --- |
| **output** `*object*` 任务输出信息。 **属性** **task\\_id** `*string*` 任务ID。查询有效期24小时。 **task\\_status** `*string*` 任务状态。 **枚举值** - PENDING:任务排队中 - RUNNING:任务处理中 - SUCCEEDED:任务执行成功 - FAILED:任务执行失败 - CANCELED:任务已取消 - UNKNOWN:任务不存在或状态未知 |
| **request\\_id** `*string*` 请求唯一标识。可用于请求明细溯源和问题排查。 |
| **message** `*string*` 请求失败的详细信息。请求成功时不会返回此参数,详情请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)。 |
| **code** `*string*` 请求失败的错误码。请求成功时不会返回此参数,详情请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)。 |
### 步骤2:根据任务ID查询结果
`GET https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/tasks/{task_id}`
**说明**
- `task_id` 有效期为**24小时**,若ID不存在或已过期,任务状态将返回 `UNKNOWN`。
- 任务成功后返回的 `url`有效期为**24小时**,请及时下载并保存图像。
- 此查询接口的默认RPS为1。如需更高频次的查询或事件通知,请[配置异步任务回调](https://help.aliyun.com/zh/model-studio/async-task-api)。
- 如需批量查询或取消任务,请参见[管理异步任务](https://help.aliyun.com/zh/model-studio/manage-asynchronous-tasks#f26499d72adsl)。
| #### 请求参数 | ## 查询任务结果 您需要将`86ecf553-d340-4e21-xxxxxxxxx`替换为真实的task\\_id。 ``` # 以下为华北2(北京)地域的URL。请将 {WorkspaceId} 替换为您的百炼业务空间ID,各地域的URL不同。 curl -X GET https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/tasks/86ecf553-d340-4e21-xxxxxxxxx \\ --header "Authorization: Bearer $DASHSCOPE_API_KEY" ``` |
| --- | --- |
| ##### **请求头(Headers)** |
| **Authorization** `*string*`**(必选)** 请求身份认证。接口使用阿里云百炼API Key进行身份认证。示例值:Bearer sk-xxxx。 |
| ##### **URL路径参数(Path parameters)** |
| **task\\_id** `*string*`**(必选)** 任务ID。 |
| #### **响应参数** | ## 任务执行成功-存在可翻译内容 任务数据(如任务状态、图像URL等)仅保留24小时,超时后会被自动清除。请您务必及时保存生成的图像。 ``` { "request_id": "5fec62eb-bf94-91f8-b9f4-f7f758e4e27e", "output": { "task_id": "72c52225-8444-4cab-ad0c-xxxxxx", "task_status": "SUCCEEDED", "submit_time": "2025-08-13 18:11:16.954", "scheduled_time": "2025-08-13 18:11:17.003", "end_time": "2025-08-13 18:11:23.860", "image_url": "http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/xxx?Expires=xxx" }, "usage": { "image_count":1 } } ``` ## 任务执行成功-无可翻译内容 在图像中无可翻译文本(例如,在识别出图像主体后,其余部分无文字)时,**任务仍会成功并正常计费**,但会返回`No text detected for translation`的提示。 ``` { "request_id": "0ccb84aa-e034-431d-9d54-08e14fxxxxxx", "output": { "task_id": "34ec4208-97d6-498b-a390-9173f7xxxxxx", "task_status": "SUCCEEDED", "submit_time": "2026-01-12 17:56:23.625", "scheduled_time": "2026-01-12 17:56:23.656", "end_time": "2026-01-12 17:56:25.324", "image_url": "http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/xxx.jpg?Expires=xxx", "message": "No text detected for translation" }, "usage": { "image_count": 1 } } ``` ## 任务执行失败 若任务执行失败,task\\_status将置为 FAILED,并提供错误码和信息。请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)进行解决。 ``` { "request_id": "daad9007-6acd-9fb3-a6bc-d55902b9c9ff", "output": { "task_id": "fe8aa114-d9f1-4f76-b598-xxxxxx", "task_status": "FAILED", "submit_time": "2025-08-20 09:54:21.911", "scheduled_time": "2025-08-20 09:54:21.984", "end_time": "2025-08-20 12:55:00.818", "code": "InternalError", "message": "xxxxxx" } } ``` |
| --- | --- |
| **output** `*object*` 任务输出信息。 **属性** **task\\_id** `*string*` 任务ID。查询有效期24小时。 **task\\_status** `*string*` 任务状态。 **枚举值** - PENDING:任务排队中 - RUNNING:任务处理中 - SUCCEEDED:任务执行成功 - FAILED:任务执行失败 - CANCELED:任务已取消 - UNKNOWN:任务不存在或状态未知 **submit\\_time** `*string*` 任务提交时间。格式为 YYYY-MM-DD HH:mm:ss.SSS。 **scheduled\\_time** `*string*` 任务执行时间。格式为 YYYY-MM-DD HH:mm:ss.SSS。 **end\\_time** `*string*` 任务完成时间。格式为 YYYY-MM-DD HH:mm:ss.SSS。 **image\\_url** `*string*` 模型生成图像的URL地址,与原图长宽相同,JPG格式。有效期为24小时,请及时下载并保存图像。 **code** `*string*` 请求失败的错误码。请求成功时不会返回此参数,详情请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)。 **message** `*string*` 请求失败的详细信息,详情请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)。 通常请求成功时不会返回此参数,仅在图像中无可翻译文本(例如,在分割图像主体后,其余部分无文字)时,**任务仍会成功并正常计费**,但会返回`No text detected for translation`的提示。 |
| **usage** `*object*` 输出信息统计。只对成功的结果计数。 **属性** **image\\_count** `*integer*` 模型生成图像的数量,固定为1。 |
| **request\\_id** `*string*` 请求唯一标识。可用于请求明细溯源和问题排查。 |
## **支持的语种**
进行图像翻译时,**源语种或目标语种必须至少有一种是中文或英文**。不支持在两个非中、英语种之间直接翻译(例如,从日语翻译为韩语)。若不确定源语种,可将 `source_lang` 设置为 `auto` 进行自动检测。
| **语种(中文名)** | **英文全称** | **编码** | **支持作为源语种** | **支持作为目标语种** |
| --- | --- | --- | --- | --- |
| 简体中文 | Chinese | zh | 支持 | 支持 |
| 英文 | English | en | 支持 | 支持 |
| 韩语 | Korean | ko | 支持 | 支持 |
| 日语 | Japanese | ja | 支持 | 支持 |
| 俄语 | Russian | ru | 支持 | 支持 |
| 西班牙语 | Spanish | es | 支持 | 支持 |
| 法语 | French | fr | 支持 | 支持 |
| 葡萄牙语 | Portuguese | pt | 支持 | 支持 |
| 意大利语 | Italian | it | 支持 | 支持 |
| 德语 | German | de | 支持 | 不支持 |
| 越南语 | Vietnamese | vi | 支持 | 支持 |
| 马来语 | Malay | ms | 不支持 | 支持 |
| 泰语 | Thai | th | 不支持 | 支持 |
| 印尼语 | Indonesian | id | 不支持 | 支持 |
| 阿拉伯语 | Arabian | ar | 不支持 | 支持 |
## **计费与限流**
- 模型免费额度和计费单价请参见[模型价格](https://help.aliyun.com/zh/model-studio/model-pricing#a3250eda6bgjx)。
- 模型限流请参见[限流](https://help.aliyun.com/zh/model-studio/rate-limit#f812e7c63axvx)。
- 计费说明:按成功生成的**图像张数**计费。模型调用失败或处理错误不产生任何费用,也不消耗[新人免费额度](https://help.aliyun.com/zh/model-studio/new-free-quota)。
- **注意**:如果图像中无可翻译文本,或在启用主体识别功能后,非主体部分无文字时,**任务仍记为成功**并**正常计费**,此时接口会返回`No text detected for translation`的提示。
## **错误码**
如果模型调用失败并返回报错信息,请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)进行解决。
## **常见问题**
#### **Q:为什么图中的内容没有被翻译?**
A:因为启用了[主体分割](#245ec24fe92ls)功能,模型不会翻译图片中人物、商品、Logo等主体上的文字。若需翻译所有文字,请将`ext.config.imgSegment`参数设置为`false`。
#### **Q:如何将临时的图像链接转为永久链接?**
A:临时链接无法直接转为永久链接。需通过后端服务下载图像,再上传至对象存储服务(如阿里云 OSS)以生成新的永久链接。
**示例代码:下载图像到本地**
```
import requests
def download_and_save_image(image_url, save_path):
try:
response = requests.get(image_url, stream=True, timeout=300) # 设置超时
response.raise_for_status() # 如果HTTP状态码不是200,则引发异常
with open(save_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
print(f"图像已成功下载到: {save_path}")
# 此处可以接上传到永久存储的逻辑
except requests.exceptions.RequestException as e:
print(f"图像下载失败: {e}")
if __name__ == '__main__':
image_url = "http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/xxx?Expires=xxx"
save_path = "image-translation.jpg"
download_and_save_image(image_url, save_path)
```
### **Q: 如何查看模型调用量?**
A: 模型调用完一小时后,请在[**模型监控**(北京)](https://bailian.console.aliyun.com/cn-beijing/?tab=model#/model-telemetry)或[**模型监控**(新加坡)](https://modelstudio.console.aliyun.com/ap-southeast-1?tab=dashboard#/model-telemetry) 页面,查看模型的调用次数、成功率等指标。详情请参见[账单查询与成本管理](https://help.aliyun.com/zh/model-studio/bill-query-and-cost-management)。
### **Q:如何获取图像存储的访问域名白名单?**
A: 模型生成的图像存储于阿里云OSS,API将返回一个临时的公网URL。**若需要对该下载地址进行防火墙白名单配置**,请注意:由于底层存储会根据业务情况进行动态变更,为避免过期信息影响访问,文档不提供固定的OSS域名白名单。如有安全管控需求,请联系客户经理获取最新OSS域名列表。
/\*表格图片设置为块元素(独占一行),居中展示,鼠标放在图片上可以点击查看原图\*/ .unionContainer .markdown-body .image.break { margin: 0px; display: inline-block; vertical-align: middle }
/\* 调整 table 宽度 \*/ .aliyun-docs-content table.medium-width { max-width: 1018px; width: 100%; } .aliyun-docs-content table.table-no-border tr td:first-child { padding-left: 0; } .aliyun-docs-content table.table-no-border tr td:last-child { padding-right: 0; } /\* 支持吸顶 \*/ div:has(.aliyun-docs-content), .aliyun-docs-content .markdown-body { overflow: visible; } .stick-top { position: sticky; top: 46px; } /\*\*代码块字体\*\*/ /\* 减少表格中的代码块 margin,让表格信息显示更紧凑 \*/ .unionContainer .markdown-body table .help-code-block { margin: 0 !important; } /\* 减少表格中的代码块字号,让表格信息显示更紧凑 \*/ .unionContainer .markdown-body .help-code-block pre { font-size: 12px !important; } /\* 减少表格中的代码块字号,让表格信息显示更紧凑 \*/ .unionContainer .markdown-body .help-code-block pre code { font-size: 12px !important; } /\*\* API Reference 表格 \*\*/ .aliyun-docs-content table.api-reference tr td:first-child { margin: 0px; border-bottom: 1px solid #d8d8d8; } .aliyun-docs-content table.api-reference tr:last-child td:first-child { border-bottom: none; } .aliyun-docs-content table.api-reference p { color: #6e6e80; } .aliyun-docs-content table.api-reference b, i { color: #181818; } .aliyun-docs-content table.api-reference .collapse { border: none; margin-top: 4px; margin-bottom: 4px; } .aliyun-docs-content table.api-reference .collapse .expandable-title-bold { padding: 0; } .aliyun-docs-content table.api-reference .collapse .expandable-title { padding: 0; } .aliyun-docs-content table.api-reference .collapse .expandable-title-bold .title { margin-left: 16px; } .aliyun-docs-content table.api-reference .collapse .expandable-title .title { margin-left: 16px; } .aliyun-docs-content table.api-reference .collapse .expandable-title-bold i.icon { position: absolute; color: #777; font-weight: 100; } .aliyun-docs-content table.api-reference .collapse .expandable-title i.icon { position: absolute; color: #777; font-weight: 100; } .aliyun-docs-content table.api-reference .collapse.expanded .expandable-content { padding: 10px 14px 10px 14px !important; margin: 0; border: 1px solid #e9e9e9; } .aliyun-docs-content table.api-reference .collapse .expandable-title-bold b { font-size: 13px; font-weight: normal; color: #6e6e80; } .aliyun-docs-content table.api-reference .collapse .expandable-title b { font-size: 13px; font-weight: normal; color: #6e6e80; } .aliyun-docs-content table.api-reference .tabbed-content-box { border: none; } .aliyun-docs-content table.api-reference .tabbed-content-box section { padding: 8px 0 !important; } .aliyun-docs-content table.api-reference .tabbed-content-box.mini .tab-box { /\* position: absolute; left: 40px; right: 0; \*/ } .aliyun-docs-content .margin-top-33 { margin-top: 33px !important; } .aliyun-docs-content .two-codeblocks pre { max-height: calc(50vh - 136px) !important; height: auto; } .expandable-content section { border-bottom: 1px solid #e9e9e9; padding-top: 6px; padding-bottom: 4px; } .expandable-content section:last-child { border-bottom: none; } .expandable-content section:first-child { padding-top: 0; }
span.aliyun-docs-icon { color: transparent !important; font-size: 0 !important; } span.aliyun-docs-icon:before { color: black; font-size: 16px; } span.aliyun-docs-icon.icon-size-20:before { font-size: 20px; } span.aliyun-docs-icon.icon-size-22:before { font-size: 22px; } span.aliyun-docs-icon.icon-size-24:before { font-size: 24px; } span.aliyun-docs-icon.icon-size-26:before { font-size: 26px; } span.aliyun-docs-icon.icon-size-28:before { font-size: 28px; }
@@ -0,0 +1,969 @@
千问-文生图模型(Qwen-Image)是一款通用图像生成模型,支持多种艺术风格,尤其擅长**复杂文本渲染**。模型支持多行布局、段落级文本生成以及细粒度细节刻画,可实现复杂的图文混合布局设计。
| **快速入口:**[使用指南](https://help.aliyun.com/zh/model-studio/text-to-image) **\\|** 在线体验([北京](https://bailian.console.aliyun.com/cn-beijing/?tab=model#/efm/model_experience_center/vision?currentTab=imageGenerate&modelId=qwen-image-max) \\| [新加坡](https://modelstudio.console.aliyun.com/ap-southeast-1?tab=dashboard#/efm/model_experience_center/vision?currentTab=imageGenerate&modelId=qwen-image-max)) **\\|** [技术博客](https://qwen.ai/blog?id=9467b4bff9c638e847f08443802c6b96ab116a87&from=research.research-list) |
| --- | --- | --- | --- |
## **效果展示**
| **输入提示词** | **输出图像** |
| --- | --- |
| 冬日北京的都市街景,青灰瓦顶、朱红色外墙的两间相邻中式商铺比肩而立,檐下悬挂印有剪纸马的暖光灯笼,在阴天漫射光中投下柔和光晕,映照湿润鹅卵石路面泛起细腻反光。左侧为书法店:靛蓝色老旧的牌匾上以遒劲行书刻着“文字渲染”。店门口的玻璃上挂着一幅字,自上而下,用田英章硬笔写着“专业幻灯片 中英文海报 高级信息图”,落款印章为“1k token”朱砂印。店内的墙上,可以模糊的辨认有三幅竖排的书法作品,第一幅写着“阿里巴巴”,第二幅写着“通义千问”,第三幅写着“图像生成”。一位白发苍苍的老人背对着镜头观赏。右侧为花店,牌匾上以鲜花做成文字“真实质感”;店内多层花架陈列红玫瑰、粉洋牡丹和绿植,门上贴了一个圆形花边标识,标识上写着“2k resolution”,门口摆放了一个彩色霓虹灯,上面写着“细腻刻画 人物 自然 建筑”。两家店中间堆放了一个雪人,举了一老式小黑板,上面用粉笔字写着“Qwen-Image-2.0 正式发布”。街道左侧,年轻情侣依偎在一起,女孩是瘦脸,身穿米白色羊绒大衣,肉色光腿神器。女孩举着心形透明气球,气球印有白色的字:“生图编辑二合一”。里面有一个毛茸茸的卡皮巴拉玩偶。男孩身着剪裁合体的深灰色呢子外套,内搭浅色高领毛衣。街道右侧,一个后背上写着“更小模型,更快速度”的骑手疾驰而过。整条街光影交织、动静相宜。 | ![image (10)](https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/9373463771/p1058343.png) |
## 模型概览
| **模型名称** | **模型简介** | **输出图像规格** |
| --- | --- | --- |
| qwen-image-2.0-pro `**推荐**` > 当前与qwen-image-2.0-pro-2026-04-22能力相同 | 千问图像生成与编辑模型Pro系列。文字渲染、真实质感、语义遵循能力更强。 > 图像编辑请参考[千问-图像编辑](https://help.aliyun.com/zh/model-studio/qwen-image-edit-api)。 | 图像分辨率:支持自由设置宽高,输出图像总像素需在512\\*512至2048\\*2048之间。默认分辨率为2048\\*2048。 图像格式:png 图像张数:1-6张 |
| qwen-image-2.0-pro-2026-06-22 `**推荐**` |
| qwen-image-2.0-pro-2026-04-22 |
| qwen-image-2.0-pro-2026-03-03 |
| qwen-image-2.0 `**推荐**` > 当前与qwen-image-2.0-2026-03-03能力相同 | 千问图像生成与编辑模型加速版,兼顾效果与响应速度。 > 图像编辑请参考[千问-图像编辑](https://help.aliyun.com/zh/model-studio/qwen-image-edit-api)。 |
| qwen-image-2.0-2026-03-03 `**推荐**` |
| qwen-image-max > 当前与qwen-image-max-2025-12-30能力相同 | 千问图像生成模型Max系列。真实感、自然度更强,AI合成痕迹更低。 | 图像分辨率:可选分辨率及对应宽高比例请参见[size参数设置](#1c7b41f2d13sv) 图像格式:png 图像张数:固定1张 |
| qwen-image-max-2025-12-30 |
| qwen-image-plus > 当前与qwen-image能力相同 | 千问图像生成模型Plus系列,擅长多样化艺术风格与文字渲染。 |
| qwen-image-plus-2026-01-09 |
| qwen-image |
各地域支持的模型请参见[百炼控制台](https://bailian.console.aliyun.com/cn-beijing?tab=model#/model-market/all)。
## 前提条件
在调用前,先[获取API Key](https://help.aliyun.com/zh/model-studio/get-api-key),再[配置API Key到环境变量](https://help.aliyun.com/zh/model-studio/configure-api-key-through-environment-variables)。如需通过SDK进行调用,请[安装DashScope SDK](https://help.aliyun.com/zh/model-studio/install-sdk)。
**重要**
华北2(北京)和新加坡地域拥有独立的 **API Key** 与**请求地址**,不可混用,跨地域调用将导致鉴权失败或服务报错。
**重要**
阿里云百炼为华北2(北京)、新加坡地域推出了业务空间专属域名,**能够为推理请求提供卓越的性能和更高的稳定性**,建议迁移至新域名:
- 华北2(北京)地域:从 `https://dashscope.aliyuncs.com` 迁移至 `https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com`
- 新加坡地域:从 `https://dashscope-intl.aliyuncs.com` 迁移至 `https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com`
其中 `{WorkspaceId}` 为您的业务空间 ID,可在阿里云百炼控制台的**业务空间详情**页面查看。现有域名仍可正常使用。
## **同步接口(推荐)**
### **HTTP调用**
千问图像模型支持同步接口,一次请求即可获得结果,调用流程简单,推荐用于多数场景。
**北京地域**:`POST https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation`
**新加坡地域**:`POST https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation`
调用时请将`{WorkspaceId}`替换为真实的[Workspace ID](https://help.aliyun.com/zh/model-studio/obtain-the-app-id-and-workspace-id#d3eb3cd37b7fu)。
| #### 请求参数 | ## **文生图** ``` curl --location 'https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation' \\ --header 'Content-Type: application/json' \\ --header "Authorization: Bearer $DASHSCOPE_API_KEY" \\ --data '{ "model": "qwen-image-2.0-pro", "input": { "messages": [ { "role": "user", "content": [ { "text": "冬日北京的都市街景,青灰瓦顶、朱红色外墙的两间相邻中式商铺比肩而立,檐下悬挂印有剪纸马的暖光灯笼,在阴天漫射光中投下柔和光晕,映照湿润鹅卵石路面泛起细腻反光。左侧为书法店:靛蓝色老旧的牌匾上以遒劲行书刻着“文字渲染”。店门口的玻璃上挂着一幅字,自上而下,用田英章硬笔写着“专业幻灯片 中英文海报 高级信息图”,落款印章为“1k token”朱砂印。店内的墙上,可以模糊的辨认有三幅竖排的书法作品,第一幅写着“阿里巴巴”,第二幅写着“通义千问”,第三幅写着“图像生成”。一位白发苍苍的老人背对着镜头观赏。右侧为花店,牌匾上以鲜花做成文字“真实质感”;店内多层花架陈列红玫瑰、粉洋牡丹和绿植,门上贴了一个圆形花边标识,标识上写着“2k resolution”,门口摆放了一个彩色霓虹灯,上面写着“细腻刻画 人物 自然 建筑”。两家店中间堆放了一个雪人,举了一老式小黑板,上面用粉笔字写着“Qwen-Image-2.0 正式发布”。街道左侧,年轻情侣依偎在一起,女孩是瘦脸,身穿米白色羊绒大衣,肉色光腿神器。女孩举着心形透明气球,气球印有白色的字:“生图编辑二合一”。里面有一个毛茸茸的卡皮巴拉玩偶。男孩身着剪裁合体的深灰色呢子外套,内搭浅色高领毛衣。街道右侧,一个后背上写着“更小模型,更快速度”的骑手疾驰而过。整条街光影交织、动静相宜。" } ] } ] }, "parameters": { "negative_prompt": "低分辨率,低画质,肢体畸形,手指畸形,画面过饱和,蜡像感,人脸无细节,过度光滑,画面具有AI感。构图混乱。文字模糊,扭曲。", "prompt_extend": true, "watermark": false, "size": "2048*2048" } }' ``` |
| --- | --- |
| ##### 请求头(Headers) |
| **Content-Type** `*string*` **(必选)** 请求内容类型。此参数必须设置为`application/json`。 |
| **Authorization** `*string*`**(必选)** 请求身份认证。接口使用阿里云百炼API Key进行身份认证。示例值:Bearer sk-xxxx。 |
| ##### 请求体(Request Body) |
| **model** `*string*` **(必选)** 模型名称。示例值:`qwen-image-2.0-pro`。 |
| **input** `*object*` **(必选)** 输入的基本信息。 **属性** **messages** `*array*` **(必选)** 请求内容数组。**当前仅支持单轮对话**,数组内**有且只有一个元素**。 **属性** **role** `*string*` **(必选)** 消息的角色。此参数必须设置为`user`。 **content** `*array*` **(必选)** 消息内容数组。 **属性** **text** `*string*` **(必选)** 正向提示词用于描述您期望生成的图像内容、风格和构图。 支持中英文,qwen-image-2.0系列模型长度上限为 1300 Token,其他模型为 800 Token,超出部分将自动截断。 **注意**:仅支持传入一个text,不传或传入多个将报错。 |
| **parameters** `*object*` (可选) 图像处理参数。 **属性** **negative\\_prompt** `*string*` (可选) 反向提示词,用于描述不希望在图像中出现的内容,对画面进行限制。 支持中英文,长度不超过500个字符,超出部分将自动截断。 示例值:低分辨率,低画质,肢体畸形,手指畸形,画面过饱和,蜡像感,人脸无细节,过度光滑,画面具有AI感。构图混乱。文字模糊,扭曲。 **size** `*string*` (可选) 输出图像的分辨率,格式为`宽*高`。 **qwen-image-2.0系列模型**:输出图像总像素需在`512*512`至`2048*2048`之间,默认分辨率为`2048*2048`。推荐分辨率: - `2688*1536` :16:9 - `1536*2688` :9:16 - `2048*2048`(**默认值)**:1:1 - `2368*1728` :4:3 - `1728*2368` :3:4 **qwen-image-max、qwen-image-plus系列模型**:默认分辨率为`1664*928`。**可选**的分辨率及其对应的图像宽高比例为: - `1664*928`(**默认值**):16:9 - `1472*1104`:4:3 - `1328*1328`:1:1 - `1104*1472`:3:4 - `928*1664`:9:16 **n** `*integer*` (可选) 输出图像的数量,默认值为1。 对于qwen-image-2.0系列模型,可选择输出1-6张图片。 对于qwen-image-max、qwen-image-plus系列模型,此参数固定为1,设置其他值将导致报错。 **prompt\\_extend** `*bool*` (可选) 是否开启 Prompt(提示词)智能改写功能。开启后模型将对正向提示词进行优化与润色。此功能不会修改反向提示词。 - `true`:**默认值**,开启智能改写。如果希望图像内容更多样化,由模型补充细节,建议开启此选项。 - `false`:关闭智能改写。如果图像细节更可控,建议关闭此选项,并参考[文生图Prompt指南](https://help.aliyun.com/zh/model-studio/text-to-image-prompt)进行优化, 点击查看改写示例 > 当前仅异步接口返回实际提示词。 **原始提示词(orig\\_prompt)**:一只坐着的橘黄色的猫,表情愉悦,活泼可爱,逼真准确。 **实际提示词(actual\\_prompt)**:一只坐着的橘黄色猫咪,毛发蓬松柔软,阳光透过窗户洒在它身上,呈现出温暖的光泽。猫咪体型匀称,四肢自然弯曲,稳稳地坐在木质地板上,尾巴轻轻卷曲在身侧,显得格外放松而优雅。它的大眼睛圆润明亮,瞳孔微微收缩,流露出愉悦而灵动的神情,嘴角微扬,仿佛正享受着美好的时光。耳朵微微向前倾斜,透露出活泼与好奇。背景是一间温馨的现代家居客厅,浅色木地板、一扇半开的窗户透进柔和的自然光,窗外可见绿意盎然的庭院,窗台上摆放着几盆绿植。画面采用真实摄影风格,细节逼真,光影层次丰富,突出猫咪的毛发质感、眼神神态与整体姿态的生动自然,整体氛围轻松愉快,充满生活气息。 **watermark** `*bool*` (可选) 是否在图像右下角添加 "Qwen-Image" 水印。默认值为 `false`。水印样式:![1](https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/8972029571/p1012089.jpg) **seed** `*integer*` (可选) 随机数种子,取值范围`[0,2147483647]`。 使用相同的`seed`参数值可使生成内容保持相对稳定。若不提供,算法将自动使用随机数种子。 **注意**:模型生成过程具有概率性,即使使用相同的`seed`,也不能保证每次生成结果完全一致。 |
| #### 响应参数 | ## 任务执行成功 图像URL仅保留24小时,超时后会被自动清除,请及时保存生成的图像。 ``` { "output": { "choices": [ { "finish_reason": "stop", "message": { "content": [ { "image": "https://dashscope-result-sh.oss-cn-shanghai.aliyuncs.com/xxx.png?Expires=xxx" } ], "role": "assistant" } } ] }, "usage": { "height": 2048, "image_count": 1, "width": 2048 }, "request_id": "d0250a3d-b07f-49e1-bdc8-6793f4929xxx" } ``` ## 任务执行异常 如果因为某种原因导致任务执行失败,将返回相关信息,可以通过code和message字段明确指示错误原因。请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)进行解决。 ``` { "request_id": "a4d78a5f-655f-9639-8437-xxxxxx", "code": "InvalidParameter", "message": "num_images_per_prompt must be 1" } ``` |
| --- | --- |
| **output** `*object*` 任务输出信息。 **属性** **choices** `*array*` 模型生成的输出内容。此数组仅包含一个元素。 **属性** **finish\\_reason** `*string*` 任务停止原因,自然停止时为`stop`。 **message** `*object*` 模型返回的消息。 **属性** **role** `*string*` 消息的角色,固定为`assistant`。 **content** `*array*` **属性** **image** `*string*` 生成图像的 URL,图像格式为PNG。**链接有效期为24小时**,请及时下载并保存图像。 **task\\_metric** `*object*` 任务结果统计。使用qwen-image-2.0系列时无此返回值。 **属性** **TOTAL** `*integer*` 总的任务数。 **SUCCEEDED** `*integer*` 任务状态为成功的任务数。 **FAILED** `*integer*` 任务状态为失败的任务数。 |
| **usage** `*object*` 输出信息统计。只对成功的结果计数。 **属性** **image\\_count** `*integer*` 模型生成图像的数量,当前固定为1。 **width** `*integer*` 模型生成图像的宽度(像素)。 **height** `*integer*` 模型生成图像的高度(像素)。 |
| **request\\_id** `*string*` 请求唯一标识。可用于请求明细溯源和问题排查。 |
| **code** `*string*` 请求失败的错误码。请求成功时不会返回此参数,详情请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)。 |
| **message** `*string*` 请求失败的详细信息。请求成功时不会返回此参数,详情请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)。 |
* * *
### **DashScope SDK调用**
DashScope SDK目前已支持Python和Java。
SDK与HTTP接口的参数名基本一致,参数结构根据语言特性进行封装。同步调用参数说明可参考[HTTP调用](#90575c8228nmq)。
## Python
**说明**
请先确认已安装最新版DashScope Python SDK,否则可能运行报错:[安装SDK](https://help.aliyun.com/zh/model-studio/install-sdk)。
##### **请求示例**
```
import json
import os
import dashscope
from dashscope import MultiModalConversation
# 以下为华北2(北京)地域的URL,调用时请将 {WorkspaceId} 替换为真实的业务空间ID,各地域的URL不同。
dashscope.base_http_api_url = 'https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1'
messages = [
{
"role": "user",
"content": [
{"text": "冬日北京的都市街景,青灰瓦顶、朱红色外墙的两间相邻中式商铺比肩而立,檐下悬挂印有剪纸马的暖光灯笼,在阴天漫射光中投下柔和光晕,映照湿润鹅卵石路面泛起细腻反光。左侧为书法店:靛蓝色老旧的牌匾上以遒劲行书刻着“文字渲染”。店门口的玻璃上挂着一幅字,自上而下,用田英章硬笔写着“专业幻灯片 中英文海报 高级信息图”,落款印章为“1k token”朱砂印。店内的墙上,可以模糊的辨认有三幅竖排的书法作品,第一幅写着“阿里巴巴”,第二幅写着“通义千问”,第三幅写着“图像生成”。一位白发苍苍的老人背对着镜头观赏。右侧为花店,牌匾上以鲜花做成文字“真实质感”;店内多层花架陈列红玫瑰、粉洋牡丹和绿植,门上贴了一个圆形花边标识,标识上写着“2k resolution”,门口摆放了一个彩色霓虹灯,上面写着“细腻刻画 人物 自然 建筑”。两家店中间堆放了一个雪人,举了一老式小黑板,上面用粉笔字写着“Qwen-Image-2.0 正式发布”。街道左侧,年轻情侣依偎在一起,女孩是瘦脸,身穿米白色羊绒大衣,肉色光腿神器。女孩举着心形透明气球,气球印有白色的字:“生图编辑二合一”。里面有一个毛茸茸的卡皮巴拉玩偶。男孩身着剪裁合体的深灰色呢子外套,内搭浅色高领毛衣。街道右侧,一个后背上写着“更小模型,更快速度”的骑手疾驰而过。整条街光影交织、动静相宜。"}
]
}
]
# 新加坡和北京地域的API Key不同。获取API Key:https://help.aliyun.com/zh/model-studio/get-api-key
# 若没有配置环境变量,请用百炼API Key将下行替换为:api_key="sk-xxx"
api_key = os.getenv("DASHSCOPE_API_KEY")
response = MultiModalConversation.call(
api_key=api_key,
model="qwen-image-2.0-pro",
messages=messages,
result_format='message',
stream=False,
watermark=False,
prompt_extend=True,
negative_prompt="低分辨率,低画质,肢体畸形,手指畸形,画面过饱和,蜡像感,人脸无细节,过度光滑,画面具有AI感。构图混乱。文字模糊,扭曲。",
size='2048*2048'
)
if response.status_code == 200:
print(json.dumps(response, ensure_ascii=False))
else:
print(f"HTTP返回码:{response.status_code}")
print(f"错误码:{response.code}")
print(f"错误信息:{response.message}")
print("请参考文档:https://help.aliyun.com/zh/model-studio/developer-reference/error-code")
```
##### **响应示例**
> 图像链接的有效期为24小时,请及时下载图像。
```
{
"status_code": 200,
"request_id": "d2d1a8c0-325f-9b9d-8b90-xxxxxx",
"code": "",
"message": "",
"output": {
"text": null,
"finish_reason": null,
"choices": [
{
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": [
{
"image": "https://dashscope-result-wlcb.oss-cn-wulanchabu.aliyuncs.com/xxx.png?Expires=xxx"
}
]
}
}
]
},
"usage": {
"input_tokens": 0,
"output_tokens": 0,
"width": 2048,
"image_count": 1,
"height": 2048
}
}
```
## Java
**说明**
请先确认已安装最新版DashScope Java SDK,否则可能运行报错:[安装SDK](https://help.aliyun.com/zh/model-studio/install-sdk)。
##### **请求示例**
```
import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversation;
import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationParam;
import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationResult;
import com.alibaba.dashscope.common.MultiModalMessage;
import com.alibaba.dashscope.common.Role;
import com.alibaba.dashscope.exception.ApiException;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.alibaba.dashscope.exception.UploadFileException;
import com.alibaba.dashscope.utils.Constants;
import com.alibaba.dashscope.utils.JsonUtils;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
public class QwenImage {
static {
// 以下为华北2(北京)地域的URL,调用时请将 {WorkspaceId} 替换为真实的业务空间ID,各地域的URL不同。
Constants.baseHttpApiUrl = "https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1";
}
// 新加坡和北京地域的API Key不同。获取API Key:https://help.aliyun.com/zh/model-studio/get-api-key
// 若没有配置环境变量,请用百炼API Key将下行替换为:static String apiKey ="sk-xxx"
static String apiKey = System.getenv("DASHSCOPE_API_KEY");
public static void call() throws ApiException, NoApiKeyException, UploadFileException, IOException {
MultiModalConversation conv = new MultiModalConversation();
MultiModalMessage userMessage = MultiModalMessage.builder().role(Role.USER.getValue())
.content(Arrays.asList(
Collections.singletonMap("text", "冬日北京的都市街景,青灰瓦顶、朱红色外墙的两间相邻中式商铺比肩而立,檐下悬挂印有剪纸马的暖光灯笼,在阴天漫射光中投下柔和光晕,映照湿润鹅卵石路面泛起细腻反光。左侧为书法店:靛蓝色老旧的牌匾上以遒劲行书刻着“文字渲染”。店门口的玻璃上挂着一幅字,自上而下,用田英章硬笔写着“专业幻灯片 中英文海报 高级信息图”,落款印章为“1k token”朱砂印。店内的墙上,可以模糊的辨认有三幅竖排的书法作品,第一幅写着“阿里巴巴”,第二幅写着“通义千问”,第三幅写着“图像生成”。一位白发苍苍的老人背对着镜头观赏。右侧为花店,牌匾上以鲜花做成文字“真实质感”;店内多层花架陈列红玫瑰、粉洋牡丹和绿植,门上贴了一个圆形花边标识,标识上写着“2k resolution”,门口摆放了一个彩色霓虹灯,上面写着“细腻刻画 人物 自然 建筑”。两家店中间堆放了一个雪人,举了一老式小黑板,上面用粉笔字写着“Qwen-Image-2.0 正式发布”。街道左侧,年轻情侣依偎在一起,女孩是瘦脸,身穿米白色羊绒大衣,肉色光腿神器。女孩举着心形透明气球,气球印有白色的字:“生图编辑二合一”。里面有一个毛茸茸的卡皮巴拉玩偶。男孩身着剪裁合体的深灰色呢子外套,内搭浅色高领毛衣。街道右侧,一个后背上写着“更小模型,更快速度”的骑手疾驰而过。整条街光影交织、动静相宜。")
)).build();
Map<String, Object> parameters = new HashMap<>();
parameters.put("watermark", false);
parameters.put("prompt_extend", true);
parameters.put("negative_prompt", "低分辨率,低画质,肢体畸形,手指畸形,画面过饱和,蜡像感,人脸无细节,过度光滑,画面具有AI感。构图混乱。文字模糊,扭曲。");
parameters.put("size", "2048*2048");
MultiModalConversationParam param = MultiModalConversationParam.builder()
.apiKey(apiKey)
.model("qwen-image-2.0-pro")
.messages(Collections.singletonList(userMessage))
.parameters(parameters)
.build();
MultiModalConversationResult result = conv.call(param);
System.out.println(JsonUtils.toJson(result));
}
public static void main(String[] args) {
try {
call();
} catch (ApiException | NoApiKeyException | UploadFileException | IOException e) {
System.out.println(e.getMessage());
}
System.exit(0);
}
}
```
##### **响应示例**
> 图像链接的有效期为24小时,请及时下载图像。
```
{
"requestId": "5b6f2d04-b019-40db-a5cc-xxxxxx",
"usage": {
"image_count": 1,
"width": 2048,
"height": 2048
},
"output": {
"choices": [
{
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": [
{
"image": "https://dashscope-result-wlcb.oss-cn-wulanchabu.aliyuncs.com/xxx.png?Expires=xxx"
}
]
}
}
]
}
}
```
## **异步接口**
**重要**
当前仅qwen-image-plus、qwen-image模型支持异步接口调用。
### **HTTP调用**
调用流程分为两步:
1. **创建任务获取任务ID**:发送一个请求创建任务,该请求会返回**任务ID(task\_id)**。
2. **根据任务ID查询结果**:使用task\_id轮询任务状态,直到任务完成并获得图像URL。
#### **步骤1:创建任务获取任务ID**
**北京地域**:`POST https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/text2image/image-synthesis`
**新加坡地域**:`POST https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api/v1/services/aigc/text2image/image-synthesis`
**说明**
- 创建成功后,使用接口返回的 `task_id` 查询结果,task\_id 有效期为 24 小时。**请勿重复创建任务**,轮询获取即可。
- 新手指引请参见[Postman](https://help.aliyun.com/zh/model-studio/first-call-to-image-and-video-api)。
| ##### **请求参数** | ## 文生图 当前仅`qwen-image-plus`、`qwen-image`模型支持异步接口调用。 ``` curl -X POST https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/text2image/image-synthesis \\ -H 'X-DashScope-Async: enable' \\ -H "Authorization: Bearer $DASHSCOPE_API_KEY" \\ -H 'Content-Type: application/json' \\ -d '{ "model": "qwen-image-plus", "input": { "prompt": "一副典雅庄重的对联悬挂于厅堂之中,房间是个安静古典的中式布置,桌子上放着一些青花瓷,对联上左书“义本生知人机同道善思新”,右书“通云赋智乾坤启数高志远”, 横批“智启千问”,字体飘逸,在中间挂着一幅中国风的画作,内容是岳阳楼。" }, "parameters": { "negative_prompt":" ", "size": "1664*928", "n": 1, "prompt_extend": true, "watermark": false } }' ``` |
| --- | --- |
| ###### **请求头(Headers)** |
| **Content-Type** `*string*` **(必选)** 请求内容类型。此参数必须设置为`application/json`。 |
| **Authorization** `*string*`**(必选)** 请求身份认证。接口使用阿里云百炼API Key进行身份认证。示例值:Bearer sk-xxxx。 |
| **X-DashScope-Async** `*string*` **(必选)** 异步处理配置参数。HTTP请求只支持异步,**必须设置为**`**enable**`。 **重要** 缺少此请求头将报错:“current user api does not support synchronous calls”。 |
| ###### **请求体(Request Body)** |
| **model** `*string*` **(必选)** 模型名称。当前仅`qwen-image-plus`、`qwen-image`模型支持异步接口调用。 示例值:`qwen-image-plus`。 |
| **input** `*object*` **(必选)** 输入的基本信息,如提示词等。 **属性** **prompt** `*string*` **(必选)** 正向提示词,用来描述生成图像中期望包含的元素和视觉特点。 支持中英文,长度不超过800个字符,每个汉字、字母、数字或符号计为一个字符,超出部分将自动截断。 示例值:一只坐着的橘黄色的猫,表情愉悦,活泼可爱,逼真准确。 **negative\\_prompt** `*string*` (可选) 反向提示词,用于描述不希望在图像中出现的内容,对画面进行限制。 支持中英文,长度不超过500个字符,超出部分将自动截断。 示例值:低分辨率,低画质,肢体畸形,手指畸形,画面过饱和,蜡像感,人脸无细节,过度光滑,画面具有AI感。构图混乱。文字模糊,扭曲。 |
| **parameters** `*object*` (可选) 图像处理参数。 **属性** **size** `*string*` (可选) 输出图像的分辨率,格式为`宽*高`。 **qwen-image-2.0系列模型**:输出图像总像素需在`512*512`至`2048*2048`之间,默认分辨率为`2048*2048`。推荐分辨率: - `2688*1536` :16:9 - `1536*2688` :9:16 - `2048*2048`(**默认值)**:1:1 - `2368*1728` :4:3 - `1728*2368` :3:4 **qwen-image-max、qwen-image-plus系列模型**:默认分辨率为`1664*928`。**可选**的分辨率及其对应的图像宽高比例为: - `1664*928`(**默认值**):16:9 - `1472*1104`:4:3 - `1328*1328`:1:1 - `1104*1472`:3:4 - `928*1664`:9:16 **n** `*integer*` (可选) 生成图像的数量。**此参数当前固定为1,设置其他值将导致报错。** **prompt\\_extend** `*bool*` (可选) 是否开启 Prompt(提示词)智能改写功能。开启后模型将对正向提示词进行优化与润色。此功能不会修改反向提示词。 - `true`:**默认值**,开启智能改写。如果希望图像内容更多样化,由模型补充细节,建议开启此选项。 - `false`:关闭智能改写。如果图像细节更可控,建议关闭此选项,并参考[文生图Prompt指南](https://help.aliyun.com/zh/model-studio/text-to-image-prompt)进行优化, 点击查看改写示例 > 当前仅异步接口返回实际提示词。 **原始提示词(orig\\_prompt)**:一只坐着的橘黄色的猫,表情愉悦,活泼可爱,逼真准确。 **实际提示词(actual\\_prompt)**:一只坐着的橘黄色猫咪,毛发蓬松柔软,阳光透过窗户洒在它身上,呈现出温暖的光泽。猫咪体型匀称,四肢自然弯曲,稳稳地坐在木质地板上,尾巴轻轻卷曲在身侧,显得格外放松而优雅。它的大眼睛圆润明亮,瞳孔微微收缩,流露出愉悦而灵动的神情,嘴角微扬,仿佛正享受着美好的时光。耳朵微微向前倾斜,透露出活泼与好奇。背景是一间温馨的现代家居客厅,浅色木地板、一扇半开的窗户透进柔和的自然光,窗外可见绿意盎然的庭院,窗台上摆放着几盆绿植。画面采用真实摄影风格,细节逼真,光影层次丰富,突出猫咪的毛发质感、眼神神态与整体姿态的生动自然,整体氛围轻松愉快,充满生活气息。 **watermark** `*bool*` (可选) 是否在图像右下角添加 "Qwen-Image" 水印。默认值为 `false`。水印样式:![1](https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/8972029571/p1012089.jpg) **seed** `*integer*` (可选) 随机数种子,取值范围`[0,2147483647]`。 使用相同的`seed`参数值可使生成内容保持相对稳定。若不提供,算法将自动使用随机数种子。 **注意**:模型生成过程具有概率性,即使使用相同的`seed`,也不能保证每次生成结果完全一致。 |
| ##### **响应参数** | #### 成功响应 请保存 task\\_id,用于查询任务状态与结果。 ``` { "output": { "task_status": "PENDING", "task_id": "0385dc79-5ff8-4d82-bcb6-xxxxxx" }, "request_id": "4909100c-7b5a-9f92-bfe5-xxxxxx" } ``` #### 异常响应 创建任务失败,请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)进行解决。 ``` { "code": "InvalidApiKey", "message": "No API-key provided.", "request_id": "7438d53d-6eb8-4596-8835-xxxxxx" } ``` |
| --- | --- |
| **output** `*object*` 任务输出信息。 **属性** **task\\_id** `*string*` 任务ID。查询有效期24小时。 **task\\_status** `*string*` 任务状态。 **枚举值** - PENDING:任务排队中 - RUNNING:任务处理中 - SUCCEEDED:任务执行成功 - FAILED:任务执行失败 - CANCELED:任务已取消 - UNKNOWN:任务不存在或状态未知 |
| **request\\_id** `*string*` 请求唯一标识。可用于请求明细溯源和问题排查。 |
| **code** `*string*` 请求失败的错误码。请求成功时不会返回此参数,详情请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)。 |
| **message** `*string*` 请求失败的详细信息。请求成功时不会返回此参数,详情请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)。 |
#### **步骤2:根据任务ID查询结果**
##### **华北2(北京)**
`GET https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/tasks/{task_id}`
调用时请将`{WorkspaceId}`替换为真实的[Workspace ID](https://help.aliyun.com/zh/model-studio/obtain-the-app-id-and-workspace-id#d3eb3cd37b7fu)。
##### 新加坡
`GET https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api/v1/tasks/{task_id}`
调用时请将`{WorkspaceId}`替换为真实的[Workspace ID](https://help.aliyun.com/zh/model-studio/obtain-the-app-id-and-workspace-id#d3eb3cd37b7fu)。
**说明**
- **轮询建议**:图像生成过程耗时较长,建议采用**轮询**机制,并设置合理的查询间隔(如 10 秒)来获取结果。
- **任务状态流转**:PENDING(排队中)→ RUNNING(处理中)→ SUCCEEDED(成功)/ FAILED(失败)。
- **结果链接**:任务成功后返回图像链接,有效期为 **24 小时**。建议在获取链接后立即下载并转存至永久存储(如[阿里云 OSS](https://help.aliyun.com/zh/oss/user-guide/what-is-oss))。
- **RPS 限制**:查询接口默认RPS为20。如需更高频查询或事件通知,建议[配置异步任务回调](https://help.aliyun.com/zh/model-studio/async-task-api)。
- **更多操作**:如需批量查询、取消任务等操作,请参见[管理异步任务](https://help.aliyun.com/zh/model-studio/manage-asynchronous-tasks#f26499d72adsl)。
| ##### **请求参数** | ## 查询任务结果 将`{task_id}`完整替换为上一步接口返回的`task_id`的值。`task_id`查询有效期为24小时,并请将`{WorkspaceId}`替换为真实的[业务空间ID](https://help.aliyun.com/zh/model-studio/obtain-the-app-id-and-workspace-id#d3eb3cd37b7fu)。 ``` curl -X GET https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/tasks/{task_id} \\ --header "Authorization: Bearer $DASHSCOPE_API_KEY" ``` |
| --- | --- |
| ###### **请求头(Headers)** |
| **Authorization** `*string*`**(必选)** 请求身份认证。接口使用阿里云百炼API Key进行身份认证。示例值:Bearer sk-xxxx。 |
| ###### **URL路径参数(Path parameters)** |
| **task\\_id** `*string*`**(必选)** 任务ID。 |
| ##### **响应参数** | ## 任务执行成功 任务数据(如任务状态、图像URL等)仅保留24小时,超时后会被自动清除。请您务必及时保存生成的图像。 ``` { "request_id": "cf4a3304-fa4d-97b6-bc72-xxxxxx", "output": { "task_id": "18e7cde0-8c17-42aa-afc5-xxxxxx", "task_status": "SUCCEEDED", "submit_time": "2025-09-05 11:33:20.542", "scheduled_time": "2025-09-05 11:33:20.581", "end_time": "2025-09-05 11:33:40.807", "results": [ { "orig_prompt": "一副典雅庄重的对联悬挂于厅堂之中,房间是个安静古典的中式布置,桌子上放着一些青花瓷,对联上左书“义本生知人机同道善思新”,右书“通云赋智乾坤启数高志远”, 横批“智启千问”,字体飘逸,在中间挂着一幅中国风的画作,内容是岳阳楼。", "actual_prompt": "一副典雅庄重的对联悬挂于中式厅堂之中,对联左侧书写“义本生知人机同道善思新”,右侧书写“通云赋智乾坤启数高志远”,横批为“智启千问”,字体为飘逸洒脱的书法体,墨色浓淡相宜,展现出浓厚的文化气息与艺术美感。对联中央悬挂一幅中国风画作,描绘的是著名的岳阳楼景观,楼阁飞檐翘角,依水而建,远处山水氤氲,云雾缭绕,展现出古典诗意之美。\\n\\n整个画面背景为一个安静、布置典雅的中式房间,室内木质结构古朴,光线柔和,营造出宁静庄重的氛围。对联悬挂于房间正中墙面,下方为一长案几,案上摆放数件青花瓷器,器型古雅,纹饰精美,蓝白相间,与整体环境和谐统一。整体画面风格为中国水墨风,线条流畅,色彩淡雅,富有传统美学韵味。", "url": "https://dashscope-result-sz.oss-cn-shenzhen.aliyuncs.com/7d/xxx.png?Expires=xxxx" } ] }, "usage": { "image_count": 1 } } ``` ## 任务执行失败 若任务执行失败,task\\_status将置为 FAILED,并提供错误码和信息。请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)进行解决。 ``` { "request_id": "c61fe158-c0de-40f0-b4d9-964625119ba4", "output": { "task_id": "86ecf553-d340-4e21-xxxxxxxxx", "task_status": "FAILED", "submit_time": "2025-11-11 11:46:28.116", "scheduled_time": "2025-11-11 11:46:28.154", "end_time": "2025-11-11 11:46:28.255", "code": "InvalidParameter", "message": "xxxxxxxx" } } ``` |
| --- | --- |
| **output** `*object*` 任务输出信息。 **属性** **task\\_id** `*string*` 任务ID。查询有效期24小时。 **task\\_status** `*string*` 任务状态。 **枚举值** - PENDING:任务排队中 - RUNNING:任务处理中 - SUCCEEDED:任务执行成功 - FAILED:任务执行失败 - CANCELED:任务已取消 - UNKNOWN:任务不存在或状态未知 **submit\\_time** `*string*` 任务提交时间。格式为 YYYY-MM-DD HH:mm:ss.SSS。 **scheduled\\_time** `*string*` 任务执行时间。格式为 YYYY-MM-DD HH:mm:ss.SSS。 **end\\_time** `*string*` 任务完成时间。格式为 YYYY-MM-DD HH:mm:ss.SSS。 **results** `*array*` 任务结果列表,包括图像URL、prompt、部分任务执行失败报错信息等。 **属性** **orig\\_prompt** `*string*` 原始输入的prompt,对应请求参数`prompt`。 **actual\\_prompt** `*string*` 开启 prompt 智能改写后,返回实际使用的优化后 prompt。若未开启该功能,则不返回此字段。 **url** `*string*` 模型生成图像的URL地址。有效期为24小时,请及时下载并保存图像。 **code** `*string*` 请求失败的错误码。请求成功时不会返回此参数,详情请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)。 **message** `*string*` 请求失败的详细信息。请求成功时不会返回此参数,详情请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)。 |
| **usage** `*object*` 输出信息统计。只对成功的结果计数。 **属性** **image\\_count** `*integer*` 模型生成图像的数量,当前固定为1。 |
| **request\\_id** `*string*` 请求唯一标识。可用于请求明细溯源和问题排查。 |
### **DashScope SDK调用**
DashScope SDK目前已支持[Python](#a3ad9a3b6d9if)和[Java](#589b80853e6rn)。
SDK与HTTP接口的参数名基本一致,参数结构根据不同语言的SDK封装而定。异步调用参数说明可参考[HTTP调用](#42703589880ts)。
由于图像模型处理时间较长,底层服务采用异步方式。SDK在此基础上封装了两种调用模式:
- **同步调用(阻塞模式)**: SDK会自动等待任务完成,然后直接返回最终结果,调用体验与常规同步调用一致。
- **异步调用(非阻塞模式)**: 调用后将立即返回任务ID,需要用户根据该ID自行查询任务状态和最终结果。
#### **Python SDK调用**
**说明**
请先确认已安装最新版DashScope Python SDK,否则可能运行报错:[安装SDK](https://help.aliyun.com/zh/model-studio/install-sdk)。
## 同步调用
##### **请求示例**
```
from http import HTTPStatus
from urllib.parse import urlparse, unquote
from pathlib import PurePosixPath
import requests
from dashscope import ImageSynthesis
import os
import dashscope
# 以下为华北2(北京)地域的URL,调用时请将 {WorkspaceId} 替换为真实的业务空间ID,各地域的URL不同。
dashscope.base_http_api_url = 'https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1'
prompt = "一副典雅庄重的对联悬挂于厅堂之中,房间是个安静古典的中式布置,桌子上放着一些青花瓷,对联上左书“义本生知人机同道善思新”,右书“通云赋智乾坤启数高志远”, 横批“智启千问”,字体飘逸,在中间挂着一幅中国风的画作,内容是岳阳楼。"
# 新加坡和北京地域的API Key不同。获取API Key:https://help.aliyun.com/zh/model-studio/get-api-key
# 若没有配置环境变量,请用百炼API Key将下行替换为:api_key="sk-xxx"
api_key = os.getenv("DASHSCOPE_API_KEY")
print('----同步调用,请等待任务执行----')
rsp = ImageSynthesis.call(api_key=api_key,
model="qwen-image-plus", # 当前仅qwen-image-plus、qwen-image模型支持异步接口
prompt=prompt,
negative_prompt=" ",
n=1,
size='1664*928',
prompt_extend=True,
watermark=False)
print(f'response: {rsp}')
if rsp.status_code == HTTPStatus.OK:
# 在当前目录下保存图像
for result in rsp.output.results:
file_name = PurePosixPath(unquote(urlparse(result.url).path)).parts[-1]
with open('./%s' % file_name, 'wb+') as f:
f.write(requests.get(result.url).content)
else:
print(f'同步调用失败, status_code: {rsp.status_code}, code: {rsp.code}, message: {rsp.message}')
```
##### 响应示例
> url 有效期24小时,请及时下载图像。
```
{
"status_code": 200,
"request_id": "03b1ef03-480d-4ea5-ba52-xxxxxx",
"code": null,
"message": "",
"output": {
"task_id": "3cefd9bc-fcb2-4de9-a8bc-xxxxxx",
"task_status": "SUCCEEDED",
"results": [
{
"url": "https://dashscope-result-sz.oss-cn-shenzhen.aliyuncs.com/xxx.png?Expires=xxxxxx",
"orig_prompt": "一副典雅庄重的对联悬挂于厅堂之中,房间是个安静古典的中式布置,桌子上放着一些青花瓷,对联上左书“义本生知人机同道善思新”,右书“通云赋智乾坤启数高志远”, 横批“智启千问”,字体飘逸,在中间挂着一幅中国风的画作,内容是岳阳楼。",
"actual_prompt": "一副典雅庄重的对联悬挂于中式厅堂正中,整体空间为安静、古色古香的中国传统布置。厅堂内木质家具沉稳大气,墙面为淡色仿古纸张质感,地面铺设深色木质地板,营造出宁静而庄重的氛围。对联以飘逸的书法字体书写,左侧上联为“义本生知人机同道善思新”,右侧下联为“通云赋智乾坤启数高志远”,横批“智启千问”,文字排列对称,墨色深邃,书法流畅有力,体现出浓厚的文化气息与哲思内涵。\n\n对联中央悬挂一幅中国风画作,内容为岳阳楼,楼阁依水而建,背景为浩渺洞庭湖,远处山峦起伏,云雾缭绕,画面采用传统水墨技法绘制,笔触细腻,意境悠远。画作下方为一张中式红木长桌,桌上错落摆放着几件青花瓷器,包括花瓶与茶具,瓷器釉色清透,纹饰典雅,与整体环境风格和谐统一。整体画面风格为中国古典水墨风,空间布局层次分明,氛围宁静雅致,展现出浓厚的东方文化底蕴。"
}
],
"submit_time": "2025-09-09 13:41:54.041",
"scheduled_time": "2025-09-09 13:41:54.087",
"end_time": "2025-09-09 13:42:22.596"
},
"usage": {
"image_count": 1
}
}
```
## 异步调用
##### 请求示例
```
from http import HTTPStatus
from urllib.parse import urlparse, unquote
from pathlib import PurePosixPath
import requests
from dashscope import ImageSynthesis
import os
import dashscope
import time
# 以下为华北2(北京)地域的URL,调用时请将 {WorkspaceId} 替换为真实的业务空间ID,各地域的URL不同。
dashscope.base_http_api_url = 'https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1'
prompt = "一副典雅庄重的对联悬挂于厅堂之中,房间是个安静古典的中式布置,桌子上放着一些青花瓷,对联上左书“义本生知人机同道善思新”,右书“通云赋智乾坤启数高志远”, 横批“智启千问”,字体飘逸,在中间挂着一幅中国风的画作,内容是岳阳楼。"
# 若没有配置环境变量,请用百炼API Key将下行替换为:api_key="sk-xxx"
# 新加坡和北京地域的API Key不同。获取API Key:https://help.aliyun.com/zh/model-studio/get-api-key
api_key = os.getenv("DASHSCOPE_API_KEY")
def async_call():
print('----创建任务----')
task_info = create_async_task()
print('----轮询任务状态----')
poll_task_status(task_info)
# 创建异步任务
def create_async_task():
rsp = ImageSynthesis.async_call(api_key=api_key,
model="qwen-image-plus", # 当前仅qwen-image-plus、qwen-image模型支持异步接口
prompt=prompt,
negative_prompt=" ",
n=1,
size='1664*928',
prompt_extend=True,
watermark=False)
print(rsp)
if rsp.status_code == HTTPStatus.OK:
print(rsp.output)
else:
print(f'创建任务失败, status_code: {rsp.status_code}, code: {rsp.code}, message: {rsp.message}')
return rsp
# 轮询异步任务状态,每5秒查询一次,最多轮询1分钟
def poll_task_status(task):
start_time = time.time()
timeout = 60 # 1分钟超时
while True:
# 检查是否超时
if time.time() - start_time > timeout:
print('轮询超时(1分钟),任务未完成')
return
# 获取任务状态
status_rsp = ImageSynthesis.fetch(task)
print(f'任务状态查询结果: {status_rsp}')
if status_rsp.status_code != HTTPStatus.OK:
print(f'获取任务状态失败, status_code: {status_rsp.status_code}, code: {status_rsp.code}, message: {status_rsp.message}')
return
task_status = status_rsp.output.task_status
print(f'当前任务状态: {task_status}')
if task_status == 'SUCCEEDED':
print('任务已完成,正在下载图像...')
for result in status_rsp.output.results:
file_name = PurePosixPath(unquote(urlparse(result.url).path)).parts[-1]
with open(f'./{file_name}', 'wb+') as f:
f.write(requests.get(result.url).content)
print(f'图像已保存为: {file_name}')
break
elif task_status == 'FAILED':
print(f'任务执行失败, status: {task_status}, code: {status_rsp.code}, message: {status_rsp.message}')
break
elif task_status == 'PENDING' or task_status == 'RUNNING':
print('任务正在进行中,5秒后继续查询...')
time.sleep(5)
elif task_status == 'CANCELED':
print('任务已被取消。')
break
else:
print(f'未知任务状态: {task_status},5秒后继续查询...')
time.sleep(5)
# 取消异步任务,只有处于PENDING状态的任务才可以取消
def cancel_task(task):
rsp = ImageSynthesis.cancel(task)
print(rsp)
if rsp.status_code == HTTPStatus.OK:
print(rsp.output.task_status)
else:
print(f'取消任务失败, status_code: {rsp.status_code}, code: {rsp.code}, message: {rsp.message}')
if __name__ == '__main__':
async_call()
```
##### **响应示例**
1、创建任务的响应示例
```
{
"status_code": 200,
"request_id": "31b04171-011c-96bd-ac00-xxxxxx",
"code": "",
"message": "",
"output": {
"task_id": "4f90cf14-a34e-4eae-xxxxxxxx",
"task_status": "PENDING",
"results": []
},
"usage": null
}
```
2、查询任务结果的响应示例
> url 有效期24小时,请及时下载图像。
```
{
"status_code": 200,
"request_id": "03b1ef03-480d-4ea5-ba52-xxxxxx",
"code": null,
"message": "",
"output": {
"task_id": "3cefd9bc-fcb2-4de9-a8bc-xxxxxx",
"task_status": "SUCCEEDED",
"results": [
{
"url": "https://dashscope-result-sz.oss-cn-shenzhen.aliyuncs.com/xxx.png?Expires=xxxxxx",
"orig_prompt": "一副典雅庄重的对联悬挂于厅堂之中,房间是个安静古典的中式布置,桌子上放着一些青花瓷,对联上左书“义本生知人机同道善思新”,右书“通云赋智乾坤启数高志远”, 横批“智启千问”,字体飘逸,在中间挂着一幅中国风的画作,内容是岳阳楼。",
"actual_prompt": "一副典雅庄重的对联悬挂于中式厅堂正中,整体空间为安静、古色古香的中国传统布置。厅堂内木质家具沉稳大气,墙面为淡色仿古纸张质感,地面铺设深色木质地板,营造出宁静而庄重的氛围。对联以飘逸的书法字体书写,左侧上联为“义本生知人机同道善思新”,右侧下联为“通云赋智乾坤启数高志远”,横批“智启千问”,文字排列对称,墨色深邃,书法流畅有力,体现出浓厚的文化气息与哲思内涵。\n\n对联中央悬挂一幅中国风画作,内容为岳阳楼,楼阁依水而建,背景为浩渺洞庭湖,远处山峦起伏,云雾缭绕,画面采用传统水墨技法绘制,笔触细腻,意境悠远。画作下方为一张中式红木长桌,桌上错落摆放着几件青花瓷器,包括花瓶与茶具,瓷器釉色清透,纹饰典雅,与整体环境风格和谐统一。整体画面风格为中国古典水墨风,空间布局层次分明,氛围宁静雅致,展现出浓厚的东方文化底蕴。"
}
],
"submit_time": "2025-09-09 13:41:54.041",
"scheduled_time": "2025-09-09 13:41:54.087",
"end_time": "2025-09-09 13:42:22.596"
},
"usage": {
"image_count": 1
}
}
```
#### **Java SDK调用**
**说明**
请先确认已安装最新版DashScope Java SDK,否则可能运行报错:[安装SDK](https://help.aliyun.com/zh/model-studio/install-sdk)。
## 同步调用
##### 请求示例
```
// Copyright (c) Alibaba, Inc. and its affiliates.
import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesis;
import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesisListResult;
import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesisParam;
import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesisResult;
import com.alibaba.dashscope.exception.ApiException;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.alibaba.dashscope.task.AsyncTaskListParam;
import com.alibaba.dashscope.utils.Constants;
import com.alibaba.dashscope.utils.JsonUtils;
import java.util.HashMap;
import java.util.Map;
public class Text2Image {
static {
// 以下为华北2(北京)地域的URL,调用时请将 {WorkspaceId} 替换为真实的业务空间ID,各地域的URL不同。
Constants.baseHttpApiUrl = "https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1";
}
// 新加坡和北京地域的API Key不同。获取API Key:https://help.aliyun.com/zh/model-studio/get-api-key
// 若没有配置环境变量,请用百炼API Key将下行替换为:static String apiKey = "sk-xxx"
static String apiKey = System.getenv("DASHSCOPE_API_KEY");
public static void basicCall() throws ApiException, NoApiKeyException {
String prompt = "一副典雅庄重的对联悬挂于厅堂之中,房间是个安静古典的中式布置,桌子上放着一些青花瓷,对联上左书“义本生知人机同道善思新”,右书“通云赋智乾坤启数高志远”, 横批“智启千问”,字体飘逸,在中间挂着一幅中国风的画作,内容是岳阳楼。";
Map<String, Object> parameters = new HashMap<>();
parameters.put("prompt_extend", true);
parameters.put("watermark", false);
parameters.put("negative_prompt", " ");
ImageSynthesisParam param =
ImageSynthesisParam.builder()
.apiKey(apiKey)
// 当前仅qwen-image-plus、qwen-image模型支持异步接口
.model("qwen-image-plus")
.prompt(prompt)
.n(1)
.size("1664*928")
.parameters(parameters)
.build();
ImageSynthesis imageSynthesis = new ImageSynthesis();
ImageSynthesisResult result = null;
try {
System.out.println("---同步调用,请等待任务执行----");
result = imageSynthesis.call(param);
} catch (ApiException | NoApiKeyException e){
throw new RuntimeException(e.getMessage());
}
System.out.println(JsonUtils.toJson(result));
}
public static void main(String[] args){
try{
basicCall();
}catch(ApiException|NoApiKeyException e){
System.out.println(e.getMessage());
}
}
}
```
##### **响应示例**
> url 有效期24小时,请及时下载图像。
```
{
"request_id": "f2153409-3950-9b73-9980-xxxxxx",
"output": {
"task_id": "2fc2e1de-0245-442d-b664-xxxxxx",
"task_status": "SUCCEEDED",
"results": [
{
"orig_prompt": "一副典雅庄重的对联悬挂于厅堂之中,房间是个安静古典的中式布置,桌子上放着一些青花瓷,对联上左书“义本生知人机同道善思新”,右书“通云赋智乾坤启数高志远”, 横批“智启千问”,字体飘逸,在中间挂着一幅中国风的画作,内容是岳阳楼。",
"actual_prompt": "一副典雅庄重的对联悬挂于中式厅堂中央,对联左侧书写“义本生知人机同道善思新”,右侧书写“通云赋智乾坤启数高志远”,横批为“智启千问”,整体采用飘逸洒脱的书法字体,墨色浓淡相宜,展现出浓厚的传统韵味。对联中间悬挂一幅中国风画作,描绘的是著名的岳阳楼景观:楼阁飞檐翘角,依水而建,远处湖光潋滟,烟波浩渺,天空中有几缕轻云缭绕,营造出诗意盎然的意境。背景房间为安静古典的中式布置,木质家具线条流畅,桌上摆放着数件青花瓷器,纹饰精美,釉色莹润。整体空间光线柔和,营造出庄重、宁静的文化氛围。画面风格为传统中国水墨风,笔触细腻,层次分明,充满古典美感。",
"url": "https://dashscope-result-sz.oss-cn-shenzhen.aliyuncs.com/xxx.png?Expires=xxxx"
}
]
},
"usage": {
"image_count": 1
}
}
```
## 异步调用
##### 请求示例
```
// Copyright (c) Alibaba, Inc. and its affiliates.
import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesis;
import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesisParam;
import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesisResult;
import com.alibaba.dashscope.exception.ApiException;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.alibaba.dashscope.utils.Constants;
import com.alibaba.dashscope.utils.JsonUtils;
import java.util.HashMap;
import java.util.Map;
public class Text2Image {
static {
// 以下为华北2(北京)地域的URL,调用时请将 {WorkspaceId} 替换为真实的业务空间ID,各地域的URL不同。
Constants.baseHttpApiUrl = "https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1";
}
// 新加坡和北京地域的API Key不同。获取API Key:https://help.aliyun.com/zh/model-studio/get-api-key
// 若没有配置环境变量,请用百炼API Key将下行替换为:static String apiKey = "sk-xxx"
static String apiKey = System.getenv("DASHSCOPE_API_KEY");
public void asyncCall() {
System.out.println("---创建任务----");
String taskId = this.createAsyncTask();
System.out.println("--等待任务结束返回图像url----");
this.waitAsyncTask(taskId);
}
public String createAsyncTask() {
String prompt = "一副典雅庄重的对联悬挂于厅堂之中,房间是个安静古典的中式布置,桌子上放着一些青花瓷,对联上左书“义本生知人机同道善思新”,右书“通云赋智乾坤启数高志远”, 横批“智启千问”,字体飘逸,在中间挂着一幅中国风的画作,内容是岳阳楼。";
Map<String, Object> parameters = new HashMap<>();
parameters.put("prompt_extend", true);
parameters.put("watermark", false);
parameters.put("negative_prompt", " ");
ImageSynthesisParam param =
ImageSynthesisParam.builder()
.apiKey(apiKey)
// 当前仅qwen-image-plus、qwen-image模型支持异步接口
.model("qwen-image-plus")
.prompt(prompt)
.n(1)
.size("1664*928")
.parameters(parameters)
.build();
try {
ImageSynthesisResult result = new ImageSynthesis().asyncCall(param);
System.out.println(JsonUtils.toJson(result));
String taskId = result.getOutput().getTaskId();
System.out.println("task_id=" + taskId);
return taskId;
} catch (Exception e) {
throw new RuntimeException(e.getMessage());
}
}
public void waitAsyncTask(String taskId) {
ImageSynthesis imageSynthesis = new ImageSynthesis();
long startTime = System.currentTimeMillis();
int timeout = 60 * 1000; // 1分钟超时
int interval = 5 * 1000; // 5秒轮询间隔
while (true) {
if (System.currentTimeMillis() - startTime > timeout) {
System.out.println("轮询超时(1分钟),任务未完成");
return;
}
try {
ImageSynthesisResult result = imageSynthesis.fetch(taskId, apiKey);
System.out.println("任务状态查询结果: " + JsonUtils.toJson(result));
if (result.getOutput() == null) {
System.out.println("获取任务状态失败,输出结果为空");
return;
}
String taskStatus = result.getOutput().getTaskStatus();
System.out.println("当前任务状态: " + taskStatus);
switch (taskStatus) {
case "SUCCEEDED":
System.out.println("任务已完成");
System.out.println(JsonUtils.toJson(result));
return;
case "FAILED":
System.out.println("任务执行失败, status: " + taskStatus);
return;
case "PENDING":
case "RUNNING":
System.out.println("任务正在进行中,5秒后继续查询...");
Thread.sleep(interval);
break;
default:
System.out.println("未知任务状态: " + taskStatus + ",5秒后继续查询...");
Thread.sleep(interval);
break;
}
} catch (ApiException | NoApiKeyException e) {
System.err.println("API调用异常: " + e.getMessage());
return;
} catch (InterruptedException e) {
System.err.println("线程中断异常: " + e.getMessage());
Thread.currentThread().interrupt();
return;
}
}
}
public static void main(String[] args){
Text2Image text2Image = new Text2Image();
text2Image.asyncCall();
}
}
```
##### 响应示例
1、创建任务的响应示例
```
{
"request_id": "5dbf9dc5-4f4c-9605-85ea-542f97709ba8",
"output": {
"task_id": "7277e20e-aa01-4709-xxxxxxxx",
"task_status": "PENDING"
}
}
```
2、查询任务结果的响应示例
> url 有效期24小时,请及时下载图像。
```
{
"request_id": "f2153409-3950-9b73-9980-xxxxxx",
"output": {
"task_id": "2fc2e1de-0245-442d-b664-xxxxxx",
"task_status": "SUCCEEDED",
"results": [
{
"orig_prompt": "一副典雅庄重的对联悬挂于厅堂之中,房间是个安静古典的中式布置,桌子上放着一些青花瓷,对联上左书“义本生知人机同道善思新”,右书“通云赋智乾坤启数高志远”, 横批“智启千问”,字体飘逸,在中间挂着一幅中国风的画作,内容是岳阳楼。",
"actual_prompt": "一副典雅庄重的对联悬挂于中式厅堂中央,对联左侧书写“义本生知人机同道善思新”,右侧书写“通云赋智乾坤启数高志远”,横批为“智启千问”,整体采用飘逸洒脱的书法字体,墨色浓淡相宜,展现出浓厚的传统韵味。对联中间悬挂一幅中国风画作,描绘的是著名的岳阳楼景观:楼阁飞檐翘角,依水而建,远处湖光潋滟,烟波浩渺,天空中有几缕轻云缭绕,营造出诗意盎然的意境。背景房间为安静古典的中式布置,木质家具线条流畅,桌上摆放着数件青花瓷器,纹饰精美,釉色莹润。整体空间光线柔和,营造出庄重、宁静的文化氛围。画面风格为传统中国水墨风,笔触细腻,层次分明,充满古典美感。",
"url": "https://dashscope-result-sz.oss-cn-shenzhen.aliyuncs.com/xxx.png?Expires=xxxx"
}
]
},
"usage": {
"image_count": 1
}
}
```
## 结合 OpenAI Agents SDK 使用
千问-文生图(Qwen-Image)等图像生成模型通过 DashScope 原生接口调用,不支持 OpenAI 兼容(compatible-mode)模式,因此无法直接作为 OpenAI Agents SDK 中 Agent 的推理模型。
在 OpenAI Agents SDK 等智能体框架中,可将图像生成能力封装为工具(function tool),由支持 OpenAI 兼容模式的文本对话模型(如 qwen-plus)作为 Agent 的推理核心进行调度。示例如下:
运行前请先安装依赖:`pip install openai-agents dashscope`。
```
import os
import asyncio
import dashscope
from openai import AsyncOpenAI
from dashscope import MultiModalConversation
from agents import Agent, Runner, function_tool, OpenAIChatCompletionsModel
# 从环境变量读取百炼 API Key(若未配置,可直接替换为 api_key="sk-xxx")
API_KEY = os.getenv("DASHSCOPE_API_KEY")
# 以下为华北2(北京)地域的URL,调用时请将 {WorkspaceId} 替换为真实的业务空间ID,各地域的URL不同。
dashscope.base_http_api_url = "https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1"
# 文本对话模型(Agent 推理核心)通过 OpenAI 兼容模式接入
client = AsyncOpenAI(api_key=API_KEY, base_url="https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/compatible-mode/v1")
# 将图像生成封装为工具(function tool),供 Agent 调用
@function_tool
def generate_image(prompt: str) -> str:
"""根据文本描述生成一张图片并返回图片 URL。prompt:图片内容的文字描述。"""
rsp = MultiModalConversation.call(
api_key=API_KEY,
model="qwen-image",
messages=[{"role": "user", "content": [{"text": prompt}]}],
result_format="message",
)
if rsp.status_code != 200:
return f"生成失败:{rsp.code} {rsp.message}"
return rsp.output.choices[0].message.content[0]["image"]
agent = Agent(
name="图像生成助手",
instructions="你是图像生成助手。当用户想要图片时,调用 generate_image 工具,并把返回的图片 URL 告诉用户。",
model=OpenAIChatCompletionsModel(model="qwen-plus", openai_client=client),
tools=[generate_image],
)
async def main():
result = await Runner.run(agent, "帮我画一只在草地上奔跑的柯基犬")
print(result.final_output)
if __name__ == "__main__":
asyncio.run(main())
```
## **计费与限流**
- 模型免费额度和计费单价请参见[模型价格](https://help.aliyun.com/zh/model-studio/model-pricing#11a4ac6ea62wt)。
- 模型限流请参见[千问(Qwen-Image)](https://help.aliyun.com/zh/model-studio/rate-limit#f812e7c63axvx)。
- 计费说明:按成功生成的 **图像张数** 计费。模型调用失败或处理错误不产生任何费用,也不消耗[新人免费额度](https://help.aliyun.com/zh/model-studio/new-free-quota)。
## **错误码**
如果模型调用失败并返回报错信息,请参见[错误码](https://help.aliyun.com/zh/model-studio/error-code)进行解决。
## **常见问题**
#### **Q:prompt\_extend参数应该开启还是关闭?**
A:如果希望图像内容更多样化,由模型补充细节,建议开启此选项(默认)。如果图像细节更可控,建议关闭此选项,并参考[文生图Prompt指南](https://help.aliyun.com/zh/model-studio/text-to-image-prompt)进行优化,
#### **Q:qwen-image、qwen-image-plus、qwen-image-max、qwen-image-2.0、qwen-image-edit 等模型的区别是什么?**
A:
- **图像生成与编辑融合模型:**同时支持文生图和图像编辑。
- `qwen-image-2.0-pro`、`qwen-image-2.0-pro-2026-03-03`:当前两者能力相同,Pro系列具备更专业的文字渲染能力、更细腻的真实质感,细腻刻画写实场景,以及更强的语义遵循能力。仅支持同步接口。
- `qwen-image-2.0`、`qwen-image-2.0-2026-03-03`:当前两者能力相同,加速版有效实现了模型效果和性能的最佳平衡。仅支持同步接口。
- **文生图模型:**根据文本描述生成图像。
- `qwen-image-max`、`qwen-image-max-2025-12-30`:当前两者能力相同,相较于`qwen-image-plus`提升了生成图像的真实感与自然度,在人物质感、纹理细节和文字渲染等方面效果更佳。
- `qwen-image`、`qwen-image-plus`:当前两者能力相同,但`qwen-image-plus`的价格更优惠。
- `qwen-image-plus-2026-01-09`:千问图像生成的全新快照版模型,为`qwen-image-max`的蒸馏加速版,支持快速生成高质量图像。
- **图像编辑模型**:
`qwen-image-edit`:根据输入的图像和文本指令,执行图生图、局部修改等操作,详情请参见[千问-图像编辑](https://help.aliyun.com/zh/model-studio/qwen-image-edit-api)。
### **Q:如何获取图像存储的访问域名白名单?**
A: 模型生成的图像存储于阿里云OSS,API将返回一个临时的公网URL。**若需要对该下载地址进行防火墙白名单配置**,请注意:由于底层存储会根据业务情况进行动态变更,为避免过期信息影响访问,文档不提供固定的OSS域名白名单。如有安全管控需求,请联系客户经理获取最新OSS域名列表。
/\* 调整 table 宽度 \*/ .aliyun-docs-content table.medium-width { max-width: 1018px; width: 100%; } .aliyun-docs-content table.table-no-border tr td:first-child { padding-left: 0; } .aliyun-docs-content table.table-no-border tr td:last-child { padding-right: 0; } /\* 支持吸顶 \*/ div:has(.aliyun-docs-content), .aliyun-docs-content .markdown-body { overflow: visible; } .stick-top { position: sticky; top: 46px; } /\*\*代码块字体\*\*/ /\* 减少表格中的代码块 margin,让表格信息显示更紧凑 \*/ .unionContainer .markdown-body table .help-code-block { margin: 0 !important; } /\* 减少表格中的代码块字号,让表格信息显示更紧凑 \*/ .unionContainer .markdown-body .help-code-block pre { font-size: 12px !important; } /\* 减少表格中的代码块字号,让表格信息显示更紧凑 \*/ .unionContainer .markdown-body .help-code-block pre code { font-size: 12px !important; } /\*\* API Reference 表格 \*\*/ .aliyun-docs-content table.api-reference tr td:first-child { margin: 0px; border-bottom: 1px solid #d8d8d8; } .aliyun-docs-content table.api-reference tr:last-child td:first-child { border-bottom: none; } .aliyun-docs-content table.api-reference p { color: #6e6e80; } .aliyun-docs-content table.api-reference b, i { color: #181818; } .aliyun-docs-content table.api-reference .collapse { border: none; margin-top: 4px; margin-bottom: 4px; } .aliyun-docs-content table.api-reference .collapse .expandable-title-bold { padding: 0; } .aliyun-docs-content table.api-reference .collapse .expandable-title { padding: 0; } .aliyun-docs-content table.api-reference .collapse .expandable-title-bold .title { margin-left: 16px; } .aliyun-docs-content table.api-reference .collapse .expandable-title .title { margin-left: 16px; } .aliyun-docs-content table.api-reference .collapse .expandable-title-bold i.icon { position: absolute; color: #777; font-weight: 100; } .aliyun-docs-content table.api-reference .collapse .expandable-title i.icon { position: absolute; color: #777; font-weight: 100; } .aliyun-docs-content table.api-reference .collapse.expanded .expandable-content { padding: 10px 14px 10px 14px !important; margin: 0; border: 1px solid #e9e9e9; } .aliyun-docs-content table.api-reference .collapse .expandable-title-bold b { font-size: 13px; font-weight: normal; color: #6e6e80; } .aliyun-docs-content table.api-reference .collapse .expandable-title b { font-size: 13px; font-weight: normal; color: #6e6e80; } .aliyun-docs-content table.api-reference .tabbed-content-box { border: none; } .aliyun-docs-content table.api-reference .tabbed-content-box section { padding: 8px 0 !important; } .aliyun-docs-content table.api-reference .tabbed-content-box.mini .tab-box { /\* position: absolute; left: 40px; right: 0; \*/ } .aliyun-docs-content .margin-top-33 { margin-top: 33px !important; } .aliyun-docs-content .two-codeblocks pre { max-height: calc(50vh - 136px) !important; height: auto; } .expandable-content section { border-bottom: 1px solid #e9e9e9; padding-top: 6px; padding-bottom: 4px; } .expandable-content section:last-child { border-bottom: none; } .expandable-content section:first-child { padding-top: 0; }
/\* 让引用上下间距调小,避免内容显示过于稀疏 \*/ .unionContainer .markdown-body blockquote { margin: 4px 0; } .aliyun-docs-content table.qwen blockquote { border-left: none; /\* 添加这一行来移除表格里的引用文字的左侧边框 \*/ padding-left: 5px; /\* 左侧内边距 \*/ margin: 4px 0; } /\* 让表格显示成类似钉钉文档的分栏卡片 \*/ table.help-table-card td { border: 10px solid #FFF !important; background: #F4F6F9; padding: 16px !important; vertical-align: top; } /\* 减少表格中的代码块 margin,让表格信息显示更紧凑 \*/ .unionContainer .markdown-body table .help-code-block { margin: 0 !important; } /\* 减少表格中的代码块字号,让表格信息显示更紧凑 \*/ .unionContainer .markdown-body .help-code-block pre { font-size: 12px !important; } /\* 减少表格中的代码块字号,让表格信息显示更紧凑 \*/ .unionContainer .markdown-body .help-code-block pre code { font-size: 12px !important; } /\* 表格中的引用上下间距调小,避免内容显示过于稀疏 \*/ .unionContainer .markdown-body table blockquote { margin: 4px 0 0 0; } /\*表格图片设置为块元素(独占一行),居中展示,鼠标放在图片上可以点击查看原图\*/ .unionContainer .markdown-body .image.break { margin: 0px; display: inline-block; vertical-align: middle }
+1 -1
View File
@@ -3,6 +3,7 @@ module github.com/qianfree/team-api
go 1.25.7
require (
github.com/Masterminds/semver/v3 v3.5.0
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
github.com/aws/aws-sdk-go-v2 v1.41.6
github.com/aws/aws-sdk-go-v2/config v1.32.16
@@ -33,7 +34,6 @@ require (
require (
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/Masterminds/semver/v3 v3.5.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect
+11 -2
View File
@@ -310,19 +310,28 @@ var (
task.InitActiveCount(ctx)
task.StartAsyncPolling(ctx)
// Start sync-image async worker pool (wraps synchronous image providers as async tasks)
task.StartSyncImageWorkers(ctx)
// Start webhook dispatcher (event-driven delivery)
tenant.InitWebhookDispatcher(ctx)
// 注入 webhook 发布函数到 billing 包(解耦循环依赖)
billing.SetPublishWebhookEventFn(tenant.PublishWebhookEvent)
// Flush usage log writer on shutdown (s.Run blocks until server stops)
// Graceful shutdown order (defers run LIFO after s.Run returns):
// 1) 先排空任务池(StopSyncImageWorkers / StopAsyncPolling)——它们收尾时会写
// 用量日志 / 审计 / 结算;此时异步 Writer 必须仍存活。
// 2) 再 flush 关闭异步 Writer。
// 3) 最后关 webhook / plugin。
// 因此把两个 task.Stop* 注册在最后(最先执行),Writer 关闭注册在前(后执行)。
defer plugin.Shutdown(ctx)
defer tenant.ShutdownWebhookDispatcher()
defer task.StopAsyncPolling()
defer common.CloseChannelErrorWriter()
defer common.CloseUsageLogWriter()
defer response.CloseErrorLogWriter()
defer task.StopAsyncPolling()
defer task.StopSyncImageWorkers()
s.Run()
return nil
@@ -0,0 +1,28 @@
package admin
import (
"context"
"github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/service"
)
func (c *ControllerV1) FileList(ctx context.Context, req *v1.FileListReq) (res *v1.FileListRes, err error) {
return service.Admin().FileList(ctx, req)
}
func (c *ControllerV1) FileStats(ctx context.Context, req *v1.FileStatsReq) (res *v1.FileStatsRes, err error) {
return service.Admin().FileStats(ctx, req)
}
func (c *ControllerV1) FileDownload(ctx context.Context, req *v1.FileDownloadReq) (res *v1.FileDownloadRes, err error) {
return service.Admin().FileDownload(ctx, req)
}
func (c *ControllerV1) FileDelete(ctx context.Context, req *v1.FileDeleteReq) (res *v1.FileDeleteRes, err error) {
return service.Admin().FileDelete(ctx, req)
}
func (c *ControllerV1) FileCleanup(ctx context.Context, req *v1.FileCleanupReq) (res *v1.FileCleanupRes, err error) {
return service.Admin().FileCleanup(ctx, req)
}
+232
View File
@@ -0,0 +1,232 @@
package relay
import (
crand "crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/qianfree/team-api/internal/logic/task"
relay_common "github.com/qianfree/team-api/relay/common"
relay_constant "github.com/qianfree/team-api/relay/constant"
relay_handler "github.com/qianfree/team-api/relay/handler"
)
// HandleSyncImageSubmit 同步图片厂商的「异步化」提交管线。
//
// 由 HandleAliImageSubmit 在识别到同步厂商(ProviderTypeToTaskPlatform 未命中)时调用:
// 执行 gate → 强制非流式 → 估价 → 预扣 → 建 QUEUED 任务行 → 入 worker 队列,
// 客户端毫秒级拿到 task_id 后即释放连接,真正的上游长连接由 task 包的 worker 池后台持有。
//
// 与 relay_handler.HandleTaskSubmit 的区别:不在提交阶段调用上游(同步厂商无上游任务 ID),
// 渠道也不在提交时绑定(channel_id=0,worker dispatch 时按最新健康度/容量重选)。
func HandleSyncImageSubmit(r *ghttp.Request, body []byte, rc *relay_handler.TaskRelayContext, channelMeta *relay_common.ChannelMeta) {
ctx := r.Context()
w := rc.Writer
modelName := extractModelName(body)
if modelName == "" {
writeSyncImageError(w, http.StatusBadRequest, "model is required")
return
}
// 1. gate(复刻 HandleTaskSubmit 的 scope / IP 白名单 / 限流 / key 并发)
if rc.Scope == "read_only" {
writeSyncImageError(w, http.StatusForbidden, "API key scope denied")
return
}
if !checkSyncImageIPWhitelist(rc.KeyIpWhitelist, rc.ClientIP) {
writeSyncImageError(w, http.StatusForbidden, "IP address is not allowed")
return
}
if allowed, level, _, _, _ := taskBillingProvider.CheckRateLimit(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, rc.KeyRateLimitQps); !allowed {
writeSyncImageError(w, http.StatusTooManyRequests, fmt.Sprintf("rate limit exceeded at %s level", level))
return
}
if !taskBillingProvider.AcquireApiKeyConcurrent(ctx, rc.ApiKeyID, rc.KeyConcurrency) {
writeSyncImageError(w, http.StatusTooManyRequests, "API key concurrent request limit exceeded")
return
}
// 提交很快即返回,并发槽在提交结束即释放(上游长连接由 worker 持有,不占此槽)。
defer taskBillingProvider.ReleaseApiKeyConcurrent(ctx, rc.ApiKeyID)
// 2. 强制非流式:剥离 stream 字段,防 DoResponse 走 SSE 分支
cleanBody := stripStreamField(body)
// 3. 估价(图片走 per_request 价)
estimatedCost, err := taskBillingProvider.EstimateTaskCost(ctx, rc.TenantID, modelName, nil)
if err != nil {
writeSyncImageError(w, http.StatusInternalServerError, "estimate cost failed: "+err.Error())
return
}
// 4. 配额 + 预扣
if err := taskBillingProvider.CheckApiKeyQuota(ctx, rc.ApiKeyID, estimatedCost); err != nil {
writeSyncImageError(w, http.StatusPaymentRequired, "API key quota exceeded")
return
}
preDeduct, err := taskBillingProvider.PreDeductTask(ctx, rc.TenantID, rc.RequestID, estimatedCost, modelName)
if err != nil {
writeSyncImageError(w, http.StatusPaymentRequired, "insufficient balance")
return
}
// 5. 建 QUEUED 任务行(channel_id=0,dispatch 时绑定)
publicTaskID := generateSyncImagePublicID()
now := time.Now()
privateData, _ := json.Marshal(map[string]any{
"task_type": string(relay_constant.TaskPlatformSyncImage),
"billing_context": map[string]any{
"ratios": nil,
"model_name": modelName,
"pre_deduct": preDeduct,
},
})
asyncTask := &relay_common.AsyncTask{
PublicTaskID: publicTaskID,
RequestID: rc.RequestID,
Platform: string(relay_constant.TaskPlatformSyncImage),
Action: "generate",
Status: "QUEUED",
Progress: "0%",
TenantID: rc.TenantID,
UserID: rc.UserID,
ApiKeyID: rc.ApiKeyID,
ChannelID: 0,
ModelName: modelName,
PreDeductAmount: preDeduct,
PrivateData: privateData,
SubmitTime: &now,
}
if err := taskDataProvider.CreateTask(ctx, asyncTask); err != nil {
_ = taskBillingProvider.SettleTaskFailed(ctx, rc.TenantID, rc.RequestID, preDeduct)
writeSyncImageError(w, http.StatusInternalServerError, "create task record failed: "+err.Error())
return
}
// CreateTask 不回填自增 ID,回查取任务行主键供 worker CAS 使用。
created, err := taskDataProvider.GetTaskByPublicID(ctx, publicTaskID)
if err != nil || created == nil {
_ = taskBillingProvider.SettleTaskFailed(ctx, rc.TenantID, rc.RequestID, preDeduct)
g.Log().Errorf(ctx, "sync_image: load created task %s failed: %v", publicTaskID, err)
writeSyncImageError(w, http.StatusInternalServerError, "create task record failed")
return
}
rc.TaskID = publicTaskID
// 6. 入队(非阻塞);队列满 → 退款 + CAS→FAILURE + DecrActive + 429
job := &task.SyncImageJob{
TaskID: created.ID,
PublicTaskID: publicTaskID,
RequestID: rc.RequestID,
TenantID: rc.TenantID,
UserID: rc.UserID,
ApiKeyID: rc.ApiKeyID,
ProjectID: rc.ProjectID,
Model: modelName,
RequestBody: cleanBody,
PreDeductAmount: preDeduct,
Ratios: nil,
SubmitTime: now,
}
if !task.EnqueueSyncImageJob(job) {
_ = taskBillingProvider.SettleTaskFailed(ctx, rc.TenantID, rc.RequestID, preDeduct)
_ = taskDataProvider.UpdateTaskCAS(ctx, &relay_common.AsyncTask{
ID: created.ID,
Status: "FAILURE",
FailReason: "sync image worker queue full",
BillingSettled: true,
FinishTime: &now,
}, "QUEUED")
task.DecrActiveTask()
rc.TaskID = ""
writeSyncImageError(w, http.StatusTooManyRequests, "server busy, please retry later")
return
}
// 7. 返回 QUEUED(客户端连接在此释放)
writeSyncImageJSON(w, http.StatusOK, map[string]any{
"id": publicTaskID,
"status": "QUEUED",
"model": modelName,
"created_at": now.Unix(),
})
}
// stripStreamField 移除请求体中的 stream 字段(强制非流式)。
func stripStreamField(body []byte) []byte {
var m map[string]json.RawMessage
if err := json.Unmarshal(body, &m); err != nil {
return body
}
if _, ok := m["stream"]; !ok {
return body
}
delete(m, "stream")
cleaned, err := json.Marshal(m)
if err != nil {
return body
}
return cleaned
}
// checkSyncImageIPWhitelist 复刻 HandleTaskSubmit 的 IP 白名单校验(支持精确 IP 与 CIDR)。
func checkSyncImageIPWhitelist(whitelist, clientIP string) bool {
if whitelist == "" {
return true
}
host, _, err := net.SplitHostPort(clientIP)
if err != nil {
host = clientIP
}
host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
parsedIP := net.ParseIP(host)
if parsedIP == nil {
return false
}
for _, item := range strings.Split(whitelist, ",") {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if item == host {
return true
}
if strings.Contains(item, "/") {
if _, cidr, err := net.ParseCIDR(item); err == nil && cidr.Contains(parsedIP) {
return true
}
}
}
return false
}
func generateSyncImagePublicID() string {
b := make([]byte, 16)
if _, err := crand.Read(b); err != nil {
panic("crypto/rand failed: " + err.Error())
}
return "task_" + hex.EncodeToString(b)
}
func writeSyncImageError(w http.ResponseWriter, statusCode int, message string) {
writeSyncImageJSON(w, statusCode, map[string]any{
"error": map[string]any{
"type": "invalid_request_error",
"message": message,
},
})
}
func writeSyncImageJSON(w http.ResponseWriter, statusCode int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
_ = json.NewEncoder(w).Encode(data)
}
+84
View File
@@ -0,0 +1,84 @@
package relay
import (
"encoding/json"
"strings"
"testing"
)
func TestStripStreamField(t *testing.T) {
// stream=true 应被移除,其余字段保留
in := []byte(`{"model":"dall-e-3","prompt":"a cat","stream":true}`)
out := stripStreamField(in)
var m map[string]json.RawMessage
if err := json.Unmarshal(out, &m); err != nil {
t.Fatalf("output not valid json: %v", err)
}
if _, ok := m["stream"]; ok {
t.Fatal("stream field should be removed")
}
if _, ok := m["model"]; !ok {
t.Fatal("model field should be preserved")
}
if _, ok := m["prompt"]; !ok {
t.Fatal("prompt field should be preserved")
}
}
func TestStripStreamField_NoStream(t *testing.T) {
in := []byte(`{"model":"dall-e-3","prompt":"a cat"}`)
out := stripStreamField(in)
var m map[string]json.RawMessage
if err := json.Unmarshal(out, &m); err != nil {
t.Fatalf("output not valid json: %v", err)
}
if len(m) != 2 {
t.Fatalf("field count = %d, want 2", len(m))
}
}
func TestStripStreamField_InvalidJSON(t *testing.T) {
in := []byte(`not json`)
if got := stripStreamField(in); string(got) != string(in) {
t.Fatal("invalid json should be returned unchanged")
}
}
func TestCheckSyncImageIPWhitelist(t *testing.T) {
cases := []struct {
name string
whitelist string
clientIP string
want bool
}{
{"empty allows all", "", "1.2.3.4:5678", true},
{"exact match", "1.2.3.4", "1.2.3.4:5678", true},
{"exact no host:port", "1.2.3.4", "1.2.3.4", true},
{"non-match", "1.2.3.4", "5.6.7.8:1000", false},
{"cidr match", "10.0.0.0/8", "10.1.2.3:9", true},
{"cidr non-match", "10.0.0.0/8", "11.1.2.3:9", false},
{"multi list match", "9.9.9.9, 10.0.0.0/8", "10.5.5.5", true},
{"invalid ip", "1.2.3.4", "not-an-ip", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := checkSyncImageIPWhitelist(c.whitelist, c.clientIP); got != c.want {
t.Errorf("checkSyncImageIPWhitelist(%q, %q) = %v, want %v", c.whitelist, c.clientIP, got, c.want)
}
})
}
}
func TestGenerateSyncImagePublicID(t *testing.T) {
id := generateSyncImagePublicID()
if !strings.HasPrefix(id, "task_") {
t.Fatalf("id %q should have task_ prefix", id)
}
if len(id) != len("task_")+32 {
t.Fatalf("id %q length = %d, want %d", id, len(id), len("task_")+32)
}
// 唯一性(极小概率碰撞)
if generateSyncImagePublicID() == id {
t.Fatal("two ids should differ")
}
}
+30 -4
View File
@@ -10,6 +10,7 @@ import (
"github.com/gogf/gf/v2/net/ghttp"
"github.com/qianfree/team-api/internal/logic/billing"
lcommon "github.com/qianfree/team-api/internal/logic/common"
"github.com/qianfree/team-api/internal/logic/monitor"
"github.com/qianfree/team-api/internal/logic/relay"
"github.com/qianfree/team-api/internal/logic/task"
@@ -295,10 +296,35 @@ func HandleAliImageSubmit(r *ghttp.Request) {
modelName := extractModelName(body)
registerAsyncTask(rc.RequestID, rc.TenantID, rc.UserID, rc.ProjectID, modelName, channelMeta, r.URL.Path)
relay_handler.HandleTaskSubmit(
r.Context(), body, r.URL.Path, r.Header,
rc, taskDataProvider, taskBillingProvider, channelMeta,
)
// 异步图片端点分流。判「是否真有异步图片上游」用 constant.IsAsyncImageModel(唯一真相源,
// 与 sync gate / async_image 标记同源)——目前仅阿里 image-synthesis(wanx / qwen-image /
// wan2.2-t2i 等)为 true,走 taskchannel 提交拿 task_id。
//
// 其余图片模型上游都是同步阻塞返回,交 sync_image worker 池包装成可轮询任务(worker 直连各自
// 同步 channel.Adaptor):阿里 multimodal(qwen-image-2.x/z-image/wan2.6-t2i/wan2.7-image)恒走;
// OpenAI / Gemini(imagen) / 火山(seedream) 等同步厂商受「同步图片异步化」总开关约束。
//
// 不能用 ProviderTypeToTaskPlatform 笼统判「异步厂商」——Gemini/火山的 taskchannel 是视频
//(veo/seedance)专用,把它们的图片模型送进去会被当视频构造请求而失败。
providerType := relay_constant.ProviderType(channelMeta.ChannelType)
isAliSyncMultimodal := providerType == relay_constant.ProviderAli &&
relay_constant.IsAliSyncMultimodalImageModel(channelMeta.UpstreamModelName)
syncImageEnabled := lcommon.Config().GetBool(r.Context(), "sync_image_async_enabled")
switch {
case relay_constant.IsAsyncImageModel(providerType, channelMeta.UpstreamModelName):
// 真·异步图片上游(阿里 image-synthesis):taskchannel 提交 + 轮询
relay_handler.HandleTaskSubmit(
r.Context(), body, r.URL.Path, r.Header,
rc, taskDataProvider, taskBillingProvider, channelMeta,
)
case isAliSyncMultimodal || syncImageEnabled:
// 同步阻塞图片:worker 池异步化(阿里 multimodal 恒走;其余同步厂商受开关约束)
HandleSyncImageSubmit(r, body, rc, channelMeta)
default:
// 同步厂商 + 「同步图片异步化」关闭:异步端点不支持,提示改用同步端点
writeSyncImageError(rc.Writer, 400, "async image generation is disabled for this model; call POST /v1/images/generations instead")
}
if rc.TaskID != "" {
monitor.SwitchToTaskID(rc.RequestID, rc.TaskID)
+9 -9
View File
@@ -88,11 +88,11 @@ func (s *sAdmin) UpdateAuditConfig(ctx context.Context, req *v1.AuditConfigUpdat
}
// queryPage 使用 GoFrame ORM 分页查询,返回 []map[string]any。
// 注意:该函数使用审计数据库连接(common.GetAuditDB())查询审计表。
// 使用主库查询审计表(aud_request_logs 之外的审计表数据量小,无需独立库)。
// 分开调用 Count 和 All 避免 ScanAndCount 对 map[string]any 的 bug。
func queryPage(ctx context.Context, table, fields, where, orderBy string, page, pageSize int, args ...any) ([]map[string]any, int, error) {
auditDB := common.GetAuditDB()
m := auditDB.Ctx(ctx).Model(table).Safe()
mainDB := g.DB()
m := mainDB.Ctx(ctx).Model(table).Safe()
if where != "" {
m = m.Where(where, args...)
}
@@ -106,7 +106,7 @@ func queryPage(ctx context.Context, table, fields, where, orderBy string, page,
}
// 重新构建查询(Count 会消耗 Model 状态)
q := auditDB.Ctx(ctx).Model(table).Safe()
q := mainDB.Ctx(ctx).Model(table).Safe()
if fields != "" {
q = q.Fields(fields)
}
@@ -456,7 +456,7 @@ func (s *sAdmin) ExportOperationLogs(ctx context.Context, req *v1.OperationLogEx
offset := 0
for {
where, args := buildOpLogWhere()
q := common.GetAuditDB().Ctx(ctx).Model("aud_operation_logs").Fields(selectFields).OrderDesc("created_at").Offset(offset).Limit(1000)
q := g.DB().Ctx(ctx).Model("aud_operation_logs").Fields(selectFields).OrderDesc("created_at").Offset(offset).Limit(1000)
if where != "" {
q = q.Where(where, args...)
}
@@ -546,11 +546,11 @@ func (s *sAdmin) ContentFilterLogList(ctx context.Context, req *v1.ContentFilter
where := strings.Join(conditions, " AND ")
// Step 1: 从审计库查询审计记录(不带 JOIN)
auditDB := common.GetAuditDB()
// Step 1: 从主库查询内容过滤日志(数据量小,无需独立库)
mainDB := g.DB()
fields := "id, tenant_id, user_id, api_key_id, project_id, request_id, method, path, client_ip, filter_mode, matched_words, original_snippet, blocked, created_at"
countM := auditDB.Ctx(ctx).Model("aud_content_filter_logs").Safe()
countM := mainDB.Ctx(ctx).Model("aud_content_filter_logs").Safe()
if where != "" {
countM = countM.Where(where, args...)
}
@@ -564,7 +564,7 @@ func (s *sAdmin) ContentFilterLogList(ctx context.Context, req *v1.ContentFilter
}, nil
}
dataM := auditDB.Ctx(ctx).Model("aud_content_filter_logs").Safe().
dataM := mainDB.Ctx(ctx).Model("aud_content_filter_logs").Safe().
Fields(fields).
OrderDesc("created_at").
Page(page, pageSize)
+121 -23
View File
@@ -145,40 +145,132 @@ func CleanupExpiredData(ctx context.Context) error {
return nil
}
// CleanupExpiredExportFiles 清理过期导出文件
// FileCleanupResult 报告一次文件保留期清理删除的文件数量。
type FileCleanupResult struct {
ExportsDeleted int `json:"exports_deleted"`
ImagesDeleted int `json:"images_deleted"`
}
// deleteExpiredFiles 删除 created_at 早于 cutoff 的 fil_files 行及其存储对象。
// exports=true 匹配 storage_path LIKE 'exports/%'(导出文件);exports=false 匹配
// 其余(AI re-host 图片等 provider 上传的文件)。删除经 FileService.Delete 完成,
// 会一并删除桶中对象(而非仅删库行,避免存储泄漏)。fileSvc 为 nil 时(对象存储未
// 配置)退化为仅删库行。分批处理以限制内存与单次事务规模。
func deleteExpiredFiles(ctx context.Context, fileSvc *common.FileService, cutoff time.Time, exports bool) (int, error) {
const batchSize = 500
total := 0
for {
m := dao.FilFiles.Ctx(ctx).Where("created_at < ?", cutoff)
if exports {
m = m.Where("storage_path LIKE ?", "exports/%")
} else {
m = m.Where("storage_path NOT LIKE ?", "exports/%")
}
var batch []struct {
ID int64 `json:"id"`
}
if err := m.Fields("id").OrderAsc("id").Limit(batchSize).Scan(&batch); err != nil {
return total, err
}
if len(batch) == 0 {
break
}
deletedInBatch := 0
for _, f := range batch {
if fileSvc != nil {
// FileService.Delete 删对象(失败仅告警)后删行;返回硬错误表示行未删。
if err := fileSvc.Delete(ctx, f.ID); err != nil {
g.Log().Warningf(ctx, "file retention: delete file %d failed: %v", f.ID, err)
continue
}
} else if _, err := dao.FilFiles.Ctx(ctx).Where("id", f.ID).Delete(); err != nil {
g.Log().Warningf(ctx, "file retention: delete row %d failed: %v", f.ID, err)
continue
}
total++
deletedInBatch++
}
// 整批未能删除任何一行(删除持续失败)——停止,避免同一批被反复重查形成热循环。
if deletedInBatch == 0 {
g.Log().Warningf(ctx, "file retention: no progress in batch, stopping (check storage/db)")
break
}
if len(batch) < batchSize {
break
}
}
if total > 0 {
g.Log().Infof(ctx, "file retention: removed %d files (exports=%v, before %s)", total, exports, cutoff.Format("2006-01-02"))
}
return total, nil
}
// buildFileService 从数据库配置构造 FileService;对象存储未配置时返回 nil(清理退化为仅删库行)。
func buildFileService(ctx context.Context) *common.FileService {
fileSvc, err := common.NewFileServiceFromConfig(ctx)
if err != nil {
g.Log().Warningf(ctx, "file retention: object storage unavailable, pruning db rows only: %v", err)
return nil
}
return fileSvc
}
// CleanupExpiredExportFiles 清理过期导出文件(含存储对象)。由 export_file_cleanup cron 调用。
func CleanupExpiredExportFiles(ctx context.Context) error {
expiryDays := common.Config().GetInt(ctx, "data_export_expiry_days")
if expiryDays <= 0 {
return nil
}
cutoff := time.Now().AddDate(0, 0, -expiryDays)
var expiredFiles []struct {
ID int64 `json:"id"`
StoragePath string `json:"storage_path"`
}
err := dao.FilFiles.Ctx(ctx).
Where("created_at < ?", cutoff).
Where("storage_path LIKE ?", "exports/%").
Fields("id, storage_path").
Scan(&expiredFiles)
if err != nil {
return err
}
for _, f := range expiredFiles {
if _, err := dao.FilFiles.Ctx(ctx).Where("id", f.ID).Delete(); err != nil {
g.Log().Warningf(ctx, "cleanup file %d failed: %v", f.ID, err)
}
}
return nil
_, err := deleteExpiredFiles(ctx, buildFileService(ctx), cutoff, true)
return err
}
// CheckFileRetention 检查文件保留期
// CleanupExpiredImages 清理过期的 AI re-host 图片及其它 provider 上传文件(含存储对象)。
// file_image_retention_days<=0 表示关闭(默认),直接跳过。
func CleanupExpiredImages(ctx context.Context) (int, error) {
retentionDays := common.Config().GetInt(ctx, "file_image_retention_days")
if retentionDays <= 0 {
return 0, nil
}
cutoff := time.Now().AddDate(0, 0, -retentionDays)
return deleteExpiredFiles(ctx, buildFileService(ctx), cutoff, false)
}
// CheckFileRetention 由 file_retention_check cron 调用:受 file_retention_enabled 开关控制,
// 清理过期图片文件(导出文件由独立的 export_file_cleanup cron 负责)。
func CheckFileRetention(ctx context.Context) error {
if !common.Config().GetBool(ctx, "file_retention_enabled") {
return nil
}
return CleanupExpiredExportFiles(ctx)
_, err := CleanupExpiredImages(ctx)
return err
}
// RunFileRetentionNow 立即执行一次完整保留期清理(导出 + 图片),返回各自删除数量。
// 供管理后台「手动触发清理」端点调用;不受 file_retention_enabled 开关限制。
func RunFileRetentionNow(ctx context.Context) (*FileCleanupResult, error) {
res := &FileCleanupResult{}
fileSvc := buildFileService(ctx)
if days := common.Config().GetInt(ctx, "data_export_expiry_days"); days > 0 {
n, err := deleteExpiredFiles(ctx, fileSvc, time.Now().AddDate(0, 0, -days), true)
if err != nil {
return res, err
}
res.ExportsDeleted = n
}
if days := common.Config().GetInt(ctx, "file_image_retention_days"); days > 0 {
n, err := deleteExpiredFiles(ctx, fileSvc, time.Now().AddDate(0, 0, -days), false)
if err != nil {
return res, err
}
res.ImagesDeleted = n
}
return res, nil
}
func cleanupTableByDate(ctx context.Context, table, dateColumn string, days int) error {
@@ -209,8 +301,14 @@ func cleanupTableByDate(ctx context.Context, table, dateColumn string, days int)
return fmt.Errorf("invalid table for cleanup: %s", table)
}
// aud_request_logs 走独立库,其余审计表走主库
db := g.DB()
if table == "aud_request_logs" {
db = common.GetAuditDB()
}
for {
result, err := common.GetAuditDB().Ctx(ctx).Exec(ctx, deleteSQL, cutoff, batchSize)
result, err := db.Ctx(ctx).Exec(ctx, deleteSQL, cutoff, batchSize)
if err != nil {
return err
}
+212
View File
@@ -0,0 +1,212 @@
package admin
import (
"context"
"strings"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/logic/common"
)
// deriveFileCategory 由存储路径与 MIME 类型推导文件分类:export / image / other。
func deriveFileCategory(storagePath, mimeType string) string {
if strings.HasPrefix(storagePath, "exports/") {
return "export"
}
if strings.HasPrefix(mimeType, "image/") {
return "image"
}
return "other"
}
// adminFileService 从数据库配置构造 FileService;对象存储未配置时返回业务错误。
func adminFileService(ctx context.Context) (*common.FileService, error) {
svc, err := common.NewFileServiceFromConfig(ctx)
if err != nil {
return nil, common.NewBadRequestError("对象存储未配置,请先在系统设置中配置存储")
}
return svc, nil
}
// FileList 文件列表(分页 + 筛选)。
func (s *sAdmin) FileList(ctx context.Context, req *v1.FileListReq) (*v1.FileListRes, error) {
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
query := dao.FilFiles.Ctx(ctx)
if req.TenantId > 0 {
query = query.Where("tenant_id", req.TenantId)
}
if req.UserId > 0 {
query = query.Where("user_id", req.UserId)
}
if req.Provider != "" {
query = query.Where("storage_provider", req.Provider)
}
switch req.Category {
case "export":
query = query.Where("storage_path LIKE ?", "exports/%")
case "image":
query = query.Where("mime_type LIKE ?", "image/%").Where("storage_path NOT LIKE ?", "exports/%")
case "other":
query = query.Where("COALESCE(mime_type,'') NOT LIKE ?", "image/%").Where("storage_path NOT LIKE ?", "exports/%")
}
if req.Keyword != "" {
kw := "%" + strings.TrimSpace(req.Keyword) + "%"
query = query.Where("(original_name LIKE ? OR storage_path LIKE ?)", kw, kw)
}
if req.StartDate != "" {
query = query.Where("created_at >= ?", req.StartDate+" 00:00:00")
}
if req.EndDate != "" {
query = query.Where("created_at <= ?", req.EndDate+" 23:59:59")
}
var total int
rows := make([]*v1.FileItem, 0)
err := query.OrderDesc("created_at").
Page(page, pageSize).
ScanAndCount(&rows, &total, false)
if err != nil {
return nil, err
}
for _, r := range rows {
r.Category = deriveFileCategory(r.StoragePath, r.MimeType)
}
return &v1.FileListRes{List: rows, Total: total, Page: page, PageSize: pageSize}, nil
}
// FileStats 存储占用统计(KPI)。
func (s *sAdmin) FileStats(ctx context.Context, req *v1.FileStatsReq) (*v1.FileStatsRes, error) {
res := &v1.FileStatsRes{
ByProvider: make([]v1.FileProviderStat, 0),
ByCategory: make([]v1.FileCategoryStat, 0),
TopTenants: make([]v1.FileTenantStat, 0),
}
// 总量
var totals struct {
Count int64 `json:"count"`
Bytes int64 `json:"bytes"`
}
if err := dao.FilFiles.Ctx(ctx).
Fields("COUNT(*) AS count, COALESCE(SUM(size),0) AS bytes").
Scan(&totals); err != nil {
return nil, err
}
res.TotalCount = totals.Count
res.TotalBytes = totals.Bytes
// 按供应商
if err := dao.FilFiles.Ctx(ctx).
Fields("storage_provider AS provider, COUNT(*) AS count, COALESCE(SUM(size),0) AS bytes").
Group("storage_provider").
Scan(&res.ByProvider); err != nil {
return nil, err
}
// 按分类(export / image / other)
for _, cat := range []string{"export", "image", "other"} {
count, bytes, err := s.fileCategoryStat(ctx, cat)
if err != nil {
return nil, err
}
res.ByCategory = append(res.ByCategory, v1.FileCategoryStat{Category: cat, Count: count, Bytes: bytes})
}
// Top 租户占用
topN := req.TopN
if topN <= 0 {
topN = 10
}
if err := dao.FilFiles.Ctx(ctx).
Fields("tenant_id, COUNT(*) AS count, COALESCE(SUM(size),0) AS bytes").
Group("tenant_id").
Order("bytes DESC").
Limit(topN).
Scan(&res.TopTenants); err != nil {
return nil, err
}
return res, nil
}
// fileCategoryStat 统计单个分类的文件数与字节数。
func (s *sAdmin) fileCategoryStat(ctx context.Context, category string) (int64, int64, error) {
m := dao.FilFiles.Ctx(ctx)
switch category {
case "export":
m = m.Where("storage_path LIKE ?", "exports/%")
case "image":
m = m.Where("mime_type LIKE ?", "image/%").Where("storage_path NOT LIKE ?", "exports/%")
default: // other
m = m.Where("COALESCE(mime_type,'') NOT LIKE ?", "image/%").Where("storage_path NOT LIKE ?", "exports/%")
}
var r struct {
Count int64 `json:"count"`
Bytes int64 `json:"bytes"`
}
if err := m.Fields("COUNT(*) AS count, COALESCE(SUM(size),0) AS bytes").Scan(&r); err != nil {
return 0, 0, err
}
return r.Count, r.Bytes, nil
}
// FileDownload 生成临时预览下载链接。
func (s *sAdmin) FileDownload(ctx context.Context, req *v1.FileDownloadReq) (*v1.FileDownloadRes, error) {
count, err := dao.FilFiles.Ctx(ctx).Where("id", req.Id).Count()
if err != nil {
return nil, err
}
if count == 0 {
return nil, common.NewNotFoundError("文件")
}
svc, err := adminFileService(ctx)
if err != nil {
return nil, err
}
var url string
if req.Variant == "thumb" {
url, err = svc.GetThumbnailURL(ctx, req.Id, req.Width)
} else {
url, err = svc.GetDownloadURL(ctx, req.Id)
}
if err != nil {
return nil, err
}
return &v1.FileDownloadRes{Url: url}, nil
}
// FileDelete 删除单个文件(存储对象 + 记录)。
func (s *sAdmin) FileDelete(ctx context.Context, req *v1.FileDeleteReq) (*v1.FileDeleteRes, error) {
count, err := dao.FilFiles.Ctx(ctx).Where("id", req.Id).Count()
if err != nil {
return nil, err
}
if count == 0 {
return nil, common.NewNotFoundError("文件")
}
svc, err := adminFileService(ctx)
if err != nil {
return nil, err
}
if err := svc.Delete(ctx, req.Id); err != nil {
return nil, err
}
return &v1.FileDeleteRes{}, nil
}
// FileCleanup 手动触发一次保留期清理(导出 + 图片)。
func (s *sAdmin) FileCleanup(ctx context.Context, req *v1.FileCleanupReq) (*v1.FileCleanupRes, error) {
result, err := RunFileRetentionNow(ctx)
if err != nil {
return nil, err
}
return &v1.FileCleanupRes{
ExportsDeleted: result.ExportsDeleted,
ImagesDeleted: result.ImagesDeleted,
}, nil
}
+7
View File
@@ -142,6 +142,13 @@ var predefinedPermissionGroups = []v1.PermissionGroup{
"redemption:view", "redemption:create", "redemption:edit",
},
},
{
Name: "file",
Label: "文件管理",
Permissions: []string{
"file:view", "file:delete", "file:cleanup",
},
},
}
// GetUserPermissions returns permission points and data scopes for an admin user.
+8 -3
View File
@@ -38,11 +38,16 @@ func IsAuditDBConfigured() bool {
return auditDBConfigured
}
// AuditModelCtx 返回指定审计表的 Model(带 context,绑定到审计库连接)。
// AuditModelCtx 返回指定审计表的 Model(带 context)。
// 仅 aud_request_logs(大模型请求审计)使用独立审计库(配置了 database.audit 时);
// 其余审计表(aud_operation_logs、aud_login_history、aud_sensitive_access_logs、
// aud_content_filter_logs)始终使用主库,数据量小无需分离。
// 使用示例:
//
// common.AuditModelCtx(ctx, "aud_request_logs").Data(data).Insert()
// common.AuditModelCtx(ctx, "aud_operation_logs").Where("id", id).Scan(&record)
func AuditModelCtx(ctx context.Context, table string) *gdb.Model {
return GetAuditDB().Model(table).Safe().Ctx(ctx)
if table == "aud_request_logs" {
return GetAuditDB().Model(table).Safe().Ctx(ctx)
}
return g.DB().Model(table).Safe().Ctx(ctx)
}
+9 -3
View File
@@ -221,16 +221,20 @@ var Registry = []SettingDef{
Label: "健康快照保留天数", Validation: "min:1,max:90"},
{Key: "channel_proxy_url", Type: SettingTypeString, Default: "", Category: "channel",
Label: "代理地址", Description: "全局代理 URL,支持 http:// 和 socks5://,如 http://127.0.0.1:7890。启用代理的渠道会通过此代理转发请求"},
{Key: "sync_image_async_enabled", Type: SettingTypeBool, Default: "true", Category: "channel",
Label: "同步图片厂商异步化", Description: "开启后,同步阻塞返回的图片厂商(如 OpenAI/DALL·E)走 /v1/images/generations/async 时由后台 worker 池异步处理,客户端提交即拿 task_id 后轮询取图;关闭则该端点对同步厂商返回不支持"},
{Key: "sync_image_rehost_url", Type: SettingTypeBool, Default: "false", Category: "channel",
Label: "同步图片 URL 转存对象存储", Description: "开启后,上游返回图片 URL 时下载并转存对象存储(返回 24h 稳定链接,需已配置存储);关闭则直接透传上游 URL(部分厂商约 1h 过期)。b64_json 始终转存"},
// ── Storage ──
{Key: "storage_provider", Type: SettingTypeString, Default: "minio", Category: "storage",
Label: "存储供应商", Validation: "enum:s3,minio,oss,cos",
Label: "存储供应商", Validation: "enum:s3,minio,r2,oss,cos",
Description: "对象存储供应商类型"},
{Key: "storage_endpoint", Type: SettingTypeString, Default: "", Category: "storage",
Label: "存储端点",
Description: "S3/MinIO: https://s3.amazonaws.com, OSS: https://oss-cn-hangzhou.aliyuncs.com, COS: https://cos.ap-guangzhou.myqcloud.com"},
Description: "S3/MinIO: https://s3.amazonaws.com, OSS: https://oss-cn-hangzhou.aliyuncs.com, COS: https://cos.ap-guangzhou.myqcloud.com, R2: https://<account_id>.r2.cloudflarestorage.com"},
{Key: "storage_region", Type: SettingTypeString, Default: "", Category: "storage",
Label: "存储区域", Description: "AWS Region / OSS Region / COS Region"},
Label: "存储区域", Description: "AWS Region / OSS Region / COS Region(R2 固定填 auto)"},
{Key: "storage_bucket", Type: SettingTypeString, Default: "", Category: "storage",
Label: "存储桶名称"},
{Key: "storage_access_key_id", Type: SettingTypeString, Default: "", Category: "storage",
@@ -257,6 +261,8 @@ var Registry = []SettingDef{
Label: "GDPR删除请求完成天数", Validation: "min:7,max:90"},
{Key: "file_retention_enabled", Type: SettingTypeBool, Default: "true", Category: "data_governance",
Label: "启用文件保留期检查"},
{Key: "file_image_retention_days", Type: SettingTypeInt, Default: "0", Category: "data_governance",
Label: "AI图片保留天数(0=不清理)", Description: "AI re-host 图片超过该天数后自动清理,0 表示不自动删除", Validation: "min:0,max:3650"},
// ── Agreement (用户协议) ──
{Key: "agreement_enabled", Type: SettingTypeBool, Default: "false", Category: "agreement",
+58 -9
View File
@@ -6,10 +6,12 @@ import (
"github.com/qianfree/team-api/internal/dao"
do "github.com/qianfree/team-api/internal/model/do"
"io"
"strings"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/google/uuid"
)
// StorageProvider defines the interface for file storage backends.
@@ -22,6 +24,11 @@ type StorageProvider interface {
Delete(ctx context.Context, key string) error
// PresignedURL generates a temporary URL for downloading.
PresignedURL(ctx context.Context, key string, expires time.Duration) (string, error)
// PresignedThumbnailURL generates a temporary URL that returns a resized
// thumbnail (width in pixels, height auto). Providers with native
// server-side image processing (OSS/COS) apply it in the signed URL;
// providers without it (S3/MinIO/R2) return the original-object URL.
PresignedThumbnailURL(ctx context.Context, key string, width int, expires time.Duration) (string, error)
}
// FileService provides file storage operations with metadata tracking.
@@ -66,12 +73,20 @@ type FileRecord struct {
// Upload uploads a file, stores it via the provider, and records metadata.
func (s *FileService) Upload(ctx context.Context, upload *FileUpload) (*FileRecord, error) {
// Generate storage key
key := fmt.Sprintf("%d/%d/%s", upload.TenantID, time.Now().Unix(), upload.Filename)
// Generate storage key (relative to the provider's configured path prefix):
// {date}/{uuid}{ext} — date-based dirs for lifecycle policies, UUID for
// uniqueness and non-guessability, original extension retained.
ext := extFromFilename(upload.Filename)
uuidStr := uuid.New().String()
key := fmt.Sprintf("%s/%s%s", time.Now().Format("2006-01-02"), uuidStr, ext)
fileName := uuidStr + ext
// Upload to storage provider
storagePath, err := s.provider.Upload(ctx, upload.Reader, key, upload.ContentType)
if err != nil {
// Upload to storage provider. The provider applies the configured path
// prefix internally, so we persist the RAW key (not the returned full key):
// Download/Delete/PresignedURL pass this key back through the provider,
// which re-applies the prefix exactly once. Storing the prefixed key here
// would cause the prefix to be applied twice on retrieval.
if _, err := s.provider.Upload(ctx, upload.Reader, key, upload.ContentType); err != nil {
return nil, gerror.Wrapf(err, "upload to storage")
}
@@ -79,12 +94,12 @@ func (s *FileService) Upload(ctx context.Context, upload *FileUpload) (*FileReco
result, err := dao.FilFiles.Ctx(ctx).Data(do.FilFiles{
TenantId: upload.TenantID,
UserId: upload.UserID,
Filename: storagePath,
Filename: fileName,
OriginalName: upload.Filename,
MimeType: upload.ContentType,
Size: upload.Size,
StorageProvider: s.providerName,
StoragePath: storagePath,
StoragePath: key,
VirusScanStatus: "pending",
}).Insert()
if err != nil {
@@ -99,12 +114,12 @@ func (s *FileService) Upload(ctx context.Context, upload *FileUpload) (*FileReco
ID: id,
TenantID: upload.TenantID,
UserID: upload.UserID,
Filename: storagePath,
Filename: fileName,
OriginalName: upload.Filename,
MimeType: upload.ContentType,
Size: upload.Size,
StorageProvider: s.providerName,
StoragePath: storagePath,
StoragePath: key,
VirusScanStatus: "pending",
}, nil
}
@@ -125,6 +140,31 @@ func (s *FileService) GetDownloadURL(ctx context.Context, fileID int64) (string,
return s.provider.PresignedURL(ctx, record.StoragePath, 24*time.Hour)
}
// GetThumbnailURL generates a presigned URL for previewing a downscaled
// thumbnail. Only images get server-side resizing; non-image files (and
// providers without native image processing) fall back to the original object.
func (s *FileService) GetThumbnailURL(ctx context.Context, fileID int64, width int) (string, error) {
var record *FileRecord
err := dao.FilFiles.Ctx(ctx).
Where("id", fileID).
Scan(&record)
if err != nil {
return "", gerror.Wrapf(err, "query file %d", fileID)
}
if record == nil {
return "", gerror.Newf("file not found: %d", fileID)
}
if width <= 0 {
width = 400
}
// Non-image objects cannot be resized; return the original presigned URL.
if !strings.HasPrefix(record.MimeType, "image/") {
return s.provider.PresignedURL(ctx, record.StoragePath, 24*time.Hour)
}
return s.provider.PresignedThumbnailURL(ctx, record.StoragePath, width, 24*time.Hour)
}
// Delete deletes a file from storage and marks it as deleted.
func (s *FileService) Delete(ctx context.Context, fileID int64) error {
var record *FileRecord
@@ -153,3 +193,12 @@ func (s *FileService) Delete(ctx context.Context, fileID int64) error {
return nil
}
// extFromFilename extracts the file extension (including the leading dot)
// from a filename. Returns empty string if no extension is found.
func extFromFilename(name string) string {
if idx := strings.LastIndex(name, "."); idx > 0 {
return name[idx:]
}
return ""
}
+18
View File
@@ -109,3 +109,21 @@ func (s *COSStorageProvider) PresignedURL(ctx context.Context, key string, expir
return presignedURL.String(), nil
}
// PresignedThumbnailURL signs a URL with a COS CI imageMogr2/thumbnail op so COS
// returns a downscaled thumbnail (width px, height auto). The process param is
// injected into the signed query so the signature covers it.
func (s *COSStorageProvider) PresignedThumbnailURL(ctx context.Context, key string, width int, expires time.Duration) (string, error) {
fullKey := s.fullKey(key)
q := &url.Values{}
q.Add(fmt.Sprintf("imageMogr2/thumbnail/%dx", width), "")
opt := &cos.PresignedURLOptions{Query: q}
presignedURL, err := s.client.Object.GetPresignedURL(ctx, http.MethodGet, fullKey, s.secretID, s.secretKey, expires, opt)
if err != nil {
return "", fmt.Errorf("cos presign thumbnail url: %w", err)
}
return presignedURL.String(), nil
}
+1 -1
View File
@@ -38,7 +38,7 @@ func GetStorageConfig(ctx context.Context) *StorageConfig {
// NewStorageProvider creates a StorageProvider based on the given configuration.
func NewStorageProvider(cfg *StorageConfig) (StorageProvider, error) {
switch cfg.Provider {
case "s3", "minio":
case "s3", "minio", "r2":
return NewS3Provider(cfg)
case "oss":
return NewOSSProvider(cfg)
+14
View File
@@ -97,3 +97,17 @@ func (s *OSSStorageProvider) PresignedURL(ctx context.Context, key string, expir
return signedURL, nil
}
// PresignedThumbnailURL signs a URL with an x-oss-process image/resize op so OSS
// returns a downscaled thumbnail (width px, height auto, no upscaling).
func (s *OSSStorageProvider) PresignedThumbnailURL(ctx context.Context, key string, width int, expires time.Duration) (string, error) {
fullKey := s.fullKey(key)
process := fmt.Sprintf("image/resize,w_%d", width)
signedURL, err := s.bucket.SignURL(fullKey, oss.HTTPGet, int64(expires.Seconds()), oss.Process(process))
if err != nil {
return "", fmt.Errorf("oss sign thumbnail url: %w", err)
}
return signedURL, nil
}
+13 -2
View File
@@ -45,9 +45,14 @@ func NewS3Provider(cfg *StorageConfig) (*S3StorageProvider, error) {
if cfg.Region != "" {
o.Region = cfg.Region
}
if cfg.Provider == "minio" && cfg.Endpoint != "" {
// Cloudflare R2 is S3-compatible but requires an account-level
// BaseEndpoint. Unlike MinIO it uses virtual-hosted addressing, so
// path-style must NOT be forced.
if (cfg.Provider == "minio" || cfg.Provider == "r2") && cfg.Endpoint != "" {
o.BaseEndpoint = aws.String(cfg.Endpoint)
o.UsePathStyle = true
if cfg.Provider == "minio" {
o.UsePathStyle = true
}
}
})
@@ -128,3 +133,9 @@ func (s *S3StorageProvider) PresignedURL(ctx context.Context, key string, expire
return req.URL, nil
}
// PresignedThumbnailURL falls back to the original object: S3/MinIO/R2 have no
// native server-side image processing, so a true thumbnail is not available.
func (s *S3StorageProvider) PresignedThumbnailURL(ctx context.Context, key string, width int, expires time.Duration) (string, error) {
return s.PresignedURL(ctx, key, expires)
}
+54
View File
@@ -0,0 +1,54 @@
package common
import "testing"
// TestProviderFullKeyAppliesPrefixOnce guards the invariant behind the
// storage_path fix: every provider prepends the configured path prefix exactly
// once to whatever key it is given. FileService therefore MUST persist the RAW
// key (relative to the prefix) — if it stored the already-prefixed key returned
// by Upload, Download/Delete/PresignedURL would prefix it a second time and
// target a non-existent object (e.g. "team-api/team-api/…").
func TestProviderFullKeyAppliesPrefixOnce(t *testing.T) {
const prefix = "team-api"
const rawKey = "42/1700000000/task_abc.png"
const want = prefix + "/" + rawKey
cases := []struct {
name string
fullKey func(string) string
}{
{"s3", (&S3StorageProvider{prefix: prefix}).fullKey},
{"oss", (&OSSStorageProvider{prefix: prefix}).fullKey},
{"cos", (&COSStorageProvider{prefix: prefix}).fullKey},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
// Raw key -> single prefix (the correct, fixed behavior).
if got := c.fullKey(rawKey); got != want {
t.Fatalf("fullKey(raw) = %q, want %q", got, want)
}
// An already-prefixed key would be double-prefixed — this is exactly
// the bug we avoid by storing the raw key in fil_files.storage_path.
if got := c.fullKey(want); got != prefix+"/"+want {
t.Fatalf("fullKey(prefixed) = %q, want double-prefixed %q (documents why raw key must be stored)", got, prefix+"/"+want)
}
})
}
}
// TestProviderFullKeyNoPrefix verifies that with an empty prefix the key is
// returned unchanged, so keys round-trip identically when no prefix is set.
func TestProviderFullKeyNoPrefix(t *testing.T) {
const rawKey = "exports/tenant_7/20240101_000000.json"
providers := map[string]func(string) string{
"s3": (&S3StorageProvider{}).fullKey,
"oss": (&OSSStorageProvider{}).fullKey,
"cos": (&COSStorageProvider{}).fullKey,
}
for name, fullKey := range providers {
if got := fullKey(rawKey); got != rawKey {
t.Fatalf("%s: fullKey(%q) with empty prefix = %q, want unchanged", name, rawKey, got)
}
}
}
+63
View File
@@ -5,6 +5,7 @@ import (
"github.com/gogf/gf/v2/frame/g"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/relay/constant"
)
// AvailableModelItem 租户可用模型基本信息(公共结构体,不含价格)
@@ -136,3 +137,65 @@ func GetTenantAvailableModels(ctx context.Context, tenantID int64, category, sea
return result, nil
}
// GetAsyncImageModelSet 判定给定图片模型(按 model_id 字符串)是否「走异步图片端点」。
//
// 真相源是服务该模型的渠道 provider 类型:桥接 mdl_models.model_id = chn_abilities.model_name
// → chn_channels.type,用 constant.IsAsyncImageModel 分类(与同步端点拦截 gate 同源)。
// - 当某模型的全部在役服务渠道都是异步图片 provider(如 DashScope wanx/qwen-image)时,同步
// /v1/images/generations 必失败,必须走 /v1/images/generations/async 提交+轮询 → true。
// - 此外若「同步图片厂商异步化」总开关(sync_image_async_enabled,默认开启)打开,同步阻塞
// 返回的图片厂商(OpenAI/DALL·E 等)也由 worker 池在异步端点处理,故所有有渠道的图片模型
// 一并走异步端点 → true。开关关闭时,同步厂商回落同步端点 → false。
//
// 一次批量查询(非 N+1);仅对图片分类模型调用。modelIDs 为空时返回空 map。
func GetAsyncImageModelSet(ctx context.Context, modelIDs []string) (map[string]bool, error) {
result := make(map[string]bool)
if len(modelIDs) == 0 {
return result, nil
}
var rows []struct {
ModelName string `json:"model_name"`
Type int `json:"type"`
}
err := dao.ChnAbilities.Ctx(ctx).As("a").
LeftJoin("chn_channels c ON a.channel_id = c.id").
WhereIn("a.model_name", modelIDs).
Where("a.enabled", true).
Where("c.status", "active").
Fields("DISTINCT a.model_name, c.type").
Scan(&rows)
if err != nil {
return nil, err
}
// 聚合每个模型的服务渠道 provider 类型:有渠道且全部为异步图片模型 → true。
// 判定按 provider + 模型名(constant.IsAsyncImageModel),与同步端点拦截 gate 同源,
// 因此 qwen-image-2.x 等同步 multimodal 模型即使渠道为 Ali 也判为非异步。
type aggState struct {
hasChannel bool
allAsync bool
}
agg := make(map[string]*aggState, len(modelIDs))
for _, r := range rows {
st := agg[r.ModelName]
if st == nil {
st = &aggState{allAsync: true}
agg[r.ModelName] = st
}
st.hasChannel = true
if !constant.IsAsyncImageModel(constant.ProviderType(r.Type), r.ModelName) {
st.allAsync = false
}
}
// 「同步图片厂商异步化」总开关(默认开启):开启时,同步阻塞返回的图片厂商(OpenAI/DALL·E
// 等)也能走 /v1/images/generations/async 由 worker 池异步处理,因此任何有在役渠道的图片
// 模型都应走异步端点(在线体验用提交+轮询)。关闭时,仅「全部在役渠道均为异步图片 provider」
// 的模型才异步(此时同步端点必失败);同步厂商回落同步端点。
syncImageEnabled := Config().GetBool(ctx, "sync_image_async_enabled")
for name, st := range agg {
result[name] = st.hasChannel && (st.allAsync || syncImageEnabled)
}
return result, nil
}
+47
View File
@@ -155,6 +155,53 @@ func buildOpenAPISpec() map[string]any {
},
},
},
"/v1/images/generations/async": map[string]any{
"post": map[string]any{
"summary": "Image Generations Async (Submit)",
"description": "异步图像生成任务提交。阿里云百炼(DashScope)等异步图像上游无法经同步接口一次性返回,需提交任务拿到 task_id 后轮询 /v1/images/generations/async/{task_id} 取图。",
"operationId": "createImageAsync",
"security": []map[string]any{{"BearerAuth": []any{}}},
"requestBody": map[string]any{
"required": true,
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{
"type": "object",
"required": []string{"model", "prompt"},
"properties": map[string]any{
"model": map[string]any{"type": "string", "example": "wanx2.1-t2i-turbo"},
"prompt": map[string]any{"type": "string"},
"n": map[string]any{"type": "integer", "default": 1},
"size": map[string]any{"type": "string", "example": "1024x1024"},
},
},
},
},
},
"responses": map[string]any{
"200": map[string]any{"description": "任务已提交,返回 task_id 与初始状态"},
},
},
},
"/v1/images/generations/async/{task_id}": map[string]any{
"get": map[string]any{
"summary": "Image Generations Async (Fetch)",
"description": "查询异步图像生成任务结果。轮询直到 status 为 SUCCESS(含图片 url)或 FAILURE(含 error),建议间隔 2~3 秒。",
"operationId": "getImageAsync",
"security": []map[string]any{{"BearerAuth": []any{}}},
"parameters": []map[string]any{
{
"name": "task_id",
"in": "path",
"required": true,
"schema": map[string]any{"type": "string"},
},
},
"responses": map[string]any{
"200": map[string]any{"description": "任务状态与结果"},
},
},
},
"/v1/messages": map[string]any{
"post": map[string]any{
"summary": "Claude Messages",
@@ -54,6 +54,7 @@ type RealtimeData struct {
ByModel map[string]int `json:"by_model"`
ByChannel map[string]int `json:"by_channel"`
ByTenant map[int64]int `json:"by_tenant"`
SyncImagePool SyncImagePoolSnapshot `json:"sync_image_pool"`
}
// snapshotRing is a fixed-size circular buffer for arbitrary snapshot types.
@@ -266,6 +267,7 @@ func GetRealtimeData() *RealtimeData {
ByModel: byModel,
ByChannel: byChannel,
ByTenant: byTenant,
SyncImagePool: GetSyncImagePoolSnapshot(),
}
}
+35
View File
@@ -0,0 +1,35 @@
package monitor
// SyncImagePoolSnapshot 是同步图片「异步化」worker 池的一次性状态快照。
// 由 internal/logic/task 在启动 worker 池时通过 RegisterSyncImagePoolProvider 注入取数函数,
// monitor 反向持有该函数即可,避免 monitor → task 的导入环(task 已 import monitor)。
type SyncImagePoolSnapshot struct {
WorkerTotal int `json:"worker_total"` // 池大小(固定)
WorkerBusy int `json:"worker_busy"` // 忙碌 worker 数(瞬时)
QueueDepth int `json:"queue_depth"` // 当前排队深度
QueueCap int `json:"queue_cap"` // 队列容量
Enqueued int64 `json:"enqueued"` // 累计入队成功
Rejected int64 `json:"rejected"` // 累计拒绝(队列满 → 429 退款)
Succeeded int64 `json:"succeeded"` // 累计 worker 处理成功
Failed int64 `json:"failed"` // 累计 worker 处理失败
ChannelInflight map[int64]int `json:"channel_inflight"` // 每渠道在途数(层② per-channel 容量)
Enabled bool `json:"enabled"` // 池是否已启动(未注册 provider 时 false)
}
// syncImagePoolProvider 由 task 包在启动 worker 池时注册。
var syncImagePoolProvider func() SyncImagePoolSnapshot
// RegisterSyncImagePoolProvider 注册同步图片 worker 池状态取数函数。
func RegisterSyncImagePoolProvider(f func() SyncImagePoolSnapshot) {
syncImagePoolProvider = f
}
// GetSyncImagePoolSnapshot 返回当前 worker 池状态;未注册(池未启动)时返回 Enabled=false。
func GetSyncImagePoolSnapshot() SyncImagePoolSnapshot {
if syncImagePoolProvider == nil {
return SyncImagePoolSnapshot{Enabled: false}
}
s := syncImagePoolProvider()
s.Enabled = true
return s
}
+43 -16
View File
@@ -104,12 +104,13 @@ func (p *DataProviderImpl) tryAffinityChannel(ctx context.Context, tenantID int6
}
type channelRow struct {
ChannelID int64 `json:"channel_id"`
ChannelName string `json:"channel_name"`
ChannelType int `json:"channel_type"`
BaseURL string `json:"base_url"`
UpstreamModel string `json:"upstream_model"`
Settings string `json:"settings"`
ChannelID int64 `json:"channel_id"`
ChannelName string `json:"channel_name"`
ChannelType int `json:"channel_type"`
BaseURL string `json:"base_url"`
UpstreamModel string `json:"upstream_model"`
MaxConcurrency int `json:"max_concurrency"`
Settings string `json:"settings"`
}
var ch *channelRow
@@ -119,7 +120,7 @@ func (p *DataProviderImpl) tryAffinityChannel(ctx context.Context, tenantID int6
Where("c.status", "active").
Where("a.model_name", modelName).
Where("a.enabled", true).
Fields("c.id as channel_id, c.name as channel_name, c.type as channel_type, c.base_url, a.upstream_model, c.settings").
Fields("c.id as channel_id, c.name as channel_name, c.type as channel_type, c.base_url, a.upstream_model, c.max_concurrency, c.settings").
Scan(&ch)
if err != nil || ch == nil {
return nil, common.ErrChannelUnavailable
@@ -144,6 +145,7 @@ func (p *DataProviderImpl) tryAffinityChannel(ctx context.Context, tenantID int6
ApiKey: key,
UpstreamModelName: upstreamModel,
IsModelMapped: ch.UpstreamModel != "" && ch.UpstreamModel != modelName,
MaxConcurrency: ch.MaxConcurrency,
Settings: settings,
}, nil
}
@@ -158,6 +160,7 @@ func (p *DataProviderImpl) selectChannelFromDB(ctx context.Context, tenantID int
UpstreamModel string `json:"upstream_model"`
Priority int `json:"priority"`
Weight int `json:"weight"`
MaxConcurrency int `json:"max_concurrency"`
Settings string `json:"settings"`
HealthScore *float64 `json:"health_score"`
ConsecutiveFailures int `json:"consecutive_failures"`
@@ -169,7 +172,7 @@ func (p *DataProviderImpl) selectChannelFromDB(ctx context.Context, tenantID int
Where("a.model_name", modelName).
Where("a.enabled", true).
Where("c.status", "active").
Fields("c.id as channel_id, c.name as channel_name, c.type as channel_type, c.base_url, a.upstream_model, c.priority, c.weight, c.settings, h.health_score, h.consecutive_failures").
Fields("c.id as channel_id, c.name as channel_name, c.type as channel_type, c.base_url, a.upstream_model, c.priority, c.weight, c.max_concurrency, c.settings, h.health_score, h.consecutive_failures").
OrderDesc("c.priority").
OrderDesc("c.weight")
@@ -211,6 +214,7 @@ func (p *DataProviderImpl) selectChannelFromDB(ctx context.Context, tenantID int
ApiKey: key,
UpstreamModelName: upstreamModel,
IsModelMapped: ch.UpstreamModel != "" && ch.UpstreamModel != modelName,
MaxConcurrency: ch.MaxConcurrency,
Settings: settings,
}, nil
}
@@ -620,14 +624,37 @@ func (p *DataProviderImpl) UpdateTaskAudit(ctx context.Context, record *common.A
updateData.LatencyMs = record.LatencyMs
}
_, err := lcommon.AuditModelCtx(bgCtx, "aud_request_logs").
Where("task_id", record.TaskID).
Data(updateData).
Update()
if err != nil {
g.Log().Errorf(bgCtx,
"update task audit failed: task_id=%s err=%v",
record.TaskID, err)
// 提交阶段的审计 INSERT 是异步的(RecordAudit 内 go func 落库)。对 sync_image 这类
// 提交后立即入队、worker 可能极快出终态的任务,完成回调有可能抢在 INSERT 之前到达,
// 导致按 task_id 更新命中 0 行、终态(SUCCESS/FAILURE)丢失,审计里的任务状态一直停在
// 「已提交」。这里在命中 0 行时短暂重试,兜住这个竞态窗口。
// 审计关闭时提交阶段本就不写行,无需重试,直接返回避免空转。
auditDisabled := globalLevel == lcommon.AuditLevelNone && tenantLevel == lcommon.AuditLevelNone
const maxAttempts = 6
for attempt := 1; ; attempt++ {
res, err := lcommon.AuditModelCtx(bgCtx, "aud_request_logs").
Where("task_id", record.TaskID).
Data(updateData).
Update()
if err != nil {
g.Log().Errorf(bgCtx,
"update task audit failed: task_id=%s attempt=%d err=%v",
record.TaskID, attempt, err)
return
}
if affected, _ := res.RowsAffected(); affected > 0 {
return
}
if auditDisabled || attempt >= maxAttempts {
if !auditDisabled {
g.Log().Warningf(bgCtx,
"update task audit affected 0 rows after %d attempts: task_id=%s (submit audit not persisted yet?)",
maxAttempts, record.TaskID)
}
return
}
// 线性退避:150ms、300ms……最长约 2.25s,足够覆盖异步 INSERT 落库延迟。
time.Sleep(time.Duration(attempt) * 150 * time.Millisecond)
}
}()
}
+67 -4
View File
@@ -86,6 +86,12 @@ func pollOnce(ctx context.Context) {
// 3. 按 platform 分组处理
platformGroups := make(map[string][]*common.AsyncTask)
for _, t := range tasks {
// sync_image 无上游任务 ID 可轮询,由 worker 池自行推进;此处跳过,
// 避免 processPlatformTasks → taskchannel.GetAdaptor("sync_image") 报错刷日志。
// 其超时/未结算仍由 handleTimedOutTasks / handleUnsettledTasks 兜底。
if t.Platform == string(constant.TaskPlatformSyncImage) {
continue
}
platformGroups[t.Platform] = append(platformGroups[t.Platform], t)
}
@@ -94,6 +100,19 @@ func pollOnce(ctx context.Context) {
}
}
// buildTimeoutFailure 把超时任务标记为失败,并返回**覆盖前**的真实状态供 CAS 谓词使用。
//
// 独立成纯函数以便回归测试。历史 bug:调用方先 `t.Status = "FAILURE"` 再把 `t.Status`
// 当作 UpdateTaskCAS 的 oldStatus 传入,使 CAS 变成 `WHERE status='FAILURE' SET status='FAILURE'`,
// 对任何非终态行匹配 0 行 → 超时兜底网从未生效(超时任务不退款、active 计数不减)。
func buildTimeoutFailure(t *common.AsyncTask, now time.Time) (oldStatus string) {
oldStatus = t.Status
t.Status = "FAILURE"
t.FailReason = "task timed out"
t.FinishTime = &now
return oldStatus
}
// handleTimedOutTasks 处理超时任务
func handleTimedOutTasks(ctx context.Context) {
cutoff := time.Now().Add(-taskTimeout).Unix()
@@ -104,12 +123,11 @@ func handleTimedOutTasks(ctx context.Context) {
}
for _, t := range tasks {
t.Status = "FAILURE"
t.FailReason = "task timed out"
now := time.Now()
t.FinishTime = &now
// 必须用覆盖前的真实状态(IN_PROGRESS/QUEUED/SUBMITTED…)作为 CAS 谓词。
oldStatus := buildTimeoutFailure(t, now)
if err := DefaultAsyncProvider.UpdateTaskCAS(ctx, t, t.Status); err != nil {
if err := DefaultAsyncProvider.UpdateTaskCAS(ctx, t, oldStatus); err != nil {
g.Log().Warningf(ctx, "poll: mark timeout task %s: %v", t.PublicTaskID, err)
continue
}
@@ -147,6 +165,13 @@ func handleUnsettledTasks(ctx context.Context) {
}
for _, t := range tasks {
// sync_image 无 upstream_task_id,不能落入下面「UpstreamTaskID=="" → 退款」的分支,
// 否则崩溃窗口内的成功任务会被误退款(用户出图却全额退回,资损)。单独处理。
if t.Platform == string(constant.TaskPlatformSyncImage) {
handleUnsettledSyncImage(ctx, t)
continue
}
var pd privateData
if err := json.Unmarshal(t.PrivateData, &pd); err != nil || pd.UpstreamTaskID == "" {
// 无法恢复,直接退还预扣
@@ -196,6 +221,44 @@ func handleUnsettledTasks(ctx context.Context) {
}
}
// handleUnsettledSyncImage 结算重试网中 sync_image 任务的专用处理。
//
// sync_image 无 upstream_task_id,通用分支会把它当「不可恢复」直接退款——但对 worker 已判
// SUCCESS、仅 billing_settled 未落库的崩溃窗口任务而言,退款即资损(用户出图却被全额退回)。
// 因此这里按终态区分:SUCCESS 用 private_data 的 billing_context 重放结算,FAILURE 才退款。
func handleUnsettledSyncImage(ctx context.Context, t *common.AsyncTask) {
taskBilling := billing.NewTaskBillingProvider()
if t.Status == "SUCCESS" {
var pd privateData
_ = json.Unmarshal(t.PrivateData, &pd)
actualCost := t.ActualCost
if actualCost <= 0 {
actualCost = t.PreDeductAmount
}
if _, err := taskBilling.SettleTaskSuccess(ctx, t.TenantID, t.UserID, t.ApiKeyID, t.ChannelID,
t.ModelName, t.RequestID, actualCost, t.PreDeductAmount, 0, 0, pd.BillingContext.Ratios, t.PublicTaskID); err != nil {
g.Log().Warningf(ctx, "poll: retry settle sync_image task %s: %v", t.PublicTaskID, err)
return
}
t.BillingSettled = true
t.ActualCost = actualCost
taskBilling.IncrApiKeyQuotaUsed(ctx, t.ApiKeyID, actualCost)
DefaultAsyncProvider.UpdateTask(ctx, t)
g.Log().Infof(ctx, "poll: retried settlement for sync_image task %s", t.PublicTaskID)
return
}
// FAILURE:退还预扣
if err := taskBilling.SettleTaskFailed(ctx, t.TenantID, t.RequestID, t.PreDeductAmount); err != nil {
g.Log().Warningf(ctx, "poll: retry refund sync_image task %s: %v", t.PublicTaskID, err)
return
}
t.BillingSettled = true
DefaultAsyncProvider.UpdateTask(ctx, t)
billing.CleanupPreDeduct(ctx, t.TenantID, t.RequestID+"_adjust")
}
// processPlatformTasks 处理同一平台的任务
func processPlatformTasks(ctx context.Context, platform string, tasks []*common.AsyncTask) {
// 按 channel_id 分组
+35
View File
@@ -0,0 +1,35 @@
package task
import (
"testing"
"time"
"github.com/qianfree/team-api/relay/common"
)
// TestBuildTimeoutFailure_UsesOriginalStatusAsCASPredicate 锁定超时兜底网的回归 bug:
// UpdateTaskCAS 的 oldStatus 必须是覆盖前的真实状态,而不是已改写的 "FAILURE"。
func TestBuildTimeoutFailure_UsesOriginalStatusAsCASPredicate(t *testing.T) {
for _, orig := range []string{"IN_PROGRESS", "QUEUED", "SUBMITTED", "NOT_START"} {
task := &common.AsyncTask{PublicTaskID: "task_x", Status: orig}
now := time.Now()
oldStatus := buildTimeoutFailure(task, now)
if oldStatus != orig {
t.Fatalf("oldStatus = %q, want %q (must be the pre-mutation status, never \"FAILURE\")", oldStatus, orig)
}
if oldStatus == "FAILURE" {
t.Fatal("regression: oldStatus must not be the mutated FAILURE value")
}
if task.Status != "FAILURE" {
t.Fatalf("task.Status = %q, want FAILURE", task.Status)
}
if task.FailReason != "task timed out" {
t.Fatalf("task.FailReason = %q, want 'task timed out'", task.FailReason)
}
if task.FinishTime == nil || !task.FinishTime.Equal(now) {
t.Fatal("task.FinishTime must be set to now")
}
}
}
+48
View File
@@ -0,0 +1,48 @@
package task
import (
"bytes"
"net/http"
)
// memResponseWriter 是一个纯内存的 http.ResponseWriter 实现。
//
// sync_image worker 复刻 relay 管线时没有真实的客户端 HTTP 连接,
// adaptor.DoResponse 会把上游响应写入传入的 writer。使用本类型捕获响应体和状态码,
// 供 worker 后续解析(b64_json / url),而不会像 ResponseCaptureWriter 那样
// 因内嵌 nil 的真实 writer 而 panic。
type memResponseWriter struct {
header http.Header
buf bytes.Buffer
status int
}
// newMemResponseWriter 创建内存 writer,默认状态码 200(与 net/http 语义一致)。
func newMemResponseWriter() *memResponseWriter {
return &memResponseWriter{
header: make(http.Header),
status: http.StatusOK,
}
}
func (w *memResponseWriter) Header() http.Header {
return w.header
}
func (w *memResponseWriter) WriteHeader(code int) {
w.status = code
}
func (w *memResponseWriter) Write(b []byte) (int, error) {
return w.buf.Write(b)
}
// Bytes 返回已捕获的响应体。
func (w *memResponseWriter) Bytes() []byte {
return w.buf.Bytes()
}
// StatusCode 返回捕获的状态码。
func (w *memResponseWriter) StatusCode() int {
return w.status
}
+762
View File
@@ -0,0 +1,762 @@
package task
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
"github.com/qianfree/team-api/internal/logic/billing"
lcommon "github.com/qianfree/team-api/internal/logic/common"
"github.com/qianfree/team-api/internal/logic/monitor"
"github.com/qianfree/team-api/internal/logic/relay"
"github.com/qianfree/team-api/relay/channel"
"github.com/qianfree/team-api/relay/common"
"github.com/qianfree/team-api/relay/constant"
"github.com/qianfree/team-api/relay/dto"
)
// 同步图片厂商「异步化」worker 池。
//
// 背景:OpenAI/DALL·E 等图片厂商同步阻塞返回(10–60s)。走 /v1/images/generations/async
// 端点时,提交阶段立即建 QUEUED 任务并返回 task_id,真正持有上游长连接的动作交给这里的
// 有界 worker 池(默认 100)在后台执行,客户端轮询 /fetch 取结果。
//
// 三层并发控制:① 全局池(worker 数硬上限)② 单渠道(channelInflight vs MaxConcurrency)
// ③ 渠道间(GetChannelForModel 优先级/权重 + 饱和渠道 exclude 重选)。
const (
syncImageWorkerCount = 100 // 全局 worker 池大小(层①)
syncImageQueueSize = 1000 // 有界队列容量,满则提交侧退款+429
syncImageMaxAttempts = 16 // 单任务选渠道最大尝试次数(防全饱和时空转)
syncImageDownloadCap = 20 << 20
// syncImageShutdownWait 停机时等待在途任务收尾的最长时间。超过则强制停止,
// 把仍未完成的在途任务标记为超时(TIMEOUT)并退款,不再无限等待上游返回。
syncImageShutdownWait = 3 * time.Minute
)
var (
syncImageQueue chan *SyncImageJob
syncImageStop chan struct{} // 关闭 → worker 不再领取新任务,处理完在途任务即退出
syncImageWg sync.WaitGroup
syncImageShutdown atomic.Bool // 停机中:拒绝新任务入队
// 本实例在途任务登记表(TaskID -> job)。用于停机超时时,只对**本实例**正在执行的
// 任务做超时+退款,避免误伤多实例部署下其他实例的 IN_PROGRESS 任务。
syncImageRunningMu sync.Mutex
syncImageRunning = make(map[int64]*SyncImageJob)
channelInflightMu sync.Mutex
channelInflight = make(map[int64]int)
// 池状态计数器(进程内累计,重启归零;供实时监控面板展示)。
syncImageBusy atomic.Int64 // 忙碌 worker 数(瞬时)
syncImageEnqueued atomic.Int64 // 累计入队成功
syncImageRejected atomic.Int64 // 累计拒绝(队列满 → 429 退款)
syncImageSucceeded atomic.Int64 // 累计 worker 处理成功
syncImageFailed atomic.Int64 // 累计 worker 处理失败
syncImageFileSvc *lcommon.FileService
syncImageBilling common.TaskBillingProvider
syncImageRelayProv common.DataProvider
)
// SyncImageJob 同步图片任务的内存载荷(请求体只放内存,不落库,无需崩溃重放)。
type SyncImageJob struct {
TaskID int64 // tsk_model_tasks 主键
PublicTaskID string
RequestID string
TenantID int64
UserID int64
ApiKeyID int64
ProjectID int64
Model string
RequestBody []byte
PreDeductAmount float64
Ratios map[string]float64
SubmitTime time.Time
}
// StartSyncImageWorkers 启动 worker 池,与 StartAsyncPolling 同期在 cmd.go 接线。
func StartSyncImageWorkers(ctx context.Context) {
syncImageQueue = make(chan *SyncImageJob, syncImageQueueSize)
syncImageStop = make(chan struct{})
syncImageShutdown.Store(false)
syncImageRunningMu.Lock()
syncImageRunning = make(map[int64]*SyncImageJob)
syncImageRunningMu.Unlock()
syncImageBilling = billing.NewTaskBillingProvider()
syncImageRelayProv = relay.NewDataProvider()
// 对象存储在启动时构造一次共享(无状态,可安全共享)。未配置时置 nil,
// b64_json 任务在无存储时会走 FAILURE+退款(见 buildImageResult)。
if fs, err := lcommon.NewFileServiceFromConfig(ctx); err != nil {
g.Log().Warningf(ctx, "sync_image: object storage not configured (%v); b64_json image tasks will fail until storage is set", err)
} else {
syncImageFileSvc = fs
}
for i := 0; i < syncImageWorkerCount; i++ {
syncImageWg.Add(1)
go syncImageWorkerLoop(i)
}
g.Log().Infof(ctx, "sync_image: started %d workers (queue=%d)", syncImageWorkerCount, syncImageQueueSize)
// 注册状态取数函数,供管理后台实时监控面板展示池状态。
monitor.RegisterSyncImagePoolProvider(SyncImageWorkerStats)
}
// StopSyncImageWorkers 停机:
// 1. 置停机标志,拒绝新任务入队,通知 worker 不再领取新任务;
// 2. 将队列中未开始的排队任务全部置为 FAILURE 并退款;
// 3. 有界等待在途任务收尾(最长 syncImageShutdownWait);超过则强制停止,
// 把仍未完成的在途任务标记为超时(TIMEOUT)并退款,不再无限等待上游返回。
func StopSyncImageWorkers() {
if syncImageQueue == nil {
return
}
ctx := gctx.New()
// 1. 拒绝新入队,并通知 worker 停止领取新任务。
syncImageShutdown.Store(true)
// 幂等:避免重复 close panic。
select {
case <-syncImageStop:
default:
close(syncImageStop)
}
// 2. 将队列中未开始的排队任务全部失败并退款。
// 此时 worker 收到 stop 后不再领取新任务;即便有个别 worker 在关闭瞬间抢走一条,
// channel 接收是原子的,该任务转为在途(由步骤 3 处理),不会与本次排干重复。
failQueuedOnShutdown(ctx)
// 3. 有界等待在途任务收尾。
done := make(chan struct{})
go func() {
syncImageWg.Wait()
close(done)
}()
select {
case <-done:
g.Log().Info(ctx, "sync_image: 所有在途任务已完成,worker 池已停止")
case <-time.After(syncImageShutdownWait):
// 超过等待上限:强制停止,未完成的在途任务标记为超时并退款。
// 残留 worker goroutine 仍卡在上游,随进程退出而终止;其后续收尾会因 CAS
// 谓词失配而 no-op,不会与这里的超时处理重复结算。
g.Log().Warningf(ctx, "sync_image: 等待在途任务超过 %v,强制停止;未完成任务标记为超时并退款", syncImageShutdownWait)
timeoutRunningOnShutdown(ctx)
}
}
// failQueuedOnShutdown 排干队列,把未开始的排队任务全部置为 FAILURE 并退款。
func failQueuedOnShutdown(ctx context.Context) {
failed := 0
for {
select {
case job := <-syncImageQueue:
if job == nil {
continue
}
failQueuedSyncImageJob(ctx, job, "server shutting down")
failed++
default:
if failed > 0 {
g.Log().Infof(ctx, "sync_image: 停机将 %d 个未开始的排队任务置为失败并退款", failed)
}
return
}
}
}
// timeoutRunningOnShutdown 停机超时:把本实例所有仍未完成的在途任务标记为超时并退款。
func timeoutRunningOnShutdown(ctx context.Context) {
syncImageRunningMu.Lock()
jobs := make([]*SyncImageJob, 0, len(syncImageRunning))
for _, j := range syncImageRunning {
jobs = append(jobs, j)
}
syncImageRunningMu.Unlock()
timedOut := 0
for _, job := range jobs {
if timeoutRunningSyncImageJob(ctx, job) {
timedOut++
}
}
if timedOut > 0 {
g.Log().Warningf(ctx, "sync_image: 停机强制将 %d 个在途任务标记为超时并退款", timedOut)
}
}
// timeoutRunningSyncImageJob 把单个在途任务标记为超时并退款(语义对齐 handleTimedOutTasks):
// DB status 置 FAILURE(task timed out)、审计 task_status 置 TIMEOUT。CAS 谓词 IN_PROGRESS
// 保护:若残留 worker 已抢先落终态,这里 no-op,避免重复退款/重复减 active 计数。
func timeoutRunningSyncImageJob(ctx context.Context, job *SyncImageJob) bool {
now := time.Now()
const reason = "task timed out"
if err := DefaultAsyncProvider.UpdateTaskCAS(ctx, &common.AsyncTask{
ID: job.TaskID,
Status: "FAILURE",
FailReason: reason,
FinishTime: &now,
}, "IN_PROGRESS"); err != nil {
return false // 已被 worker 收尾或其他方抢占
}
settled := true
if job.PreDeductAmount > 0 {
if err := syncImageBilling.SettleTaskFailed(ctx, job.TenantID, job.RequestID, job.PreDeductAmount); err != nil {
g.Log().Warningf(ctx, "sync_image: timeout task %s refund failed (unsettled net will retry): %v", job.PublicTaskID, err)
settled = false
}
}
if settled {
_ = DefaultAsyncProvider.UpdateTask(ctx, &common.AsyncTask{
ID: job.TaskID,
Status: "FAILURE",
FailReason: reason,
FinishTime: &now,
BillingSettled: true,
})
}
syncImageFailed.Add(1)
DecrActiveTask()
monitor.UnregisterRequestByTaskID(job.PublicTaskID)
usageTask := buildUsageTask(job, nil, "FAILURE", 0, now)
recordTaskUsage(usageTask, nil, false, reason, nil)
// 闭环审计:把提交阶段的 SUBMITTED 审计记录更新为 TIMEOUT(与超时兜底网一致)。
recordTaskCompletionAudit(usageTask, "TIMEOUT", reason, nil)
return true
}
// EnqueueSyncImageJob 非阻塞入队;队列满返回 false,供提交侧退款+429。
func EnqueueSyncImageJob(job *SyncImageJob) bool {
if syncImageQueue == nil || syncImageShutdown.Load() {
syncImageRejected.Add(1)
return false
}
select {
case syncImageQueue <- job:
syncImageEnqueued.Add(1)
return true
default:
syncImageRejected.Add(1)
return false
}
}
// SyncImageWorkerStats 返回 worker 池当前状态快照,供管理后台实时监控面板展示。
// 在 StartSyncImageWorkers 中注册进 monitor 包(Provider 注入,避免 monitor → task 导入环)。
func SyncImageWorkerStats() monitor.SyncImagePoolSnapshot {
channelInflightMu.Lock()
inflight := make(map[int64]int, len(channelInflight))
for k, v := range channelInflight {
inflight[k] = v
}
channelInflightMu.Unlock()
qLen, qCap := 0, 0
if syncImageQueue != nil {
qLen, qCap = len(syncImageQueue), cap(syncImageQueue)
}
return monitor.SyncImagePoolSnapshot{
WorkerTotal: syncImageWorkerCount,
WorkerBusy: int(syncImageBusy.Load()),
QueueDepth: qLen,
QueueCap: qCap,
Enqueued: syncImageEnqueued.Load(),
Rejected: syncImageRejected.Load(),
Succeeded: syncImageSucceeded.Load(),
Failed: syncImageFailed.Load(),
ChannelInflight: inflight,
}
}
func syncImageWorkerLoop(workerID int) {
defer syncImageWg.Done()
for {
// 优先响应停机:一旦收到停机信号,不再领取新的排队任务。已在执行中的在途任务
// 由 runSyncImageJob 同步跑完(syncImageWg 会等待),未开始的排队任务由
// StopSyncImageWorkers 在 worker 全部退出后统一置为 FAILURE。
select {
case <-syncImageStop:
return
default:
}
select {
case <-syncImageStop:
return
case job := <-syncImageQueue:
if job == nil {
return
}
runSyncImageJob(workerID, job)
}
}
}
// failQueuedSyncImageJob 停机时把仍处于 QUEUED(未开始)的任务置为 FAILURE 并退款。
// 用 CAS QUEUED->FAILURE 保护:若该任务已被某 worker 抢走(IN_PROGRESS/终态),这里 no-op,
// 由该 worker 负责结算,避免重复退款。
func failQueuedSyncImageJob(ctx context.Context, job *SyncImageJob, reason string) {
now := time.Now()
reason = truncateStr(reason, 500)
if err := DefaultAsyncProvider.UpdateTaskCAS(ctx, &common.AsyncTask{
ID: job.TaskID,
Status: "FAILURE",
FailReason: reason,
FinishTime: &now,
}, "QUEUED"); err != nil {
return
}
settled := true
if job.PreDeductAmount > 0 {
if err := syncImageBilling.SettleTaskFailed(ctx, job.TenantID, job.RequestID, job.PreDeductAmount); err != nil {
g.Log().Warningf(ctx, "sync_image: shutdown-fail task %s refund failed (unsettled net will retry): %v", job.PublicTaskID, err)
settled = false
}
}
if settled {
_ = DefaultAsyncProvider.UpdateTask(ctx, &common.AsyncTask{
ID: job.TaskID,
Status: "FAILURE",
FailReason: reason,
FinishTime: &now,
BillingSettled: true,
})
}
syncImageFailed.Add(1)
DecrActiveTask()
monitor.UnregisterRequestByTaskID(job.PublicTaskID)
usageTask := buildUsageTask(job, nil, "FAILURE", 0, now)
recordTaskUsage(usageTask, nil, false, reason, nil)
// 闭环审计:把提交阶段的 SUBMITTED 审计记录更新为 FAILURE。
recordTaskCompletionAudit(usageTask, "FAILURE", reason, nil)
}
// runSyncImageJob 每任务 recover 兜 panic,防单任务崩溃拖垮 worker。
func runSyncImageJob(workerID int, job *SyncImageJob) {
syncImageBusy.Add(1)
// 登记为本实例在途任务,供停机超时时定向处理;defer 保证 panic 也能摘除。
syncImageRunningMu.Lock()
syncImageRunning[job.TaskID] = job
syncImageRunningMu.Unlock()
defer func() {
syncImageRunningMu.Lock()
delete(syncImageRunning, job.TaskID)
syncImageRunningMu.Unlock()
syncImageBusy.Add(-1)
if r := recover(); r != nil {
g.Log().Errorf(gctx.New(), "sync_image: worker %d panic on task %s: %v", workerID, job.PublicTaskID, r)
}
}()
processSyncImageJob(job)
}
func processSyncImageJob(job *SyncImageJob) {
ctx := gctx.New()
// 1. CAS QUEUED -> IN_PROGRESS(与超时网互斥)
now := time.Now()
if err := DefaultAsyncProvider.UpdateTaskCAS(ctx, &common.AsyncTask{
ID: job.TaskID,
Status: "IN_PROGRESS",
Progress: "0%",
StartTime: &now,
}, "QUEUED"); err != nil {
g.Log().Warningf(ctx, "sync_image: task %s CAS QUEUED->IN_PROGRESS failed (already taken): %v", job.PublicTaskID, err)
return
}
// 2. 选渠道循环 + 复刻管线(失败换渠道重试)
var exclude []int64
lastErr := "no available channel"
for attempt := 0; attempt < syncImageMaxAttempts; attempt++ {
sel, err := syncImageRelayProv.GetChannelForModel(ctx, job.TenantID, job.UserID, job.Model, exclude)
if err != nil {
lastErr = fmt.Sprintf("select channel: %v", err)
break // 无更多候选
}
// 层②:per-channel 容量。MaxConcurrency<=0 视为不限。
if !tryOccupyChannel(sel.ChannelID, sel.MaxConcurrency) {
exclude = append(exclude, sel.ChannelID)
syncImageJitter()
continue
}
ok, memW, perr := runImagePipelineWithRelease(ctx, job, sel)
if ok {
settleSyncImageSuccess(ctx, job, sel, memW)
return
}
// 失败:降健康度 + 排除该渠道 + 抖动退避重选
syncImageRelayProv.UpdateChannelHealth(ctx, sel.ChannelID, false, 0)
syncImageRelayProv.IncrementConsecutiveFailure(ctx, sel.ChannelID)
exclude = append(exclude, sel.ChannelID)
lastErr = perr
syncImageJitter()
}
// 3. 全饱和/无候选/重试耗尽 → FAILURE + 退款
failSyncImageJob(ctx, job, nil, lastErr)
}
// runImagePipelineWithRelease 保证无论成功/失败/panic 都释放 per-channel 槽(defer)。
func runImagePipelineWithRelease(ctx context.Context, job *SyncImageJob, sel *common.ChannelSelection) (ok bool, memW *memResponseWriter, failReason string) {
defer decInflight(sel.ChannelID)
return runImagePipeline(ctx, job, sel)
}
// runImagePipeline 复刻 RelayHandler 的最小管线,把上游响应捕获到内存 writer。
func runImagePipeline(ctx context.Context, job *SyncImageJob, sel *common.ChannelSelection) (bool, *memResponseWriter, string) {
info := &common.RelayInfo{
Context: ctx,
TenantID: job.TenantID,
UserID: job.UserID,
ApiKeyID: job.ApiKeyID,
ProjectID: job.ProjectID,
RequestID: job.RequestID,
RelayMode: int(constant.RelayModeImagesGenerations),
IsStream: false,
OriginModelName: job.Model,
BaseModelName: job.Model,
RequestURLPath: "/v1/images/generations",
RequestHeaders: http.Header{
"Content-Type": []string{"application/json"},
"Accept": []string{"application/json"},
},
StartTime: time.Now(),
StreamStatus: common.NewStreamStatus(),
InboundFormat: constant.RelayFormatOpenAI,
ClientFormat: constant.RelayFormatOpenAI,
ChannelMeta: &common.ChannelMeta{
ChannelID: sel.ChannelID,
ChannelType: sel.ChannelType,
ChannelName: sel.ChannelName,
BaseURL: sel.BaseURL,
ApiKey: sel.ApiKey,
UpstreamModelName: sel.UpstreamModelName,
IsModelMapped: sel.IsModelMapped,
Settings: sel.Settings,
},
}
adaptor := channel.GetAdaptor(sel.ChannelType)
if adaptor == nil {
return false, nil, fmt.Sprintf("no adaptor for channelType %d", sel.ChannelType)
}
adaptor.Init(info)
reader, err := adaptor.ConvertRequest(ctx, info, job.RequestBody)
if err != nil {
return false, nil, fmt.Sprintf("convert request: %v", err)
}
// 上游长连接用后台 context(不随停机被斩断),阻塞 10–60s。
resp, err := adaptor.DoRequest(context.WithoutCancel(ctx), info, reader)
if err != nil {
return false, nil, fmt.Sprintf("do request: %v", err)
}
memW := newMemResponseWriter()
if _, err := adaptor.DoResponse(ctx, resp, info, memW); err != nil {
return false, memW, fmt.Sprintf("do response: %v", err)
}
if memW.StatusCode() != http.StatusOK {
return false, memW, fmt.Sprintf("upstream status %d: %s", memW.StatusCode(), truncateStr(string(memW.Bytes()), 300))
}
return true, memW, ""
}
// settleSyncImageSuccess 成功收尾:先 CAS 赢终态,再结算钱包,最后标记 billing_settled。
// 崩溃窗口(终态已写、settled 未写)由 handleUnsettledTasks 的 sync_image 分支兜底。
func settleSyncImageSuccess(ctx context.Context, job *SyncImageJob, sel *common.ChannelSelection, memW *memResponseWriter) {
resultURL, normalized, err := buildImageResult(ctx, job, memW.Bytes())
if err != nil {
failSyncImageJob(ctx, job, sel, fmt.Sprintf("build result: %v", err))
return
}
now := time.Now()
// per_request 图片:实际费用 == 预扣(RecalculateByTokens 对 per_request 返回 0,无 token 重算)。
actualCost := job.PreDeductAmount
// 1. 赢得终态(billing_settled=false 先落)
if err := DefaultAsyncProvider.UpdateTaskCAS(ctx, &common.AsyncTask{
ID: job.TaskID,
Status: "SUCCESS",
Progress: "100%",
ResultURL: resultURL,
Data: normalized,
FinishTime: &now,
}, "IN_PROGRESS"); err != nil {
// 已被超时网抢占,放弃结算(抢占方负责退款/DecrActiveTask)
g.Log().Warningf(ctx, "sync_image: task %s terminal CAS failed (preempted): %v", job.PublicTaskID, err)
return
}
// 2. 结算钱包
settleResult, serr := syncImageBilling.SettleTaskSuccess(ctx, job.TenantID, job.UserID, job.ApiKeyID, sel.ChannelID,
job.Model, job.RequestID, actualCost, job.PreDeductAmount, 0, 0, job.Ratios, job.PublicTaskID)
if serr != nil {
// 保留 billing_settled=false,由未结算兜底网重放结算
g.Log().Warningf(ctx, "sync_image: task %s settle success failed (unsettled net will retry): %v", job.PublicTaskID, serr)
} else {
// 3. 标记已结算
_ = DefaultAsyncProvider.UpdateTask(ctx, &common.AsyncTask{
ID: job.TaskID,
Status: "SUCCESS",
Progress: "100%",
ResultURL: resultURL,
Data: normalized,
FinishTime: &now,
BillingSettled: true,
ActualCost: actualCost,
})
syncImageBilling.IncrApiKeyQuotaUsed(ctx, job.ApiKeyID, actualCost)
}
// 4. 收尾
syncImageSucceeded.Add(1)
DecrActiveTask()
monitor.UnregisterRequestByTaskID(job.PublicTaskID)
syncImageRelayProv.UpdateChannelHealth(ctx, sel.ChannelID, true, 0)
chBasic := &common.ChannelBasicInfo{ID: sel.ChannelID, Type: sel.ChannelType, Name: sel.ChannelName}
usageTask := buildUsageTask(job, sel, "SUCCESS", actualCost, now)
recordTaskUsage(usageTask, chBasic, true, "", settleResult)
// 闭环审计:把提交阶段写入的 SUBMITTED 审计记录更新为终态(与 pollSingleTask 一致),
// 否则请求审计日志里的任务状态会一直停留在「已提交」。
recordTaskCompletionAudit(usageTask, "SUCCESS", string(normalized), nil)
}
// failSyncImageJob 失败收尾:CAS 赢终态 → 退款 → 标记已结算 → 用量/计数。
func failSyncImageJob(ctx context.Context, job *SyncImageJob, sel *common.ChannelSelection, reason string) {
now := time.Now()
reason = truncateStr(reason, 500)
if err := DefaultAsyncProvider.UpdateTaskCAS(ctx, &common.AsyncTask{
ID: job.TaskID,
Status: "FAILURE",
FailReason: reason,
FinishTime: &now,
}, "IN_PROGRESS"); err != nil {
g.Log().Warningf(ctx, "sync_image: task %s fail CAS failed (already finalized): %v", job.PublicTaskID, err)
return
}
settled := true
if job.PreDeductAmount > 0 {
if err := syncImageBilling.SettleTaskFailed(ctx, job.TenantID, job.RequestID, job.PreDeductAmount); err != nil {
g.Log().Warningf(ctx, "sync_image: task %s refund failed (unsettled net will retry): %v", job.PublicTaskID, err)
settled = false
}
}
if settled {
_ = DefaultAsyncProvider.UpdateTask(ctx, &common.AsyncTask{
ID: job.TaskID,
Status: "FAILURE",
FailReason: reason,
FinishTime: &now,
BillingSettled: true,
})
}
syncImageFailed.Add(1)
DecrActiveTask()
monitor.UnregisterRequestByTaskID(job.PublicTaskID)
var chBasic *common.ChannelBasicInfo
if sel != nil {
chBasic = &common.ChannelBasicInfo{ID: sel.ChannelID, Type: sel.ChannelType, Name: sel.ChannelName}
}
usageTask := buildUsageTask(job, sel, "FAILURE", 0, now)
recordTaskUsage(usageTask, chBasic, false, reason, nil)
// 闭环审计:更新提交阶段的 SUBMITTED 审计记录为 FAILURE。
recordTaskCompletionAudit(usageTask, "FAILURE", reason, nil)
}
// buildImageResult 解析上游图片响应,返回结果 URL 与归一化后的响应体(供落库 Data)。
// b64_json 无条件 re-host;url 按配置开关(默认透传,开启则下载 re-host)。
func buildImageResult(ctx context.Context, job *SyncImageJob, body []byte) (resultURL string, normalized []byte, err error) {
var imgResp dto.ImageResponse
if e := json.Unmarshal(body, &imgResp); e != nil {
return "", nil, fmt.Errorf("parse image response: %w", e)
}
if len(imgResp.Data) == 0 {
return "", nil, fmt.Errorf("empty image data")
}
if len(imgResp.Data) > 1 {
g.Log().Warningf(ctx, "sync_image: task %s returned %d images; only the first is surfaced via ResultURL", job.PublicTaskID, len(imgResp.Data))
}
first := imgResp.Data[0]
switch {
case first.B64JSON != "":
data, e := base64.StdEncoding.DecodeString(first.B64JSON)
if e != nil {
return "", nil, fmt.Errorf("decode b64_json: %w", e)
}
resultURL, err = rehostImage(ctx, job, data, "image/png", ".png")
case first.URL != "":
if shouldRehostURL(ctx) {
resultURL, err = rehostFromURL(ctx, job, first.URL)
} else {
resultURL = first.URL
}
default:
return "", nil, fmt.Errorf("no url or b64_json in image response")
}
if err != nil {
return "", nil, err
}
// 归一化落库:只存单张结果 URL,避免把大体积 b64 原文写进 data 列。
normalized, _ = json.Marshal(dto.ImageResponse{
Created: imgResp.Created,
Data: []dto.ImageData{{URL: resultURL, RevisedPrompt: first.RevisedPrompt}},
})
return resultURL, normalized, nil
}
func rehostImage(ctx context.Context, job *SyncImageJob, data []byte, contentType, ext string) (string, error) {
if syncImageFileSvc == nil {
return "", fmt.Errorf("object storage not configured, cannot re-host image")
}
rec, err := syncImageFileSvc.Upload(ctx, &lcommon.FileUpload{
Reader: bytes.NewReader(data),
Filename: job.PublicTaskID + ext,
ContentType: contentType,
Size: int64(len(data)),
TenantID: job.TenantID,
UserID: job.UserID,
})
if err != nil {
return "", err
}
return syncImageFileSvc.GetDownloadURL(ctx, rec.ID)
}
func rehostFromURL(ctx context.Context, job *SyncImageJob, url string) (string, error) {
if syncImageFileSvc == nil {
return "", fmt.Errorf("object storage not configured, cannot re-host image")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download image status %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, syncImageDownloadCap))
if err != nil {
return "", err
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "image/png"
}
return rehostImage(ctx, job, data, contentType, extFromContentType(contentType))
}
func shouldRehostURL(ctx context.Context) bool {
return lcommon.Config().GetBool(ctx, "sync_image_rehost_url")
}
// buildUsageTask 构造供 recordTaskUsage 使用的 AsyncTask(用量日志需要的字段)。
func buildUsageTask(job *SyncImageJob, sel *common.ChannelSelection, status string, actualCost float64, finish time.Time) *common.AsyncTask {
submit := job.SubmitTime
t := &common.AsyncTask{
ID: job.TaskID,
PublicTaskID: job.PublicTaskID,
RequestID: job.RequestID,
Platform: string(constant.TaskPlatformSyncImage),
Status: status,
TenantID: job.TenantID,
UserID: job.UserID,
ApiKeyID: job.ApiKeyID,
ModelName: job.Model,
PreDeductAmount: job.PreDeductAmount,
ActualCost: actualCost,
SubmitTime: &submit,
FinishTime: &finish,
}
if sel != nil {
t.ChannelID = sel.ChannelID
t.UpstreamModel = sel.UpstreamModelName
}
return t
}
// tryOccupyChannel 层②:容量未满则占槽并返回 true。MaxConcurrency<=0 视为不限。
func tryOccupyChannel(channelID int64, maxConcurrency int) bool {
channelInflightMu.Lock()
defer channelInflightMu.Unlock()
if maxConcurrency > 0 && channelInflight[channelID] >= maxConcurrency {
return false
}
channelInflight[channelID]++
return true
}
func decInflight(channelID int64) {
channelInflightMu.Lock()
defer channelInflightMu.Unlock()
if channelInflight[channelID] > 0 {
channelInflight[channelID]--
}
if channelInflight[channelID] <= 0 {
delete(channelInflight, channelID)
}
}
func syncImageJitter() {
time.Sleep(time.Duration(50+rand.Intn(150)) * time.Millisecond)
}
func truncateStr(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max]
}
func extFromContentType(ct string) string {
switch {
case bytes.Contains([]byte(ct), []byte("jpeg")), bytes.Contains([]byte(ct), []byte("jpg")):
return ".jpg"
case bytes.Contains([]byte(ct), []byte("webp")):
return ".webp"
default:
return ".png"
}
}
@@ -0,0 +1,129 @@
package task
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"testing"
)
func TestMemResponseWriter(t *testing.T) {
w := newMemResponseWriter()
if w.StatusCode() != http.StatusOK {
t.Fatalf("default status = %d, want 200", w.StatusCode())
}
if _, err := w.Write([]byte("hello ")); err != nil {
t.Fatalf("write: %v", err)
}
if _, err := w.Write([]byte("world")); err != nil {
t.Fatalf("write: %v", err)
}
if got := string(w.Bytes()); got != "hello world" {
t.Fatalf("body = %q, want %q", got, "hello world")
}
w.WriteHeader(http.StatusBadGateway)
if w.StatusCode() != http.StatusBadGateway {
t.Fatalf("status after WriteHeader = %d, want 502", w.StatusCode())
}
w.Header().Set("Content-Type", "application/json")
if w.Header().Get("Content-Type") != "application/json" {
t.Fatalf("header not stored")
}
}
func TestTryOccupyChannel_Unlimited(t *testing.T) {
const ch = int64(9000001)
defer func() { channelInflight = map[int64]int{} }()
// MaxConcurrency <= 0 视为不限:多次占用均成功。
for i := 0; i < 5; i++ {
if !tryOccupyChannel(ch, 0) {
t.Fatalf("occupy #%d with maxConc=0 should succeed", i)
}
}
if !tryOccupyChannel(ch, -1) {
t.Fatalf("occupy with maxConc=-1 should succeed")
}
}
func TestTryOccupyChannel_Saturation(t *testing.T) {
const ch = int64(9000002)
defer func() { channelInflight = map[int64]int{} }()
if !tryOccupyChannel(ch, 2) {
t.Fatal("occupy #1 should succeed")
}
if !tryOccupyChannel(ch, 2) {
t.Fatal("occupy #2 should succeed")
}
if tryOccupyChannel(ch, 2) {
t.Fatal("occupy #3 should fail (saturated)")
}
// 释放一个槽后应能再次占用
decInflight(ch)
if !tryOccupyChannel(ch, 2) {
t.Fatal("occupy after release should succeed")
}
}
func TestDecInflight_CleansUpAndFloorsAtZero(t *testing.T) {
const ch = int64(9000003)
defer func() { channelInflight = map[int64]int{} }()
tryOccupyChannel(ch, 5)
decInflight(ch)
if _, ok := channelInflight[ch]; ok {
t.Fatal("counter reaching 0 should be deleted from map")
}
// 多减不应变负 / panic
decInflight(ch)
if channelInflight[ch] != 0 {
t.Fatalf("counter = %d, want 0", channelInflight[ch])
}
}
func TestBuildImageResult_EmptyData(t *testing.T) {
job := &SyncImageJob{PublicTaskID: "task_x"}
body, _ := json.Marshal(map[string]any{"created": 1, "data": []any{}})
if _, _, err := buildImageResult(context.Background(), job, body); err == nil {
t.Fatal("empty data should error")
}
}
func TestBuildImageResult_B64WithoutStorageErrors(t *testing.T) {
// syncImageFileSvc 默认 nil(未启动 worker),b64_json 无法 re-host → 报错。
syncImageFileSvc = nil
job := &SyncImageJob{PublicTaskID: "task_x", TenantID: 1, UserID: 2}
raw, _ := json.Marshal(map[string]any{
"created": 1,
"data": []any{map[string]any{"b64_json": base64.StdEncoding.EncodeToString([]byte("PNGDATA"))}},
})
if _, _, err := buildImageResult(context.Background(), job, raw); err == nil {
t.Fatal("b64 without storage should error")
}
}
func TestExtFromContentType(t *testing.T) {
cases := map[string]string{
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
"": ".png",
"text/plain": ".png",
}
for ct, want := range cases {
if got := extFromContentType(ct); got != want {
t.Errorf("extFromContentType(%q) = %q, want %q", ct, got, want)
}
}
}
func TestTruncateStr(t *testing.T) {
if got := truncateStr("abcdef", 3); got != "abc" {
t.Fatalf("truncate = %q, want abc", got)
}
if got := truncateStr("ab", 5); got != "ab" {
t.Fatalf("truncate = %q, want ab", got)
}
}
+2 -2
View File
@@ -106,12 +106,12 @@ func (s *sTenant) AuditLogs(ctx context.Context, req *v1.TenantAuditLogsReq) (*v
// 使用原生 SQL 查询,绕过 GoFrame ScanAndCount 对 map[string]any 的 bug
dataSQL := `SELECT id, tenant_id, user_id, user_type, action, resource_type, resource_id, detail, changes_json, ip_address, created_at
FROM aud_operation_logs WHERE tenant_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?`
result, err := common.GetAuditDB().Query(ctx, dataSQL, tenantID, pageSize, (page-1)*pageSize)
result, err := g.DB().Query(ctx, dataSQL, tenantID, pageSize, (page-1)*pageSize)
if err != nil {
return nil, err
}
countResult, err := common.GetAuditDB().Query(ctx, "SELECT COUNT(*) AS total FROM aud_operation_logs WHERE tenant_id = ?", tenantID)
countResult, err := g.DB().Query(ctx, "SELECT COUNT(*) AS total FROM aud_operation_logs WHERE tenant_id = ?", tenantID)
if err != nil {
return nil, err
}
+21
View File
@@ -362,6 +362,27 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
}
}
// 标记图片模型是否必须走异步端点(DashScope 等异步图片 provider)。
// 判定与同步端点拦截 gate 同源(constant.IsAsyncImageProvider),保证在线体验示例
// 与后端实际行为一致。仅对图片分类模型跑一次批量查询。
imageModelIDs := make([]string, 0)
for _, item := range list {
if item.Category == "image" {
imageModelIDs = append(imageModelIDs, item.ModelId)
}
}
if len(imageModelIDs) > 0 {
asyncSet, err := lcommon.GetAsyncImageModelSet(ctx, imageModelIDs)
if err != nil {
return nil, err
}
for i := range list {
if list[i].Category == "image" && asyncSet[list[i].ModelId] {
list[i].AsyncImage = true
}
}
}
return &v1.TenantAvailableModelsRes{List: list}, nil
}
+7
View File
@@ -197,6 +197,13 @@ var adminPermissionRules = []permissionRule{
{method: "GET", path: "/api/admin/audit/sensitive-logs", perm: "audit:read_sensitive"},
{method: "GET", path: "/api/admin/audit/content-filter-logs", perm: "audit:view"},
// ── file 文件管理 ──
{method: "GET", path: "/api/admin/files", perm: "file:view"},
{method: "GET", path: "/api/admin/files/stats", perm: "file:view"},
{method: "POST", path: "/api/admin/files/cleanup", perm: "file:cleanup"},
{method: "GET", prefix: "/api/admin/files/", suffix: "/download", perm: "file:view"},
{method: "DELETE", prefix: "/api/admin/files/", perm: "file:delete"},
// ── monitor 监控告警 ──
{method: "GET", prefix: "/api/admin/monitor/", perm: "monitor:view"},
{method: "GET", path: "/api/admin/alert/rules", perm: "monitor:view"},
+10
View File
@@ -203,6 +203,16 @@ type (
UpdateFeedbackStatus(ctx context.Context, req *v1.FeedbackUpdateStatusReq) (*v1.FeedbackUpdateStatusRes, error)
// GetFeedbackStats 反馈统计
GetFeedbackStats(ctx context.Context, req *v1.FeedbackStatsReq) (*v1.FeedbackStatsRes, error)
// FileList 文件列表(分页 + 筛选)。
FileList(ctx context.Context, req *v1.FileListReq) (*v1.FileListRes, error)
// FileStats 存储占用统计(KPI)。
FileStats(ctx context.Context, req *v1.FileStatsReq) (*v1.FileStatsRes, error)
// FileDownload 生成临时预览下载链接。
FileDownload(ctx context.Context, req *v1.FileDownloadReq) (*v1.FileDownloadRes, error)
// FileDelete 删除单个文件(存储对象 + 记录)。
FileDelete(ctx context.Context, req *v1.FileDeleteReq) (*v1.FileDeleteRes, error)
// FileCleanup 手动触发一次保留期清理(导出 + 图片)。
FileCleanup(ctx context.Context, req *v1.FileCleanupReq) (*v1.FileCleanupRes, error)
// CreateHelpCategory 创建帮助分类
CreateHelpCategory(ctx context.Context, req *v1.HelpCategoryCreateReq) (*v1.HelpCategoryCreateRes, error)
// UpdateHelpCategory 更新帮助分类
+1
View File
@@ -226,6 +226,7 @@ type (
// GetOrgInfo returns tenant organization info.
GetOrgInfo(ctx context.Context, req *v1.TenantOrgInfoReq) (*v1.TenantOrgInfoRes, error)
// UpdateOrgInfo updates tenant organization info.
// 组织代码仅可在未启用团队功能时设置一次(设置即激活),启用后不可修改。
UpdateOrgInfo(ctx context.Context, req *v1.TenantOrgUpdateReq) (*v1.TenantOrgUpdateRes, error)
// TransferOwnership transfers tenant ownership to another member.
TransferOwnership(ctx context.Context, req *v1.TenantOrgTransferReq) (*v1.TenantOrgTransferRes, error)
+15
View File
@@ -40,6 +40,11 @@ func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) {
case constant.RelayModeEmbeddings:
return baseURL + "/compatible-mode/v1/embeddings", nil
case constant.RelayModeImagesGenerations:
// qwen-image-2.x 等同步 multimodal 模型走 multimodal-generation 端点;
// 其余(wanx*、qwen-image 等异步族)用 image-synthesis(异步族一般已被 gate 引导到异步端点)。
if isMultimodalImageMode(info) {
return baseURL + "/api/v1/services/aigc/multimodal-generation/generation", nil
}
return baseURL + "/api/v1/services/aigc/text2image/image-synthesis", nil
default:
return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode)
@@ -68,6 +73,11 @@ func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, re
return convertClaudeRequest(requestBody, info)
}
// 同步 multimodal 图片(qwen-image-2.x):OpenAI Images 请求体 → DashScope messages 格式
if isMultimodalImageMode(info) {
return convertMultimodalImageRequest(requestBody, info)
}
// 非 OpenAI 格式先转换为 OpenAI
if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI {
c, err := openai.ConvertToOpenAI(requestBody, info)
@@ -129,6 +139,11 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request
// DoResponse 处理上游响应。
// Claude 入站委托 claude.Adaptor 原生直通;其他格式委托 openai.Adaptor。
func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) {
// 同步 multimodal 图片(qwen-image-2.x):DashScope multimodal 响应 → OpenAI Images 响应
if isMultimodalImageMode(info) {
return handleMultimodalImageResponse(resp, writer)
}
if info.GetOriginalClientFormat() == constant.RelayFormatClaude {
delegate := &claude.Adaptor{}
delegate.Init(info)
+169
View File
@@ -0,0 +1,169 @@
package ali
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/qianfree/team-api/relay/common"
"github.com/qianfree/team-api/relay/constant"
)
// 阿里云 DashScope「同步 multimodal」图片生成(qwen-image-2.x 系列)。
//
// 与异步 wanx / qwen-image image-synthesis 端点不同,qwen-image-2.0 仅支持同步调用,走
// /api/v1/services/aigc/multimodal-generation/generation,请求体为 messages 格式、响应从
// output.choices[].message.content[].image 取图片 URL。这里做 OpenAI Images ↔ DashScope
// multimodal 的双向转换,使其能经同步 /v1/images/generations 一次性返回。
// isMultimodalImageMode 判断当前请求是否为阿里云同步 multimodal 图片生成。
func isMultimodalImageMode(info *common.RelayInfo) bool {
return constant.RelayMode(info.RelayMode) == constant.RelayModeImagesGenerations &&
constant.IsAliSyncMultimodalImageModel(info.ChannelMeta.UpstreamModelName)
}
// ==================== 请求/响应结构体 ====================
type multimodalImageContent struct {
Text string `json:"text,omitempty"`
Image string `json:"image,omitempty"`
}
type multimodalImageMessage struct {
Role string `json:"role"`
Content []multimodalImageContent `json:"content"`
}
type multimodalImageInput struct {
Messages []multimodalImageMessage `json:"messages"`
}
type multimodalImageRequest struct {
Model string `json:"model"`
Input multimodalImageInput `json:"input"`
Parameters map[string]any `json:"parameters,omitempty"`
}
type multimodalImageResponse struct {
Output struct {
Choices []struct {
Message struct {
Content []multimodalImageContent `json:"content"`
} `json:"message"`
} `json:"choices"`
} `json:"output"`
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
}
// ==================== 转换实现 ====================
// convertMultimodalImageRequest 将 OpenAI Images 请求转换为 DashScope multimodal 请求体。
func convertMultimodalImageRequest(requestBody []byte, info *common.RelayInfo) (io.Reader, error) {
var req map[string]any
if err := json.Unmarshal(requestBody, &req); err != nil {
return nil, fmt.Errorf("parse image request: %w", err)
}
prompt, _ := req["prompt"].(string)
mm := multimodalImageRequest{
Model: info.ChannelMeta.UpstreamModelName,
Input: multimodalImageInput{
Messages: []multimodalImageMessage{
{Role: "user", Content: []multimodalImageContent{{Text: prompt}}},
},
},
Parameters: map[string]any{},
}
// size: 1024x1024 → 1024*1024
if v, ok := req["size"].(string); ok && v != "" {
mm.Parameters["size"] = strings.ReplaceAll(v, "x", "*")
}
if v, ok := req["n"]; ok {
mm.Parameters["n"] = v
}
if v, ok := req["negative_prompt"].(string); ok && v != "" {
mm.Parameters["negative_prompt"] = v
}
if v, ok := req["seed"]; ok {
mm.Parameters["seed"] = v
}
if v, ok := req["prompt_extend"]; ok {
mm.Parameters["prompt_extend"] = v
}
if v, ok := req["watermark"]; ok {
mm.Parameters["watermark"] = v
}
if len(mm.Parameters) == 0 {
mm.Parameters = nil
}
data, err := json.Marshal(mm)
if err != nil {
return nil, fmt.Errorf("marshal multimodal image request: %w", err)
}
return bytes.NewReader(data), nil
}
// handleMultimodalImageResponse 将 DashScope multimodal 响应转换为 OpenAI Images 响应写回客户端。
func handleMultimodalImageResponse(resp *http.Response, writer http.ResponseWriter) (*common.Usage, error) {
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, constant.NewUpstreamError(resp.StatusCode, "read response body failed", err)
}
if resp.StatusCode != http.StatusOK {
var errResp multimodalImageResponse
msg := string(body)
if json.Unmarshal(body, &errResp) == nil && errResp.Message != "" {
msg = errResp.Message
}
return nil, constant.NewUpstreamError(resp.StatusCode, msg, nil)
}
var mmResp multimodalImageResponse
if err := json.Unmarshal(body, &mmResp); err != nil {
return nil, constant.NewUpstreamError(resp.StatusCode, "parse response failed", err)
}
// 200 但业务错误(顶层 code/message)
if mmResp.Code != "" {
return nil, constant.NewUpstreamError(resp.StatusCode, mmResp.Message, nil)
}
// 收集所有 choices 里的图片 URL
data := make([]map[string]any, 0)
for _, ch := range mmResp.Output.Choices {
for _, c := range ch.Message.Content {
if c.Image != "" {
data = append(data, map[string]any{"url": c.Image})
}
}
}
if len(data) == 0 {
return nil, constant.NewUpstreamError(resp.StatusCode, "upstream returned no image", nil)
}
out := map[string]any{
"created": time.Now().Unix(),
"data": data,
}
outBody, err := json.Marshal(out)
if err != nil {
return nil, constant.NewUpstreamError(http.StatusInternalServerError, "marshal image response failed", err)
}
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(outBody)
// 图片按次/张计费,token 用量为空(与 OpenAI dall-e 处理一致)
return &common.Usage{}, nil
}
+1
View File
@@ -120,6 +120,7 @@ type ChannelSelection struct {
ApiKey string // 解密后的上游 API Key
UpstreamModelName string
IsModelMapped bool
MaxConcurrency int // 该渠道最大并发(0/负值表示不限),供 sync_image worker 做 per-channel 容量控制
Settings ChannelSettings
}
+44
View File
@@ -1,7 +1,11 @@
package constant
import (
"context"
"errors"
"io"
"net"
"syscall"
"github.com/gogf/gf/v2/errors/gerror"
)
@@ -113,5 +117,45 @@ func IsRetryable(err error) bool {
return false
}
}
// 非 RelayError:检查是否为连接层 / 网络层瞬时错误(请求未获得任何 HTTP 响应)
return isTransientNetworkError(err)
}
// isTransientNetworkError 判断是否为可重试的连接层 / 网络层瞬时错误。
// 这类错误发生时请求未获得任何 HTTP 响应(连接被对端提前关闭、重置、超时或拒绝),
// 重试通常能命中健康渠道或新建连接成功,是中转网关最应重试 / 故障转移的场景。
// 适配器(如 Gemini DoRequest)把 client.Do 的这类错误包成普通 error(非 *RelayError),
// 因此需要在这里显式识别,否则会被 IsRetryable 判为不可重试而直接失败。
func isTransientNetworkError(err error) bool {
if err == nil {
return false
}
// 客户端主动断开(context 已取消):响应无处可回,重试无意义且浪费上游调用
if errors.Is(err, context.Canceled) {
return false
}
// 连接被对端提前关闭(GFW 干扰 / 上游瞬断的典型表现)
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return true
}
// 连接被重置 / 拒绝
if errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.ECONNREFUSED) {
return true
}
// 请求 / 握手超时(i/o timeout、TLS handshake timeout、Client.Timeout 等)
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return true
}
// http.Client 超时也会以 context.DeadlineExceeded 形式冒泡
if errors.Is(err, context.DeadlineExceeded) {
return true
}
return false
}
+49
View File
@@ -1,5 +1,7 @@
package constant
import "strings"
// TaskPlatform 异步任务平台类型
type TaskPlatform string
@@ -11,6 +13,11 @@ const (
TaskPlatformVolcengine TaskPlatform = "volcengine"
TaskPlatformAli TaskPlatform = "ali"
TaskPlatformGemini TaskPlatform = "gemini"
// TaskPlatformSyncImage 同步图片厂商异步化专用平台标记。
// 注意:故意不加入 ProviderTypeToTaskPlatform —— 它由 sync_image worker 池执行,
// 无上游任务 ID 可轮询,polling 轮询分支需跳过它。
TaskPlatformSyncImage TaskPlatform = "sync_image"
)
// TaskAction 异步任务动作类型
@@ -92,6 +99,48 @@ func ProviderTypeToTaskPlatform(p ProviderType) (TaskPlatform, bool) {
}
}
// IsAsyncImageModel 判断某 provider + 模型的图片生成(RelayModeImagesGenerations)是否为
// 异步任务式(提交拿 task_id → 轮询取图)。此类模型的同步 /v1/images/generations 端点无法
// 一次性返回,必须走 /v1/images/generations/async 提交 + 轮询。
//
// 这是「图片必须走异步」的唯一判定源:同步端点拦截 gate(relay_handler)与租户模型列表的
// async_image 标记都调用它,保证在线体验示例与后端实际行为一致。
//
// 目前仅阿里云 DashScope 图片模型分两族:
// - 异步族(wanx*、qwen-image、qwen-image-plus、flux* 等):text2image/image-synthesis + 轮询
// - 同步 multimodal 族(qwen-image-2.x 系列):multimodal-generation/generation,一次性同步返回
//
// 故对 Ali 默认异步,qwen-image-2.x 系列除外。其他 provider 图片均为同步(含 Gemini Imagen)。
func IsAsyncImageModel(p ProviderType, model string) bool {
if p != ProviderAli {
return false
}
return !IsAliSyncMultimodalImageModel(model)
}
// IsAliSyncMultimodalImageModel 判断是否为阿里云 DashScope「multimodal messages」协议图片模型。
// 这类模型走同步 /api/v1/services/aigc/multimodal-generation/generation 端点,请求体为
// input.messages 格式、响应从 output.choices[].message.content[].image 取图;不走旧版
// text2image/image-synthesis(input.prompt)端点。本项目对它们统一用同步 multimodal 处理
// (同步端点直出 / 异步端点交 sync_image worker 池同步执行)。目前包含:
// - qwen-image-2.x 系列(qwen-image-2.0 / qwen-image-2.0-pro 等)
// - z-image 系列(z-image-turbo 等)
// - 万相新版协议图片模型:wan2.6-t2i、wan2.7-image / wan2.7-image-pro
//
// 注意:万相旧版图片模型(wan2.5-t2i-preview、wan2.2-t2i-*、wanx2.x-t2i-*)走旧版
// image-synthesis(input.prompt)异步协议,不在此列,由 taskchannel 的 buildImageRequest 处理。
//
// 该判定是「Ali multimodal 图片」的唯一真相源——同步端点拦截 gate、canPassThrough、
// isMultimodalImageMode、异步端点 worker 池路由、租户模型列表 async_image 标记均调用它,
// 改这一处即全链路生效。
func IsAliSyncMultimodalImageModel(model string) bool {
m := strings.ToLower(model)
return strings.HasPrefix(m, "qwen-image-2") ||
strings.HasPrefix(m, "z-image") ||
strings.HasPrefix(m, "wan2.6-t2i") ||
strings.HasPrefix(m, "wan2.7-image")
}
// RelayModeToTaskAction 将 RelayMode 映射到异步任务动作
func RelayModeToTaskAction(mode RelayMode) (TaskAction, bool) {
switch mode {
+4 -2
View File
@@ -177,8 +177,10 @@ func WriteRelayError(w http.ResponseWriter, err error) {
statusCode = http.StatusInternalServerError
}
if statusCode >= 500 {
g.Log().Errorf(context.Background(), "[RelayError] statusCode=%d type=%s message=%s originalError=%v",
// 无可用渠道是正常业务条件,已在 handleChannelUnavailable 中以 Warning 记录,此处跳过避免重复日志;
// 其余 5xx 为真实错误,保留 ERROR 但禁用堆栈打印(此处调用栈固定,无调试价值)
if statusCode >= 500 && !errors.Is(err, common.ErrChannelUnavailable) {
g.Log().Stack(false).Errorf(context.Background(), "[RelayError] statusCode=%d type=%s message=%s originalError=%v",
statusCode, errType, errMsg, err)
}
+4 -2
View File
@@ -54,8 +54,10 @@ func WriteClaudeRelayError(w http.ResponseWriter, err error) {
statusCode = http.StatusInternalServerError
}
if statusCode >= 500 {
g.Log().Errorf(context.Background(), "[ClaudeRelayError] statusCode=%d type=%s message=%s originalError=%v",
// 无可用渠道是正常业务条件,已在 handleChannelUnavailable 中以 Warning 记录,此处跳过避免重复日志;
// 其余 5xx 为真实错误,保留 ERROR 但禁用堆栈打印(此处调用栈固定,无调试价值)
if statusCode >= 500 && !errors.Is(err, common.ErrChannelUnavailable) {
g.Log().Stack(false).Errorf(context.Background(), "[ClaudeRelayError] statusCode=%d type=%s message=%s originalError=%v",
statusCode, errType, errMsg, err)
}
+4 -2
View File
@@ -101,8 +101,10 @@ func WriteGeminiRelayError(w http.ResponseWriter, err error) {
statusCode = http.StatusInternalServerError
}
if statusCode >= 500 {
g.Log().Errorf(context.Background(), "[GeminiRelayError] statusCode=%d status=%s message=%s originalError=%v",
// 无可用渠道是正常业务条件,已在 handleChannelUnavailable 中以 Warning 记录,此处跳过避免重复日志;
// 其余 5xx 为真实错误,保留 ERROR 但禁用堆栈打印(此处调用栈固定,无调试价值)
if statusCode >= 500 && !errors.Is(err, common.ErrChannelUnavailable) {
g.Log().Stack(false).Errorf(context.Background(), "[GeminiRelayError] statusCode=%d status=%s message=%s originalError=%v",
statusCode, errStatus, errMsg, err)
}
+8
View File
@@ -9,6 +9,14 @@ import (
func canPassThrough(info *common.RelayInfo) bool {
settings := info.ChannelMeta.Settings
// 阿里云同步 multimodal 图片(qwen-image-2.x):入站 OpenAI Images 格式与上游 DashScope
// multimodal-generation 的 messages 结构不兼容,必须经 ConvertRequest 转换。即使运营者
// 显式开启直连、或未配置模型映射,也不能原样透传,否则上游因请求体格式错误报错。
if constant.RelayMode(info.RelayMode) == constant.RelayModeImagesGenerations &&
constant.IsAliSyncMultimodalImageModel(info.ChannelMeta.UpstreamModelName) {
return false
}
// 显式开启:运营者明确配置直连,不做额外检查
if settings.PassThroughBodyEnabled {
return true
+19 -2
View File
@@ -420,6 +420,20 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
return result.usage, result.billingResult, result.err
}
// 阿里云百炼(DashScope)异步图片模型(wanx*、qwen-image-plus 等)上游为异步任务式
// (提交拿 task_id → 轮询取图),其 image-synthesis 端点强制异步,无法经同步
// /v1/images/generations 一次性返回图片。命中时直接引导客户端改用异步端点。
// 判定收敛到 constant.IsAsyncImageModel(按 provider + 模型),qwen-image-2.x 等
// 同步 multimodal 模型不在此列,会继续走下方同步转发。租户模型列表 async_image 标记同源。
if v.relayMode == constant.RelayModeImagesGenerations &&
constant.IsAsyncImageModel(constant.ProviderType(selection.ChannelType), selection.UpstreamModelName) {
if billing != nil && preDeductAmount > 0 {
_ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount)
}
return nil, v.billingResult, constant.NewRequestError(
"this image model uses asynchronous generation on Alibaba DashScope; submit via POST /v1/images/generations/async and poll for the result", nil)
}
info := buildRelayInfo(ctx, rc, v, selection, path, headers)
if tr := monitor.GetTrackedRequest(rc.RequestID); tr != nil {
@@ -675,7 +689,8 @@ func handleChannelUnavailable(
if billing != nil && preDeductAmount > 0 {
_ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount)
}
g.Log().Errorf(ctx, "[RelayHandler] All %d channels failed for model=%s tenant=%d user=%d request=%s. Failure details: %s",
// 全部渠道失败是真实运营告警,保留 ERROR 级别;但调用栈固定无意义,禁用堆栈打印
g.Log().Stack(false).Errorf(ctx, "[RelayHandler] All %d channels failed for model=%s tenant=%d user=%d request=%s. Failure details: %s",
len(channelErrors), v.modelName, rc.TenantID, rc.UserID, rc.RequestID, strings.Join(channelErrors, "\n"))
allFailedErr := constant.NewChannelError(
fmt.Sprintf("all %d channels failed for model: %s", len(channelErrors), v.modelName),
@@ -688,7 +703,9 @@ func handleChannelUnavailable(
if billing != nil && preDeductAmount > 0 {
_ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount)
}
g.Log().Errorf(ctx, "[RelayHandler] No available channel for model=%s tenant=%d user=%d", v.modelName, rc.TenantID, rc.UserID)
// 无可用渠道属于正常业务条件(用户请求了未配置/未启用渠道的模型),
// 降级为 Warning 并禁用堆栈打印,避免污染 ERROR 日志与刷屏无意义的调用栈
g.Log().Stack(false).Warningf(ctx, "[RelayHandler] No available channel for model=%s tenant=%d user=%d", v.modelName, rc.TenantID, rc.UserID)
noChErr := constant.NewChannelError("no available channel for model: "+v.modelName, err)
recordFailedUsage(provider, rc, 0, v.modelName, v.relayMode, v.isStream, noChErr)
return &channelUnavailableResult{nil, v.billingResult, noChErr}
+8 -2
View File
@@ -118,9 +118,15 @@ func (a *AliAdaptor) Init(info *common.RelayInfo) {
a.info = info
}
// detectVideo 根据模型名判断是否为视频生成
// detectVideo 根据模型名判断是否为视频生成。
// 注意:万相图片模型(-t2i 文生图、-i2i 图像编辑)即使以 wan2. 开头也不是视频,必须先排除,
// 否则会被误判为视频而 POST 到 video-synthesis 端点,被上游以 "url error" 拒绝。
func (a *AliAdaptor) detectVideo(modelName string) bool {
return strings.HasPrefix(modelName, "wan2.") || strings.HasPrefix(modelName, "wanx2.1-t2v")
m := strings.ToLower(modelName)
if strings.Contains(m, "-t2i") || strings.Contains(m, "-i2i") {
return false
}
return strings.HasPrefix(m, "wan2.") || strings.HasPrefix(m, "wanx2.1-t2v")
}
func (a *AliAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, body []byte) *common.TaskError {
+6
View File
@@ -216,6 +216,12 @@ const adminRoutes: RouteRecordRaw[] = [
component: () => import('@/views/system/SettingsPage.vue'),
meta: { title: '系统设置' },
},
{
path: 'files',
name: 'AdminFiles',
component: () => import('@/views/system/FilesPage.vue'),
meta: { title: '文件管理' },
},
{
path: 'help-categories',
name: 'AdminHelpCategories',
+1
View File
@@ -355,6 +355,7 @@ const menuGroups = [
{ name: 'AdminUsers', label: '用户管理', icon: IconUserGroup },
{ name: 'AdminPlugins', label: '插件管理', icon: IconCodeBlock },
{ name: 'AdminSettings', label: '系统设置', icon: IconSettings },
{ name: 'AdminFiles', label: '文件管理', icon: IconStorage },
{ name: 'AdminPaymentSettings', label: '支付设置', icon: IconStorage },
{ name: 'AdminHelpCategories', label: '帮助分类', icon: IconLayers },
{ name: 'AdminHelpArticles', label: '帮助文章', icon: IconFile },
+34 -1
View File
@@ -102,9 +102,18 @@ const columns: TableColumnData[] = [
},
{ title: '创建时间', dataIndex: 'created_at', width: 170 },
{
title: '操作', dataIndex: 'actions', width: 150, fixed: 'right',
title: '操作', dataIndex: 'actions', width: 210, fixed: 'right',
render({ record }) {
const isActive = record.status === 'active'
const action = isActive ? '禁用' : '启用'
return h(Space, { size: 4 }, () => [
h(Popconfirm, {
content: `确定${action}该渠道?`,
onOk: () => toggleStatus(record),
}, () => h(Button, {
size: 'small',
status: isActive ? 'normal' : 'success',
}, () => action)),
h(Button, { size: 'small', type: 'primary', onClick: () => openDetail(record) }, () => '详情'),
h(Popconfirm, { content: '确定删除该渠道?此操作不可撤销。', onOk: () => deleteChannel(record) }, () => h(Button, { size: 'small', status: 'danger' }, () => '删除')),
])
@@ -195,6 +204,30 @@ async function deleteChannel(row: any) {
catch { /* interceptor handles error toast */ }
}
// 快捷启用/禁用渠道:复用 PUT /admin/channels/{id}。
// 后端 UpdateChannel 会无条件写入 priority/weight,故必须带上当前值,否则会被重置为 0。
// status 为 active → 禁用;其它(disabled/testing)→ 启用为 active。
async function toggleStatus(record: any) {
if (record._toggling) return
const isActive = record.status === 'active'
const nextStatus = isActive ? 'disabled' : 'active'
const prevStatus = record.status
record._toggling = true
record.status = nextStatus // 乐观更新,状态 Tag 即刻变化
try {
await request.put(`/admin/channels/${record.id}`, {
status: nextStatus,
priority: record.priority,
weight: record.weight,
})
message.success(nextStatus === 'active' ? '已启用' : '已禁用')
} catch {
record.status = prevStatus // 失败回滚,错误提示由 request 拦截器统一处理
} finally {
record._toggling = false
}
}
onMounted(fetchData)
// === OAuth ===
@@ -7,7 +7,7 @@ import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/compon
import VChart from 'vue-echarts'
import PageHeader from '@/components/PageHeader.vue'
import request from '@/utils/request'
import { IconThunderbolt, IconSwap, IconClockCircle, IconCode, IconBarChart, IconLayers, IconApps, IconUserGroup, IconFire } from '@arco-design/web-vue/es/icon'
import { IconThunderbolt, IconSwap, IconClockCircle, IconCode, IconBarChart, IconLayers, IconApps, IconUserGroup, IconFire, IconStorage } from '@arco-design/web-vue/es/icon'
use([CanvasRenderer, LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent])
@@ -166,6 +166,28 @@ const byChannel = computed(() => {
return entries.map(([k, v]) => ({ channel: k, count: v as number, pct: ((v as number) / max) * 100 }))
})
// 同步图片「异步化」Worker 池状态
const pool = computed(() => data.value.sync_image_pool || {})
const workerPct = computed(() => {
const t = pool.value.worker_total || 0
return t > 0 ? Math.min(100, ((pool.value.worker_busy || 0) / t) * 100) : 0
})
const queuePct = computed(() => {
const c = pool.value.queue_cap || 0
return c > 0 ? Math.min(100, ((pool.value.queue_depth || 0) / c) * 100) : 0
})
// 队列水位配色:>95% 红(即将背压 429)、>80% 橙、否则蓝
const queueColor = computed(() => {
const p = queuePct.value
return p > 95 ? '#F53F3F' : p > 80 ? '#FF7D00' : '#165DFF'
})
const poolChannelInflight = computed(() => {
const m: Record<string, number> = pool.value.channel_inflight || {}
const entries = Object.entries(m).sort(([, a], [, b]) => (b as number) - (a as number)).slice(0, 10)
const max = entries.length > 0 ? (entries[0][1] as number) : 1
return entries.map(([k, v]) => ({ channel: k, count: v as number, pct: ((v as number) / max) * 100 }))
})
let pollTimer: ReturnType<typeof setInterval> | null = null
let resizeTimer: ReturnType<typeof setTimeout> | null = null
@@ -354,6 +376,66 @@ onUnmounted(() => {
</a-card>
</div>
<!-- Sync-Image Worker Pool -->
<a-card v-if="pool.enabled" :bordered="false" class="mb-16">
<template #title><span class="card-title"><IconStorage :size="16" style="color:#722ED1" /> 同步图片 Worker 池</span></template>
<template #extra>
<a-tag color="arcoblue" size="small">同步转异步</a-tag>
</template>
<div class="pool-grid">
<!-- 利用率 / 水位进度条 -->
<div class="pool-bars">
<div class="pool-bar-block">
<div class="pool-bar-label">
<span>Worker 利用率</span>
<span class="mono">{{ pool.worker_busy || 0 }} / {{ pool.worker_total || 0 }} 忙碌</span>
</div>
<a-progress :percent="workerPct / 100" :stroke-width="10" :show-text="false" color="#165DFF" />
</div>
<div class="pool-bar-block">
<div class="pool-bar-label">
<span>队列水位</span>
<span class="mono">{{ pool.queue_depth || 0 }} / {{ pool.queue_cap || 0 }}</span>
</div>
<a-progress :percent="queuePct / 100" :stroke-width="10" :show-text="false" :color="queueColor" />
</div>
</div>
<!-- 累计计数 -->
<div class="pool-stats">
<div class="pool-stat">
<span class="pool-stat-value">{{ (pool.enqueued || 0).toLocaleString() }}</span>
<span class="pool-stat-label">已入队</span>
</div>
<div class="pool-stat">
<span class="pool-stat-value" style="color:#00B42A">{{ (pool.succeeded || 0).toLocaleString() }}</span>
<span class="pool-stat-label">成功</span>
</div>
<div class="pool-stat">
<span class="pool-stat-value" style="color:#F53F3F">{{ (pool.failed || 0).toLocaleString() }}</span>
<span class="pool-stat-label">失败</span>
</div>
<div class="pool-stat">
<span class="pool-stat-value" :style="{ color: (pool.rejected || 0) > 0 ? '#F53F3F' : undefined }">{{ (pool.rejected || 0).toLocaleString() }}</span>
<span class="pool-stat-label">拒绝 (429)</span>
</div>
</div>
</div>
<!-- 按渠道在途 -->
<div class="pool-inflight">
<div class="pool-inflight-title">按渠道在途(层② per-channel 容量)</div>
<div v-if="poolChannelInflight.length === 0" class="empty-hint">暂无在途任务</div>
<div v-for="item in poolChannelInflight" :key="item.channel" class="dist-row">
<div class="dist-label">
<span class="dist-name" :title="'渠道 ' + item.channel">渠道 {{ item.channel }}</span>
<span class="dist-count">{{ item.count }}</span>
</div>
<div class="dist-bar-track">
<div class="dist-bar-fill" style="background:#722ED1" :style="{ width: item.pct + '%' }" />
</div>
</div>
</div>
</a-card>
<!-- Distribution -->
<div class="row row-2 mb-16">
<a-card :bordered="false">
@@ -639,6 +721,59 @@ onUnmounted(() => {
font-weight: 500;
}
/* Sync-image worker pool */
.pool-grid {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 24px;
align-items: center;
}
.pool-bars {
display: flex;
flex-direction: column;
gap: 14px;
}
.pool-bar-label {
display: flex;
justify-content: space-between;
font-size: 13px;
color: var(--color-text-2);
margin-bottom: 6px;
}
.pool-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
padding-left: 24px;
border-left: 1px solid var(--color-border);
}
.pool-stat {
text-align: center;
}
.pool-stat-value {
display: block;
font-size: 20px;
font-weight: 600;
color: var(--color-text-1);
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
}
.pool-stat-label {
display: block;
font-size: 11px;
color: var(--color-text-3);
margin-top: 2px;
}
.pool-inflight {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--color-border);
}
.pool-inflight-title {
font-size: 12px;
color: var(--color-text-3);
margin-bottom: 10px;
}
/* Distribution bars */
.dist-row {
margin-bottom: 10px;
@@ -698,6 +833,8 @@ onUnmounted(() => {
}
@media (max-width: 768px) {
.row-2 { grid-template-columns: 1fr; }
.pool-grid { grid-template-columns: 1fr; }
.pool-stats { padding-left: 0; border-left: none; }
}
@media (max-width: 480px) {
.metrics-grid {
+544
View File
@@ -0,0 +1,544 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, h } from 'vue'
import { Tag, Button, Modal, Message } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue'
import {
IconFile, IconImage, IconArchive, IconStorage, IconSettings,
} from '@arco-design/web-vue/es/icon'
import PageHeader from '@/components/PageHeader.vue'
import request from '@/utils/request'
// ============================================================
// Helpers
// ============================================================
function formatNumber(val: any) {
const n = Number(val)
if (!n && n !== 0) return '0'
return n.toLocaleString()
}
function formatBytes(val: any) {
const n = Number(val)
if (!n || n < 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
let i = 0
let size = n
while (size >= 1024 && i < units.length - 1) {
size /= 1024
i++
}
return `${size.toFixed(i === 0 ? 0 : 2)} ${units[i]}`
}
const CATEGORY_META: Record<string, { label: string; color: string }> = {
image: { label: '图片', color: 'arcoblue' },
export: { label: '导出', color: 'orange' },
other: { label: '其它', color: 'gray' },
}
// ============================================================
// Stats (KPI)
// ============================================================
const statsLoading = ref(false)
const stats = ref<any>({ total_count: 0, total_bytes: 0, by_category: [], by_provider: [], top_tenants: [] })
function categoryStat(cat: string) {
return (stats.value.by_category || []).find((c: any) => c.category === cat) || { count: 0, bytes: 0 }
}
const metricCards = computed(() => [
{
label: '文件总数', value: formatNumber(stats.value.total_count),
icon: IconFile, gradient: 'linear-gradient(135deg,#6366f1,#8b5cf6)',
},
{
label: '总占用', value: formatBytes(stats.value.total_bytes),
icon: IconStorage, gradient: 'linear-gradient(135deg,#0ea5e9,#22d3ee)',
},
{
label: 'AI 图片', value: `${formatNumber(categoryStat('image').count)} · ${formatBytes(categoryStat('image').bytes)}`,
icon: IconImage, gradient: 'linear-gradient(135deg,#10b981,#34d399)',
},
{
label: '导出文件', value: `${formatNumber(categoryStat('export').count)} · ${formatBytes(categoryStat('export').bytes)}`,
icon: IconArchive, gradient: 'linear-gradient(135deg,#f59e0b,#fbbf24)',
},
])
async function fetchStats() {
statsLoading.value = true
try {
const res: any = await request.get('/admin/files/stats', { params: { top_n: 10 } })
stats.value = res.data?.data || stats.value
} catch {
// interceptor already toasts
} finally {
statsLoading.value = false
}
}
// ============================================================
// File list
// ============================================================
const loading = ref(false)
const data = ref<any[]>([])
const pagination = reactive({
current: 1, pageSize: 20, total: 0, showPageSize: true, pageSizeOptions: [10, 20, 50],
})
const filter = reactive({
tenant_id: '',
user_id: '',
provider: '',
category: '',
keyword: '',
date_range: [] as string[],
})
const providerOptions = [
{ label: '全部供应商', value: '' },
{ label: 'AWS S3', value: 's3' },
{ label: 'MinIO', value: 'minio' },
{ label: 'Cloudflare R2', value: 'r2' },
{ label: '阿里云 OSS', value: 'oss' },
{ label: '腾讯云 COS', value: 'cos' },
]
const categoryOptions = [
{ label: '全部分类', value: '' },
{ label: '图片', value: 'image' },
{ label: '导出', value: 'export' },
{ label: '其它', value: 'other' },
]
const columns: TableColumnData[] = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: '租户', dataIndex: 'tenant_id', width: 80 },
{ title: '上传者', dataIndex: 'user_id', width: 80 },
{ title: '文件名', dataIndex: 'original_name', ellipsis: true, tooltip: true, minWidth: 180 },
{
title: '分类', dataIndex: 'category', width: 80,
render({ record }) {
const m = CATEGORY_META[record.category] || CATEGORY_META.other
return h(Tag, { color: m.color, size: 'small' }, () => m.label)
},
},
{ title: 'MIME', dataIndex: 'mime_type', width: 140, ellipsis: true },
{
title: '大小', dataIndex: 'size', width: 100,
render({ record }) { return formatBytes(record.size) },
},
{ title: '供应商', dataIndex: 'storage_provider', width: 90 },
{ title: '创建时间', dataIndex: 'created_at', width: 170 },
{
title: '操作', dataIndex: 'actions', width: 130, fixed: 'right',
render({ record }) {
const first = isImage(record)
? h(Button, { size: 'mini', type: 'text', onClick: () => openPreview(record) }, () => '预览')
: h(Button, { size: 'mini', type: 'text', onClick: () => downloadOriginal(record) }, () => '下载')
return h('div', { style: 'display:flex;gap:4px' }, [
first,
h(Button, { size: 'mini', type: 'text', status: 'danger', onClick: () => deleteFile(record) }, () => '删除'),
])
},
},
]
async function fetchList() {
loading.value = true
try {
const params: Record<string, any> = {
page: pagination.current,
page_size: pagination.pageSize,
}
if (filter.tenant_id) params.tenant_id = parseInt(filter.tenant_id)
if (filter.user_id) params.user_id = parseInt(filter.user_id)
if (filter.provider) params.provider = filter.provider
if (filter.category) params.category = filter.category
if (filter.keyword) params.keyword = filter.keyword.trim()
if (filter.date_range.length === 2) {
params.start_date = filter.date_range[0]
params.end_date = filter.date_range[1]
}
const res: any = await request.get('/admin/files', { params })
const raw = res.data?.data
data.value = (raw?.list || []).filter(Boolean)
pagination.total = Number(raw?.total) || 0
} catch {
data.value = []
pagination.total = 0
} finally {
loading.value = false
}
}
function search() {
pagination.current = 1
fetchList()
}
function resetFilter() {
filter.tenant_id = ''
filter.user_id = ''
filter.provider = ''
filter.category = ''
filter.keyword = ''
filter.date_range = []
search()
}
// ============================================================
// 预览 / 下载
// ============================================================
const previewVisible = ref(false)
const previewLoading = ref(false)
const previewRecord = ref<any>(null)
const previewThumbUrl = ref('')
const previewError = ref(false)
function isImage(record: any) {
return record?.category === 'image' || String(record?.mime_type || '').startsWith('image/')
}
// 预览图片:弹窗内展示服务端缩略图(小图,加载快),原图仅在点「下载原图」时请求。
async function openPreview(record: any) {
previewRecord.value = record
previewThumbUrl.value = ''
previewError.value = false
previewVisible.value = true
previewLoading.value = true
try {
const res: any = await request.get(`/admin/files/${record.id}/download`, {
params: { variant: 'thumb', width: 600 },
})
previewThumbUrl.value = res.data?.data?.url || ''
if (!previewThumbUrl.value) Message.warning('未获取到预览链接')
} catch {
// interceptor toasts
} finally {
previewLoading.value = false
}
}
// 下载/打开原图:请求原图签名链接并在新标签打开。
async function downloadOriginal(record: any) {
if (!record) return
try {
const res: any = await request.get(`/admin/files/${record.id}/download`)
const url = res.data?.data?.url
if (url) window.open(url, '_blank')
else Message.warning('未获取到下载链接')
} catch {
// interceptor toasts
}
}
function deleteFile(record: any) {
Modal.confirm({
title: '确认删除',
content: `确定删除文件「${record.original_name || record.id}」?该操作会同时删除存储对象,不可恢复。`,
okText: '删除', cancelText: '取消',
onOk: async () => {
try {
await request.delete(`/admin/files/${record.id}`)
Message.success('删除成功')
fetchList()
fetchStats()
} catch {}
},
})
}
// ============================================================
// Retention settings + manual cleanup
// ============================================================
const retentionLoading = ref(false)
const retentionSaving = ref(false)
const cleanupLoading = ref(false)
const retentionEnabled = ref(true)
const imageRetentionDays = ref<number>(0)
async function fetchRetention() {
retentionLoading.value = true
try {
const res: any = await request.get('/admin/settings/data_governance')
const list = res.data?.data?.list || []
for (const item of list) {
if (item.key === 'file_image_retention_days') imageRetentionDays.value = Number(item.value ?? item.default ?? 0)
if (item.key === 'file_retention_enabled') retentionEnabled.value = String(item.value ?? item.default) === 'true'
}
} catch {
} finally {
retentionLoading.value = false
}
}
async function saveRetention() {
retentionSaving.value = true
try {
await request.put('/admin/settings/data_governance', {
settings: {
file_image_retention_days: String(imageRetentionDays.value ?? 0),
file_retention_enabled: String(retentionEnabled.value),
},
})
Message.success('保留策略已保存')
} catch {
} finally {
retentionSaving.value = false
}
}
function manualCleanup() {
Modal.confirm({
title: '手动清理过期文件',
content: '将按当前保留策略立即清理过期的导出文件与 AI 图片(含存储对象)。图片保留天数为 0 时不会清理图片。是否继续?',
okText: '开始清理', cancelText: '取消',
onOk: async () => {
cleanupLoading.value = true
try {
const res: any = await request.post('/admin/files/cleanup')
const r = res.data?.data || {}
Message.success(`清理完成:导出 ${r.exports_deleted || 0} 个,图片 ${r.images_deleted || 0} 个`)
fetchList()
fetchStats()
} catch {
} finally {
cleanupLoading.value = false
}
},
})
}
onMounted(() => {
fetchStats()
fetchList()
fetchRetention()
})
</script>
<template>
<div>
<PageHeader title="文件管理" description="对象存储文件观测与清理">
<template #actions>
<APopover trigger="click" position="br">
<AButton>
<template #icon><IconSettings /></template>
保留策略
</AButton>
<template #content>
<div class="retention-pop">
<div class="retention-pop__row">
<span>启用自动保留期检查</span>
<ASwitch v-model="retentionEnabled" :loading="retentionLoading" />
</div>
<div class="retention-pop__label">AI 图片保留天数</div>
<AInputNumber v-model="imageRetentionDays" :min="0" :max="3650" style="width:100%" placeholder="0=不清理" />
<div class="retention-pop__hint">0 表示不自动删除图片</div>
<AButton type="primary" long :loading="retentionSaving" @click="saveRetention">保存策略</AButton>
</div>
</template>
</APopover>
<AButton :loading="cleanupLoading" @click="manualCleanup">手动清理过期文件</AButton>
</template>
</PageHeader>
<!-- 顶部统计条(紧凑单行) -->
<ASpin :loading="statsLoading" style="display:block">
<div class="stat-strip mb-4">
<div v-for="(stat, idx) in metricCards" :key="idx" class="stat-strip__item">
<div class="stat-strip__icon" :style="{ background: stat.gradient }">
<component :is="stat.icon" :size="16" style="color:#fff" />
</div>
<div class="stat-strip__text">
<div class="stat-strip__label">{{ stat.label }}</div>
<div class="stat-strip__value">{{ stat.value }}</div>
</div>
</div>
</div>
</ASpin>
<!-- 列表 -->
<ACard :bordered="false">
<ASpace wrap style="margin-bottom:16px">
<AInput v-model="filter.tenant_id" placeholder="租户ID" allow-clear style="width:110px" />
<AInput v-model="filter.user_id" placeholder="上传者ID" allow-clear style="width:110px" />
<ASelect v-model="filter.provider" :options="providerOptions" style="width:150px" />
<ASelect v-model="filter.category" :options="categoryOptions" style="width:130px" />
<AInput v-model="filter.keyword" placeholder="文件名 / 路径" allow-clear style="width:200px" @press-enter="search" />
<ARangePicker v-model="filter.date_range" style="width:260px" format="YYYY-MM-DD" />
<AButton type="primary" @click="search">搜索</AButton>
<AButton @click="resetFilter">重置</AButton>
</ASpace>
<ATable
:columns="columns" :data="data" :loading="loading"
:scroll="{ x: 1200 }" :bordered="false" :stripe="true" size="small"
:pagination="false" row-key="id"
/>
<div class="table-footer">
<APagination
v-model:current="pagination.current"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-size-options="pagination.pageSizeOptions"
show-total show-page-size
@change="fetchList"
@page-size-change="(s: number) => { pagination.pageSize = s; pagination.current = 1; fetchList() }"
/>
</div>
</ACard>
<!-- 图片预览弹窗:展示缩略图,下载才取原图 -->
<AModal
v-model:visible="previewVisible"
title="图片预览"
:footer="false"
:width="720"
:body-style="{ padding: '16px' }"
>
<ASpin :loading="previewLoading" style="display:block">
<div class="preview-box">
<img v-if="previewThumbUrl && !previewError" :src="previewThumbUrl" class="preview-img" alt="预览" @error="previewError = true" />
<div v-else-if="previewThumbUrl && previewError" class="preview-error">
<div class="preview-error__icon">😕</div>
<div class="preview-error__text">图片加载失败,可能是过期或者被清理了</div>
</div>
<div v-else class="preview-empty">暂无预览</div>
</div>
</ASpin>
<div v-if="previewRecord" class="preview-meta">
<div class="preview-name" :title="previewRecord.original_name">{{ previewRecord.original_name }}</div>
<div class="preview-sub">{{ formatBytes(previewRecord.size) }} · {{ previewRecord.mime_type }}</div>
</div>
<div class="preview-actions">
<AButton @click="previewVisible = false">关闭</AButton>
<AButton type="primary" @click="downloadOriginal(previewRecord)">下载原图</AButton>
</div>
</AModal>
</div>
</template>
<style scoped>
.stat-strip {
display: flex;
flex-wrap: wrap;
gap: 10px;
background: var(--color-bg-2);
border: 1px solid var(--color-border-2);
border-radius: 8px;
padding: 12px 16px;
}
.stat-strip__item {
display: flex;
align-items: center;
gap: 10px;
flex: 1 1 0;
min-width: 160px;
}
.stat-strip__item + .stat-strip__item {
border-left: 1px solid var(--color-border-2);
padding-left: 16px;
}
.stat-strip__icon {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 8px;
flex-shrink: 0;
}
.stat-strip__text {
min-width: 0;
}
.stat-strip__label {
font-size: 12px;
color: var(--color-text-3);
line-height: 1.2;
}
.stat-strip__value {
font-size: 17px;
font-weight: 700;
color: var(--color-text-1);
letter-spacing: -0.01em;
line-height: 1.3;
white-space: nowrap;
}
.table-footer {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.retention-pop {
width: 260px;
}
.retention-pop__row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.retention-pop__label {
font-size: 13px;
color: var(--color-text-2);
margin-bottom: 8px;
}
.retention-pop__hint {
color: var(--color-text-3);
font-size: 12px;
margin: 6px 0 14px;
}
.preview-box {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
max-height: 60vh;
background: var(--color-fill-2);
border-radius: 8px;
overflow: auto;
}
.preview-img {
max-width: 100%;
max-height: 60vh;
object-fit: contain;
display: block;
}
.preview-error {
text-align: center;
padding: 40px 16px;
}
.preview-error__icon {
font-size: 32px;
margin-bottom: 8px;
}
.preview-error__text {
color: var(--color-text-3);
font-size: 13px;
}
.preview-empty {
color: var(--color-text-3);
font-size: 13px;
}
.preview-meta {
margin-top: 12px;
}
.preview-name {
font-size: 14px;
font-weight: 600;
color: var(--color-text-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-sub {
font-size: 12px;
color: var(--color-text-3);
margin-top: 2px;
}
.preview-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
</style>
@@ -70,6 +70,27 @@ const values = useFormValues()
全局代理 URL,支持 http:// 和 socks5:// 协议。在渠道编辑中开启"使用代理"即可生效。
</div>
</div>
<!-- 同步图片异步化 -->
<div class="section">
<div class="section-title">同步图片异步化</div>
<div class="switch-row">
<span class="switch-label">同步图片厂商异步化</span>
<span class="switch-desc">同步阻塞返回的图片厂商(OpenAI/DALL·E 等)走 /v1/images/generations/async 时改由后台 worker 池异步处理,客户端提交即拿 task_id 后轮询取图;关闭则该端点对同步厂商返回不支持</span>
<ASwitch
:model-value="!!values['sync_image_async_enabled']"
@change="(v: string | number | boolean) => values['sync_image_async_enabled'] = v"
/>
</div>
<div class="switch-row" style="margin-top: 12px">
<span class="switch-label">图片 URL 转存对象存储</span>
<span class="switch-desc">上游返回图片 URL 时下载并转存对象存储(返回 24h 稳定链接,需已配置存储);关闭则直接透传上游 URL(部分厂商约 1h 过期)。b64_json 始终转存</span>
<ASwitch
:model-value="!!values['sync_image_rehost_url']"
@change="(v: string | number | boolean) => values['sync_image_rehost_url'] = v"
/>
</div>
</div>
</div>
</template>
@@ -1,6 +1,17 @@
<script setup lang="ts">
import { watch } from 'vue'
import { useFormValues } from './useSettings'
const values = useFormValues()
// Cloudflare R2 不分区,但 AWS SDK 签名要求非空 region,选中 R2 时自动填 auto
watch(
() => values['storage_provider'],
(provider) => {
if (provider === 'r2' && !values['storage_region']) {
values['storage_region'] = 'auto'
}
}
)
</script>
<template>
@@ -13,6 +24,7 @@ const values = useFormValues()
<ASelect v-model="values['storage_provider']">
<AOption value="minio" label="MinIO" />
<AOption value="s3" label="AWS S3" />
<AOption value="r2" label="Cloudflare R2" />
<AOption value="oss" label="阿里云 OSS" />
<AOption value="cos" label="腾讯云 COS" />
</ASelect>
@@ -21,10 +33,10 @@ const values = useFormValues()
<AInput v-model="values['storage_bucket']" placeholder="my-bucket" />
</AFormItem>
<AFormItem label="存储端点" class="field-full"
help="S3/MinIO: https://s3.amazonaws.com, OSS: https://oss-cn-hangzhou.aliyuncs.com">
help="S3/MinIO: https://s3.amazonaws.com, OSS: https://oss-cn-hangzhou.aliyuncs.com, R2: https://<account_id>.r2.cloudflarestorage.com">
<AInput v-model="values['storage_endpoint']" placeholder="https://s3.amazonaws.com" />
</AFormItem>
<AFormItem label="存储区域">
<AFormItem label="存储区域" help="R2 固定填 auto">
<AInput v-model="values['storage_region']" placeholder="us-east-1" />
</AFormItem>
<AFormItem label="路径前缀" help="存储路径前缀,用于隔离不同环境">
@@ -85,6 +85,7 @@ const icons: Record<string, string> = {
// UI Elements
eye: 'M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178zM15 12a3 3 0 11-6 0 3 3 0 016 0z',
expand: 'M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15',
eyeOff: 'M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88',
key: 'M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z',
lock: 'M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z',
+46
View File
@@ -463,6 +463,52 @@ const categories: Category[] = [
"prompt": "A sunlit indoor lounge area with a pool",
"n": 1,
"size": "1024x1024"
}`,
},
},
{
id: 'images-generations-async-submit',
method: 'POST',
path: '/v1/images/generations/async',
title: 'Image Generations Async (Submit)',
desc: '异步图像生成任务提交。阿里云百炼(DashScope,通义万相/qwen-image 等)图像上游为异步任务式,无法经同步 /v1/images/generations 一次性返回,需通过本接口提交任务拿到 task_id,再轮询查询接口取图。请求体与同步接口一致。',
params: [
{ field: 'model', type: 'string', desc: '模型 ID(如 wanx2.1-t2i-turbo、qwen-image)', required: true },
{ field: 'prompt', type: 'string', desc: '图像描述提示词', required: true },
{ field: 'size', type: 'string', desc: '图像尺寸(如 1024x1024)' },
{ field: 'n', type: 'integer', desc: '生成数量(默认 1)' },
],
example: {
req: `{
"model": "wanx2.1-t2i-turbo",
"prompt": "A white siamese cat wearing sunglasses",
"size": "1024x1024"
}`,
resp: `{
"id": "task_abc123",
"status": "SUBMITTED",
"model": "wanx2.1-t2i-turbo",
"created_at": 1717100000
}`,
},
},
{
id: 'images-generations-async-fetch',
method: 'GET',
path: '/v1/images/generations/async/{task_id}',
title: 'Image Generations Async (Fetch)',
desc: '查询异步图像生成任务结果。轮询本接口直到 status 为 SUCCESS(含图片 url)或 FAILURE(含 error)。建议轮询间隔 2~3 秒。状态流转:SUBMITTED / QUEUED / IN_PROGRESS → SUCCESS / FAILURE。',
params: [
{ field: 'task_id', type: 'string', desc: '任务 ID(路径参数)', required: true },
],
example: {
resp: `{
"id": "task_abc123",
"status": "SUCCESS",
"model": "wanx2.1-t2i-turbo",
"created_at": 1717100000,
"completed_at": 1717100030,
"url": "https://cdn.example.com/img/abc123.png"
}`,
},
},
@@ -18,6 +18,8 @@ interface ModelItem {
per_request_price?: number | null
input_price?: number | null
output_price?: number | null
// 图片模型是否必须走异步端点(DashScope 等)
async_image?: boolean
}
const tabs = [
@@ -12,6 +12,7 @@ interface ModelItem {
const props = defineProps<{ models: ModelItem[]; apiKey: string }>()
const sending = ref(false)
const errorMessage = ref('')
const selectedModel = ref(props.models[0]?.model_id || '')
const inputText = ref('')
const voice = ref('alloy')
@@ -40,6 +41,7 @@ function arrayBufferToBase64(buffer: ArrayBuffer): string {
async function synthesize() {
if (!inputText.value.trim() || !selectedModel.value) return
sending.value = true
errorMessage.value = ''
audioBase64.value = ''
try {
@@ -57,6 +59,7 @@ async function synthesize() {
contentType.value = String(res.headers['content-type'] || 'audio/mpeg')
} catch (e) {
console.error(e)
errorMessage.value = (e as any)?.message || '请求失败,请重试'
} finally {
sending.value = false
}
@@ -100,7 +103,19 @@ async function synthesize() {
<h3 class="text-sm font-semibold text-gray-900">合成结果</h3>
</div>
<div class="card-body">
<div v-if="!audioBase64 && !sending" class="empty-state">
<!-- 错误提示 -->
<div
v-if="errorMessage"
class="mb-4 rounded-xl border border-red-200 bg-red-50 p-4"
>
<div class="flex items-center gap-2 text-red-700">
<Icon name="xCircle" size="sm" />
<span class="text-sm font-medium">合成失败</span>
</div>
<p class="mt-2 text-sm text-red-600">{{ errorMessage }}</p>
</div>
<div v-if="!audioBase64 && !sending && !errorMessage" class="empty-state">
<div class="empty-state-icon"><Icon name="bookOpen" size="xl" /></div>
<h3 class="empty-state-title">等待合成</h3>
<p class="empty-state-description">输入文本并点击合成</p>
@@ -283,11 +283,11 @@ function clearChat() {
</script>
<template>
<div class="h-[calc(100vh-20rem)] flex flex-col overflow-hidden">
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6 min-h-0 flex-1">
<div class="flex flex-col lg:h-[calc(100vh-20rem)] lg:overflow-hidden">
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6 lg:min-h-0 lg:flex-1">
<!-- Left: Parameters -->
<div class="lg:col-span-1">
<div class="card sticky top-6">
<div class="card lg:sticky lg:top-6">
<div class="card-header">
<h3 class="text-sm font-semibold text-gray-900">参数配置</h3>
</div>
@@ -346,7 +346,7 @@ function clearChat() {
</div>
<!-- Right: Chat -->
<div class="lg:col-span-3 min-h-0">
<div class="lg:col-span-3 min-h-0 h-[65vh] lg:h-auto">
<div class="card flex flex-col h-full overflow-hidden">
<div class="px-6 py-2 bg-amber-50 border-b border-amber-200 text-center">
<span class="text-xs text-amber-700 font-medium">Playground 模式 — 使用真实 API Key 调用,产生实际费用</span>
@@ -17,6 +17,7 @@ interface ModelItem {
const props = defineProps<{ models: ModelItem[]; apiKey: string }>()
const sending = ref(false)
const errorMessage = ref('')
const selectedModel = ref(props.models[0]?.model_id || '')
const selectedModelItem = computed(() =>
props.models.find(m => m.model_id === selectedModel.value),
@@ -33,6 +34,7 @@ const tokenUsage = reactive({ promptTokens: 0, totalTokens: 0, cost: '' })
async function embed() {
if (!inputText.value.trim() || !selectedModel.value) return
sending.value = true
errorMessage.value = ''
embeddings.value = []
try {
@@ -55,6 +57,7 @@ async function embed() {
tokenUsage.cost = calculateCost(selectedModelItem.value, usage) || ''
} catch (e) {
console.error(e)
errorMessage.value = (e as any)?.message || '请求失败,请重试'
} finally {
sending.value = false
}
@@ -100,7 +103,19 @@ function formatEmbedding(values: number[], full: boolean) {
<h3 class="text-sm font-semibold text-gray-900">嵌入结果</h3>
</div>
<div class="card-body">
<div v-if="embeddings.length === 0 && !sending" class="empty-state">
<!-- 错误提示 -->
<div
v-if="errorMessage"
class="mb-4 rounded-xl border border-red-200 bg-red-50 p-4"
>
<div class="flex items-center gap-2 text-red-700">
<Icon name="xCircle" size="sm" />
<span class="text-sm font-medium">计算失败</span>
</div>
<p class="mt-2 text-sm text-red-600">{{ errorMessage }}</p>
</div>
<div v-if="embeddings.length === 0 && !sending && !errorMessage" class="empty-state">
<div class="empty-state-icon"><Icon name="bookOpen" size="xl" /></div>
<h3 class="empty-state-title">等待计算</h3>
<p class="empty-state-description">输入文本并点击计算</p>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { ref, reactive, computed, watch, onUnmounted } from 'vue'
import { createPlaygroundApi } from '@/utils/playgroundApi'
import { calculateCost } from './calculateCost'
import ParamRefModal from './ParamRefModal.vue'
@@ -14,10 +14,14 @@ interface ModelItem {
per_request_price?: number | null
input_price?: number | null
output_price?: number | null
// async_image 为 true 时该图片模型必须走异步端点(DashScope 等):
// 提交 /v1/images/generations/async 拿 task_id 后轮询取图。
async_image?: boolean
}
const props = defineProps<{ models: ModelItem[]; apiKey: string }>()
const sending = ref(false)
const errorMessage = ref('')
const selectedModel = ref(props.models[0]?.model_id || '')
const selectedModelItem = computed(() =>
props.models.find(m => m.model_id === selectedModel.value),
@@ -142,65 +146,201 @@ interface ImageResult { b64_json?: string; url?: string; revised_prompt?: string
const images = ref<ImageResult[]>([])
const tokenUsage = reactive({ promptTokens: 0, totalTokens: 0, cost: '' })
// 该模型是否必须走异步端点(DashScope 等):由 /tenant/models 的 async_image 决定,
// 与后端同步端点拦截 gate 同源。异步模型走「提交 + 轮询」,同步模型一次性返回。
const isAsyncModel = computed(() => selectedModelItem.value?.async_image === true)
// 异步任务状态(仅异步模型使用)
interface AsyncTask {
id: string
status: string
progress: string
error?: string
createdAt: number
}
const asyncTask = ref<AsyncTask | null>(null)
const polling = ref(false)
let pollTimer: ReturnType<typeof setTimeout> | null = null
const statusLabel: Record<string, string> = {
SUBMITTED: '已提交',
IN_PROGRESS: '生成中',
NOT_START: '排队中',
QUEUED: '排队中',
SUCCESS: '已完成',
FAILURE: '失败',
}
const statusColor: Record<string, string> = {
SUBMITTED: 'badge-primary',
IN_PROGRESS: 'badge-warning',
NOT_START: 'badge-gray',
QUEUED: 'badge-gray',
SUCCESS: 'badge-success',
FAILURE: 'badge-danger',
}
// 切换模型时停止上一模型的轮询并清空结果
watch(selectedModel, () => {
stopPolling()
asyncTask.value = null
images.value = []
errorMessage.value = ''
})
onUnmounted(() => {
stopPolling()
window.removeEventListener('keydown', onZoomKeydown)
})
// 组装请求体:固定字段 + 自定义参数(同步/异步共用)
function buildBody(): Record<string, any> {
const body: Record<string, any> = {
model: selectedModel.value,
prompt: prompt.value,
}
for (const param of customParams.value) {
const key = param.key.trim()
const val = param.value.trim()
if (key && val) {
body[key] = parseValue(val)
}
}
return body
}
async function generate() {
if (!prompt.value.trim() || !selectedModel.value) return
sending.value = true
errorMessage.value = ''
images.value = []
asyncTask.value = null
stopPolling()
try {
const api = createPlaygroundApi(props.apiKey)
const body = buildBody()
// 组装请求体:固定字段 + 自定义参数
const body: Record<string, any> = {
model: selectedModel.value,
prompt: prompt.value,
}
for (const param of customParams.value) {
const key = param.key.trim()
const val = param.value.trim()
if (key && val) {
body[key] = parseValue(val)
if (isAsyncModel.value) {
// 异步模型:提交任务拿 task_id,随后轮询取图
const res = await api.post('/v1/images/generations/async', body, { timeout: 60_000 })
const data = res.data
asyncTask.value = {
id: data.id,
status: data.status || 'SUBMITTED',
progress: data.progress || '',
createdAt: data.created_at || Math.floor(Date.now() / 1000),
}
if (data.status === 'SUCCESS') {
if (data.url) images.value = [{ url: data.url }]
} else if (data.status !== 'FAILURE') {
startPolling()
} else {
asyncTask.value.error = data.error || '生成失败'
}
} else {
// 同步模型:一次性返回
const res = await api.post('/v1/images/generations', body, { timeout: 300_000 })
const data = res.data
images.value = data.data || []
const usage = data.usage || {}
tokenUsage.promptTokens = usage.prompt_tokens || 0
tokenUsage.totalTokens = usage.total_tokens || 0
tokenUsage.cost = calculateCost(selectedModelItem.value, usage) || ''
}
const res = await api.post('/v1/images/generations', body, { timeout: 300_000 })
const data = res.data
images.value = data.data || []
const usage = data.usage || {}
tokenUsage.promptTokens = usage.prompt_tokens || 0
tokenUsage.totalTokens = usage.total_tokens || 0
tokenUsage.cost = calculateCost(selectedModelItem.value, usage) || ''
} catch (e) {
console.error(e)
errorMessage.value = (e as any)?.message || '请求失败,请重试'
} finally {
sending.value = false
}
}
function downloadImage(img: ImageResult, idx: number) {
let src = ''
let filename = `image_${idx + 1}.png`
if (img.b64_json) {
src = 'data:image/png;base64,' + img.b64_json
} else if (img.url) {
src = img.url
filename = `image_${idx + 1}.png`
} else {
return
function startPolling() {
polling.value = true
pollLoop()
}
function stopPolling() {
polling.value = false
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
}
async function pollLoop() {
if (!polling.value || !asyncTask.value?.id) return
try {
const api = createPlaygroundApi(props.apiKey)
const res = await api.get(`/v1/images/generations/async/${asyncTask.value.id}`)
const data = res.data
asyncTask.value = {
...asyncTask.value,
status: data.status,
progress: data.progress || '',
error: data.error || undefined,
}
if (data.status === 'SUCCESS') {
polling.value = false
if (data.url) images.value = [{ url: data.url }]
return
}
if (data.status === 'FAILURE') {
polling.value = false
return
}
} catch {
// 轮询失败不中断,继续尝试
}
if (polling.value) {
pollTimer = setTimeout(pollLoop, 3000)
}
}
function imageSrc(img: ImageResult): string {
if (img.b64_json) return 'data:image/png;base64,' + img.b64_json
if (img.url) return img.url
return ''
}
function downloadImage(img: ImageResult, idx: number) {
const src = imageSrc(img)
if (!src) return
const a = document.createElement('a')
a.href = src
a.download = filename
a.download = `image_${idx + 1}.png`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
// ── 图片放大预览 ──
const zoomedSrc = ref('')
function onZoomKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') closeZoom()
}
function openZoom(img: ImageResult) {
const src = imageSrc(img)
if (!src) return
zoomedSrc.value = src
window.addEventListener('keydown', onZoomKeydown)
}
function closeZoom() {
zoomedSrc.value = ''
window.removeEventListener('keydown', onZoomKeydown)
}
</script>
<template>
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div class="lg:col-span-1">
<div class="grid grid-cols-1 lg:grid-cols-5 gap-6">
<div class="lg:col-span-2">
<div class="card sticky top-6">
<div class="card-header">
<h3 class="text-sm font-semibold text-gray-900">图片生成参数</h3>
@@ -210,6 +350,10 @@ function downloadImage(img: ImageResult, idx: number) {
<div>
<label class="input-label">模型</label>
<BaseSelect v-model="selectedModel" :options="modelOptions" />
<div v-if="isAsyncModel" class="mt-1.5 flex items-center gap-1.5">
<span class="badge badge-warning">异步</span>
<span class="text-xs text-gray-500">提交后轮询取图</span>
</div>
</div>
<!-- 提示词 -->
@@ -288,8 +432,8 @@ function downloadImage(img: ImageResult, idx: number) {
</div>
</div>
<button class="btn btn-primary w-full" :disabled="sending || !prompt.trim()" @click="generate">
{{ sending ? '生成中...' : '生成图片' }}
<button class="btn btn-primary w-full" :disabled="sending || polling || !prompt.trim()" @click="generate">
{{ sending ? (isAsyncModel ? '提交中...' : '生成中...') : polling ? '生成中...' : '生成图片' }}
</button>
</div>
</div>
@@ -301,26 +445,88 @@ function downloadImage(img: ImageResult, idx: number) {
<h3 class="text-sm font-semibold text-gray-900">生成结果</h3>
</div>
<div class="card-body">
<div v-if="images.length === 0 && !sending" class="empty-state">
<!-- 异步模型说明 -->
<div v-if="isAsyncModel" class="mb-4 rounded-xl border border-primary-200 bg-primary-50/60 p-3">
<div class="flex items-start gap-2">
<Icon name="infoCircle" size="sm" class="text-primary-500 flex-shrink-0 mt-0.5" />
<p class="text-xs text-gray-600 leading-relaxed">
<span class="font-medium text-gray-700">异步任务模型</span>:提交至
<code class="code text-[11px]">POST /v1/images/generations/async</code>
拿到 task_id 后轮询
<code class="code text-[11px]">GET /v1/images/generations/async/{task_id}</code>
取图。
</p>
</div>
</div>
<!-- 错误提示 -->
<div
v-if="errorMessage"
class="mb-4 rounded-xl border border-red-200 bg-red-50 p-4"
>
<div class="flex items-center gap-2 text-red-700">
<Icon name="xCircle" size="sm" />
<span class="text-sm font-medium">生成失败</span>
</div>
<p class="mt-2 text-sm text-red-600">{{ errorMessage }}</p>
</div>
<div v-if="images.length === 0 && !sending && !errorMessage && !asyncTask" class="empty-state">
<div class="empty-state-icon"><Icon name="bookOpen" size="xl" /></div>
<h3 class="empty-state-title">等待生成</h3>
<p class="empty-state-description">输入提示词并点击生成</p>
</div>
<div v-if="sending" class="flex items-center justify-center py-12">
<div class="spinner h-8 w-8 text-primary-600"></div>
<span class="ml-3 text-sm text-gray-500">图片生成中...</span>
<span class="ml-3 text-sm text-gray-500">{{ isAsyncModel ? '提交图片生成任务中...' : '图片生成中...' }}</span>
</div>
<!-- 异步任务状态 -->
<div v-if="asyncTask && !sending" class="mb-4 space-y-3">
<div class="flex items-center gap-3">
<span class="badge" :class="statusColor[asyncTask.status] || 'badge-gray'">
{{ statusLabel[asyncTask.status] || asyncTask.status }}
</span>
<span v-if="asyncTask.progress" class="text-xs text-gray-500">进度: {{ asyncTask.progress }}</span>
<span v-if="polling" class="text-xs text-primary-600 flex items-center gap-1">
<div class="spinner h-3 w-3"></div>
轮询中
</span>
<span v-if="asyncTask.id" class="text-xs text-gray-400 ml-auto">task_id: {{ asyncTask.id }}</span>
</div>
<div v-if="asyncTask.status === 'IN_PROGRESS' || asyncTask.status === 'SUBMITTED' || asyncTask.status === 'QUEUED'" class="w-full bg-gray-100 rounded-full h-2 overflow-hidden">
<div class="h-full rounded-full bg-primary-500 transition-all duration-500" :style="{ width: asyncTask.progress || '10%' }" />
</div>
<div v-if="asyncTask.status === 'FAILURE'" class="rounded-xl border border-red-200 bg-red-50 p-4">
<div class="flex items-center gap-2 text-red-700">
<Icon name="xCircle" size="sm" />
<span class="text-sm font-medium">生成失败</span>
</div>
<p v-if="asyncTask.error" class="mt-2 text-sm text-red-600">{{ asyncTask.error }}</p>
</div>
</div>
<div v-if="images.length > 0" class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div v-for="(img, idx) in images" :key="idx" class="rounded-xl overflow-hidden border border-gray-200 relative group">
<img v-if="img.b64_json" :src="'data:image/png;base64,' + img.b64_json" class="w-full" alt="Generated image" />
<img v-else-if="img.url" :src="img.url" class="w-full" alt="Generated image" />
<button
class="absolute top-2 right-2 btn btn-sm bg-white/90 backdrop-blur-sm border border-gray-200 shadow-sm opacity-0 group-hover:opacity-100 transition-opacity duration-200"
@click="downloadImage(img, idx)"
>
<Icon name="arrowDown" size="sm" />
下载
</button>
<img :src="imageSrc(img)" class="w-full cursor-zoom-in" alt="Generated image" @click="openZoom(img)" />
<div class="absolute top-2 right-2 flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<button
class="btn btn-sm bg-white/90 backdrop-blur-sm border border-gray-200 shadow-sm"
title="放大查看"
@click="openZoom(img)"
>
<Icon name="expand" size="sm" />
放大
</button>
<button
class="btn btn-sm bg-white/90 backdrop-blur-sm border border-gray-200 shadow-sm"
title="下载图片"
@click="downloadImage(img, idx)"
>
<Icon name="arrowDown" size="sm" />
下载
</button>
</div>
<p v-if="img.revised_prompt" class="text-xs text-gray-500 p-3">{{ img.revised_prompt }}</p>
</div>
</div>
@@ -335,4 +541,22 @@ function downloadImage(img: ImageResult, idx: number) {
<!-- 参数参考弹窗 -->
<ParamRefModal :show="showParamRef" mode="image" @close="showParamRef = false" />
<!-- 图片放大预览 -->
<Teleport to="body">
<Transition name="modal">
<div v-if="zoomedSrc" class="modal-overlay" @click="closeZoom">
<div class="relative max-h-[90vh] max-w-[90vw]" @click.stop>
<img :src="zoomedSrc" class="max-h-[90vh] max-w-[90vw] rounded-xl object-contain shadow-2xl" alt="放大预览" />
<button
class="absolute -top-3 -right-3 flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-600 shadow-lg transition-colors hover:text-gray-900"
title="关闭"
@click="closeZoom"
>
<Icon name="x" size="sm" />
</button>
</div>
</div>
</Transition>
</Teleport>
</template>
@@ -17,6 +17,10 @@ export interface ModelParamGroup {
label: string
docUrl?: string
params: ParamDef[]
// 该组参数发送到的端点(缺省为同步 /v1/images/generations)
endpoint?: string
// 是否为异步任务式(提交 + 轮询)
async?: boolean
}
const props = defineProps<{
@@ -76,6 +80,19 @@ const imageGroups: ModelParamGroup[] = [
{ name: 'style', type: 'string', required: false, default: 'vivid', values: ['vivid', 'natural'], description: '仅 dall-e-3 支持。vivid 生动,natural 自然' },
],
},
{
key: 'dashscope-async',
label: 'DashScope(异步)',
docUrl: 'https://help.aliyun.com/zh/model-studio/text-to-image-v2-api-reference',
endpoint: '/v1/images/generations/async',
async: true,
params: [
{ name: 'prompt', type: 'string', required: true, description: '图像描述文本,支持中英文' },
{ name: 'model', type: 'string', required: true, description: '模型 ID:wanx2.1-t2i-turbo、wanx2.0-t2i-turbo、qwen-image 等通义万相/千问图像模型' },
{ name: 'size', type: 'string', required: false, default: '1024x1024', values: ['1024x1024', '1280x720', '720x1280', '1152x864', '864x1152'], description: '图像尺寸(宽x高)' },
{ name: 'n', type: 'integer', required: false, default: '1', values: ['1', '2', '3', '4'], description: '生成数量' },
],
},
]
// ── 视频模型参数参考(预留) ──
@@ -189,7 +206,8 @@ const currentGroup = computed(() => groups.value.find(g => g.key === activeGroup
<div class="flex items-start gap-2">
<Icon name="infoCircle" size="sm" class="text-amber-500 mt-0.5 flex-shrink-0" />
<div class="text-xs text-amber-700 space-y-1">
<p>以上参数通过 <code class="bg-amber-100 px-1 rounded">/v1/images/generations</code> 端点发送,使用自定义参数区域逐行添加。</p>
<p>以上参数通过 <code class="bg-amber-100 px-1 rounded">{{ currentGroup?.endpoint || '/v1/images/generations' }}</code> 端点发送,使用自定义参数区域逐行添加。</p>
<p v-if="currentGroup?.async">该厂商图像为<strong>异步任务式</strong>:提交后返回 task_id,需轮询 <code class="bg-amber-100 px-1 rounded">GET /v1/images/generations/async/{task_id}</code> 取图。在线体验会自动完成提交与轮询。</p>
<p>参数名为 JSON 键名,值为对应格式的字符串(系统会自动识别布尔值和数字)。</p>
</div>
</div>
@@ -17,6 +17,7 @@ interface ModelItem {
const props = defineProps<{ models: ModelItem[]; apiKey: string }>()
const sending = ref(false)
const errorMessage = ref('')
const selectedModel = ref(props.models[0]?.model_id || '')
const selectedModelItem = computed(() =>
props.models.find(m => m.model_id === selectedModel.value),
@@ -36,6 +37,7 @@ async function rerank() {
if (docs.length === 0) return
sending.value = true
errorMessage.value = ''
results.value = []
try {
@@ -55,6 +57,7 @@ async function rerank() {
tokenUsage.cost = calculateCost(selectedModelItem.value, usage) || ''
} catch (e) {
console.error(e)
errorMessage.value = (e as any)?.message || '请求失败,请重试'
} finally {
sending.value = false
}
@@ -104,7 +107,19 @@ function scoreColor(score: number) {
<h3 class="text-sm font-semibold text-gray-900">排序结果</h3>
</div>
<div class="card-body">
<div v-if="results.length === 0 && !sending" class="empty-state">
<!-- 错误提示 -->
<div
v-if="errorMessage"
class="mb-4 rounded-xl border border-red-200 bg-red-50 p-4"
>
<div class="flex items-center gap-2 text-red-700">
<Icon name="xCircle" size="sm" />
<span class="text-sm font-medium">排序失败</span>
</div>
<p class="mt-2 text-sm text-red-600">{{ errorMessage }}</p>
</div>
<div v-if="results.length === 0 && !sending && !errorMessage" class="empty-state">
<div class="empty-state-icon"><Icon name="bookOpen" size="xl" /></div>
<h3 class="empty-state-title">等待排序</h3>
<p class="empty-state-description">输入查询和文档并点击重排序</p>
@@ -176,8 +176,8 @@ function downloadVideo() {
</script>
<template>
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div class="lg:col-span-1">
<div class="grid grid-cols-1 lg:grid-cols-5 gap-6">
<div class="lg:col-span-2">
<div class="card sticky top-6">
<div class="card-header">
<h3 class="text-sm font-semibold text-gray-900">视频生成参数</h3>