Merge branch 'feat/video' into dev
This commit is contained in:
commit
b04e656667
55 files changed
+2231
-474
No files matched your search
@@ -60,6 +60,7 @@ type RequestAuditLogListReq struct {
|
||||
ApiKeyID int `json:"api_key_id" dc:"API Key ID"`
|
||||
Username string `json:"username" dc:"用户名(模糊匹配)"`
|
||||
RequestId string `json:"request_id" dc:"Request ID(精确匹配)"`
|
||||
TaskId string `json:"task_id" dc:"异步任务ID(精确匹配)"`
|
||||
Method string `json:"method" dc:"HTTP 方法"`
|
||||
Path string `json:"path" dc:"请求路径"`
|
||||
StatusCode int `json:"status_code" dc:"状态码"`
|
||||
|
||||
@@ -7,7 +7,7 @@ type ModelListReq struct {
|
||||
g.Meta `path:"/models" method:"get" mime:"json" tags:"管理后台-模型" summary:"模型列表"`
|
||||
Page int `json:"page" d:"1" v:"min:1" dc:"页码"`
|
||||
PageSize int `json:"page_size" d:"20" v:"min:1|max:100" dc:"每页数量"`
|
||||
Category string `json:"category" dc:"模型分类筛选:chat/embedding/image/audio/rerank"`
|
||||
Category string `json:"category" dc:"模型分类筛选:chat/embedding/image/audio/rerank/video"`
|
||||
Status string `json:"status" dc:"状态筛选:active/deprecated/offline"`
|
||||
Search string `json:"search" dc:"搜索关键词(模型名或显示名)"`
|
||||
}
|
||||
@@ -44,7 +44,7 @@ type ModelCreateReq struct {
|
||||
g.Meta `path:"/models" method:"post" mime:"json" tags:"管理后台-模型" summary:"创建模型"`
|
||||
ModelId string `json:"model_id" v:"required|length:1,100#请输入模型标识|模型标识长度1-100" dc:"模型唯一标识"`
|
||||
ModelName string `json:"model_name" dc:"模型显示名称"`
|
||||
Category string `json:"category" v:"required|in:chat,embedding,image,audio,rerank#请选择分类|分类必须是 chat/embedding/image/audio/rerank" dc:"模型分类"`
|
||||
Category string `json:"category" v:"required|in:chat,embedding,image,audio,rerank,video#请选择分类|分类必须是 chat/embedding/image/audio/rerank/video" dc:"模型分类"`
|
||||
MaxContext int `json:"max_context_tokens" dc:"最大上下文 token 数"`
|
||||
MaxOutput int `json:"max_output_tokens" dc:"最大输出 token 数"`
|
||||
Capabilities map[string]bool `json:"capabilities" dc:"模型能力特性"`
|
||||
@@ -62,7 +62,7 @@ type ModelUpdateReq struct {
|
||||
g.Meta `path:"/models/{id}" method:"put" mime:"json" tags:"管理后台-模型" summary:"更新模型"`
|
||||
ID int64 `json:"id" in:"path" v:"required" dc:"模型ID"`
|
||||
ModelName string `json:"model_name" dc:"模型显示名称"`
|
||||
Category string `json:"category" v:"in:chat,embedding,image,audio,rerank" dc:"模型分类"`
|
||||
Category string `json:"category" v:"in:chat,embedding,image,audio,rerank,video" dc:"模型分类"`
|
||||
MaxContext int `json:"max_context_tokens" dc:"最大上下文 token 数"`
|
||||
MaxOutput int `json:"max_output_tokens" dc:"最大输出 token 数"`
|
||||
Capabilities map[string]bool `json:"capabilities" dc:"模型能力特性"`
|
||||
|
||||
@@ -5,11 +5,12 @@ import "github.com/gogf/gf/v2/frame/g"
|
||||
// === 大模型异步任务管理 ===
|
||||
|
||||
type TaskListReq struct {
|
||||
g.Meta `path:"/tasks" method:"get" mime:"json" tags:"管理后台-任务管理" summary:"大模型异步任务列表"`
|
||||
Page int `json:"page" in:"query" d:"1" v:"min:1" dc:"页码"`
|
||||
PageSize int `json:"page_size" in:"query" d:"20" v:"min:1|max:100" dc:"每页数量"`
|
||||
Status string `json:"status" in:"query" dc:"筛选状态"`
|
||||
Platform string `json:"platform" in:"query" dc:"筛选平台(sora/kling/midjourney/suno)"`
|
||||
g.Meta `path:"/tasks" method:"get" mime:"json" tags:"管理后台-任务管理" summary:"大模型异步任务列表"`
|
||||
Page int `json:"page" in:"query" d:"1" v:"min:1" dc:"页码"`
|
||||
PageSize int `json:"page_size" in:"query" d:"20" v:"min:1|max:100" dc:"每页数量"`
|
||||
Status string `json:"status" in:"query" dc:"筛选状态"`
|
||||
Platform string `json:"platform" in:"query" dc:"筛选平台(sora/kling/midjourney/suno)"`
|
||||
PublicTaskID string `json:"public_task_id" in:"query" dc:"任务ID(精确匹配)"`
|
||||
}
|
||||
|
||||
type ModelTaskItem struct {
|
||||
|
||||
@@ -154,6 +154,8 @@ type ITenantV1 interface {
|
||||
Tenant2FAVerify(ctx context.Context, req *v1.Tenant2FAVerifyReq) (res *v1.Tenant2FAVerifyRes, err error)
|
||||
Tenant2FAConfirm(ctx context.Context, req *v1.Tenant2FAConfirmReq) (res *v1.Tenant2FAConfirmRes, err error)
|
||||
TenantLoginHistory(ctx context.Context, req *v1.TenantLoginHistoryReq) (res *v1.TenantLoginHistoryRes, err error)
|
||||
TenantTaskList(ctx context.Context, req *v1.TenantTaskListReq) (res *v1.TenantTaskListRes, err error)
|
||||
TenantTaskDetail(ctx context.Context, req *v1.TenantTaskDetailReq) (res *v1.TenantTaskDetailRes, err error)
|
||||
TenantTicketCreate(ctx context.Context, req *v1.TenantTicketCreateReq) (res *v1.TenantTicketCreateRes, err error)
|
||||
TenantTicketList(ctx context.Context, req *v1.TenantTicketListReq) (res *v1.TenantTicketListRes, err error)
|
||||
TenantTicketGet(ctx context.Context, req *v1.TenantTicketGetReq) (res *v1.TenantTicketGetRes, err error)
|
||||
|
||||
@@ -40,6 +40,7 @@ type TenantRequestAuditLogsReq struct {
|
||||
PageSize int `json:"page_size" in:"query" d:"20" v:"min:1|max:100"`
|
||||
Username string `json:"username" in:"query" dc:"用户名(模糊匹配)"`
|
||||
RequestId string `json:"request_id" in:"query" dc:"Request ID(精确匹配)"`
|
||||
TaskId string `json:"task_id" in:"query" dc:"异步任务ID(精确匹配)"`
|
||||
Path string `json:"path" in:"query" dc:"请求路径(模糊匹配)"`
|
||||
StatusCode int `json:"status_code" in:"query" dc:"HTTP 状态码"`
|
||||
StartDate string `json:"start_date" in:"query" dc:"开始日期"`
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package v1
|
||||
|
||||
import "github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
// === 租户端异步任务查询 ===
|
||||
|
||||
type TenantTaskListReq struct {
|
||||
g.Meta `path:"/tasks" method:"get" mime:"json" tags:"租户控制台-任务管理" summary:"租户异步任务列表"`
|
||||
Page int `json:"page" in:"query" d:"1" v:"min:1" dc:"页码"`
|
||||
PageSize int `json:"page_size" in:"query" d:"20" v:"min:1|max:100" dc:"每页数量"`
|
||||
Status string `json:"status" in:"query" dc:"筛选状态"`
|
||||
Platform string `json:"platform" in:"query" dc:"筛选平台(sora/kling/midjourney/suno)"`
|
||||
PublicTaskID string `json:"public_task_id" in:"query" dc:"任务ID(精确匹配)"`
|
||||
}
|
||||
|
||||
type TenantTaskItem struct {
|
||||
ID int64 `json:"id"`
|
||||
PublicTaskID string `json:"public_task_id"`
|
||||
Platform string `json:"platform"`
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status"`
|
||||
Progress string `json:"progress"`
|
||||
ModelName string `json:"model_name"`
|
||||
FailReason string `json:"fail_reason,omitempty"`
|
||||
PreDeductAmount float64 `json:"pre_deduct_amount"`
|
||||
ActualCost float64 `json:"actual_cost"`
|
||||
BillingSettled bool `json:"billing_settled"`
|
||||
ResultURL string `json:"result_url,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
SubmitTime string `json:"submit_time,omitempty"`
|
||||
FinishTime string `json:"finish_time,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type TenantTaskListRes struct {
|
||||
List []TenantTaskItem `json:"list"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
type TenantTaskDetailReq struct {
|
||||
g.Meta `path:"/tasks/{id}" method:"get" mime:"json" tags:"租户控制台-任务管理" summary:"租户异步任务详情"`
|
||||
ID int64 `json:"id" in:"path" v:"required" dc:"任务ID"`
|
||||
}
|
||||
|
||||
type TenantTaskDetailRes struct {
|
||||
Task TenantTaskItem `json:"task"`
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qianfree/team-api/api/tenant/v1"
|
||||
"github.com/qianfree/team-api/internal/service"
|
||||
)
|
||||
|
||||
func (c *ControllerV1) TenantTaskDetail(ctx context.Context, req *v1.TenantTaskDetailReq) (res *v1.TenantTaskDetailRes, err error) {
|
||||
return service.Tenant().TenantTaskDetail(ctx, req)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qianfree/team-api/api/tenant/v1"
|
||||
"github.com/qianfree/team-api/internal/service"
|
||||
)
|
||||
|
||||
func (c *ControllerV1) TenantTaskList(ctx context.Context, req *v1.TenantTaskListReq) (res *v1.TenantTaskListRes, err error) {
|
||||
return service.Tenant().TenantTaskList(ctx, req)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"github.com/qianfree/team-api/internal/dao/internal"
|
||||
)
|
||||
|
||||
// audContentFilterLogsDao is the data access object for the table aud_content_filter_logs.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type audContentFilterLogsDao struct {
|
||||
*internal.AudContentFilterLogsDao
|
||||
}
|
||||
|
||||
var (
|
||||
// AudContentFilterLogs is a globally accessible object for table aud_content_filter_logs operations.
|
||||
AudContentFilterLogs = audContentFilterLogsDao{internal.NewAudContentFilterLogsDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -0,0 +1,105 @@
|
||||
// ==========================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// ==========================================================================
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// AudContentFilterLogsDao is the data access object for the table aud_content_filter_logs.
|
||||
type AudContentFilterLogsDao struct {
|
||||
table string // table is the underlying table name of the DAO.
|
||||
group string // group is the database configuration group name of the current DAO.
|
||||
columns AudContentFilterLogsColumns // columns contains all the column names of Table for convenient usage.
|
||||
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||
}
|
||||
|
||||
// AudContentFilterLogsColumns defines and stores column names for the table aud_content_filter_logs.
|
||||
type AudContentFilterLogsColumns struct {
|
||||
Id string // 主键ID
|
||||
TenantId string // 租户ID
|
||||
UserId string // 用户ID
|
||||
ApiKeyId string // API Key ID
|
||||
RequestId string // 请求唯一ID
|
||||
Method string // HTTP 方法
|
||||
Path string // 请求路径
|
||||
ClientIp string // 客户端 IP
|
||||
FilterMode string // 过滤模式:log / replace / block
|
||||
MatchedWords string // 命中的敏感词列表(JSONB 数组)
|
||||
OriginalSnippet string // 原始请求体片段(截断存储,仅 replace 模式)
|
||||
Blocked string // 是否被拦截(mode=block 时为 true)
|
||||
CreatedAt string // 创建时间
|
||||
ProjectId string // 项目ID
|
||||
}
|
||||
|
||||
// audContentFilterLogsColumns holds the columns for the table aud_content_filter_logs.
|
||||
var audContentFilterLogsColumns = AudContentFilterLogsColumns{
|
||||
Id: "id",
|
||||
TenantId: "tenant_id",
|
||||
UserId: "user_id",
|
||||
ApiKeyId: "api_key_id",
|
||||
RequestId: "request_id",
|
||||
Method: "method",
|
||||
Path: "path",
|
||||
ClientIp: "client_ip",
|
||||
FilterMode: "filter_mode",
|
||||
MatchedWords: "matched_words",
|
||||
OriginalSnippet: "original_snippet",
|
||||
Blocked: "blocked",
|
||||
CreatedAt: "created_at",
|
||||
ProjectId: "project_id",
|
||||
}
|
||||
|
||||
// NewAudContentFilterLogsDao creates and returns a new DAO object for table data access.
|
||||
func NewAudContentFilterLogsDao(handlers ...gdb.ModelHandler) *AudContentFilterLogsDao {
|
||||
return &AudContentFilterLogsDao{
|
||||
group: "default",
|
||||
table: "aud_content_filter_logs",
|
||||
columns: audContentFilterLogsColumns,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||
func (dao *AudContentFilterLogsDao) DB() gdb.DB {
|
||||
return g.DB(dao.group)
|
||||
}
|
||||
|
||||
// Table returns the table name of the current DAO.
|
||||
func (dao *AudContentFilterLogsDao) Table() string {
|
||||
return dao.table
|
||||
}
|
||||
|
||||
// Columns returns all column names of the current DAO.
|
||||
func (dao *AudContentFilterLogsDao) Columns() AudContentFilterLogsColumns {
|
||||
return dao.columns
|
||||
}
|
||||
|
||||
// Group returns the database configuration group name of the current DAO.
|
||||
func (dao *AudContentFilterLogsDao) Group() string {
|
||||
return dao.group
|
||||
}
|
||||
|
||||
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
|
||||
func (dao *AudContentFilterLogsDao) Ctx(ctx context.Context) *gdb.Model {
|
||||
model := dao.DB().Model(dao.table)
|
||||
for _, handler := range dao.handlers {
|
||||
model = handler(model)
|
||||
}
|
||||
return model.Safe().Ctx(ctx)
|
||||
}
|
||||
|
||||
// Transaction wraps the transaction logic using function f.
|
||||
// It rolls back the transaction and returns the error if function f returns a non-nil error.
|
||||
// It commits the transaction and returns nil if function f returns nil.
|
||||
//
|
||||
// Note: Do not commit or roll back the transaction in function f,
|
||||
// as it is automatically handled by this function.
|
||||
func (dao *AudContentFilterLogsDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -21,60 +21,70 @@ type AudRequestLogsDao struct {
|
||||
|
||||
// AudRequestLogsColumns defines and stores column names for the table aud_request_logs.
|
||||
type AudRequestLogsColumns struct {
|
||||
Id string // 主键ID
|
||||
TenantId string // 租户ID
|
||||
UserId string // 用户ID
|
||||
ApiKeyId string // 使用的 API Key ID
|
||||
RequestId string // 请求唯一ID(关联全链路追踪)
|
||||
Method string // HTTP 方法(GET/POST/PUT/DELETE)
|
||||
Path string // 请求路径
|
||||
QueryParams string // 查询参数(URL Query String)
|
||||
StatusCode string // HTTP 响应状态码
|
||||
ClientIp string // 客户端 IP
|
||||
UserAgent string // 客户端 User-Agent
|
||||
RequestBody string // 请求体(敏感字段脱敏后存储)
|
||||
ResponseBody string // 响应体(截断后存储)
|
||||
LatencyMs string // 请求延迟(毫秒)
|
||||
AuditLevel string // 审计级别:full(完整记录)/ masked(脱敏记录)/ question_only(仅记录提问)/ none(不记录)
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedAt string // 更新时间
|
||||
TenantRequestBody string // 租户级请求体(按租户审计级别处理)
|
||||
TenantResponseBody string // 租户级响应体(按租户审计级别处理)
|
||||
TenantAuditLevel string // 租户审计级别:full/full_text/masked/question_only/none
|
||||
ProjectId string // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目)
|
||||
FirstTokenMs string // 首个 Token 出现的用时(毫秒),仅流式请求有值
|
||||
RequestHeaders string // 请求头信息(仅审计级别为 all 时记录,管理后台调试用)
|
||||
ResponseHeaders string // 响应头信息(仅审计级别为 all 时记录,管理后台调试用)
|
||||
ForwardingTrace string // 请求转发路径追踪(仅管理员可见)
|
||||
Id string // 主键ID
|
||||
TenantId string // 租户ID
|
||||
UserId string // 用户ID
|
||||
ApiKeyId string // 使用的 API Key ID
|
||||
RequestId string // 请求唯一ID(关联全链路追踪)
|
||||
Method string // HTTP 方法(GET/POST/PUT/DELETE)
|
||||
Path string // 请求路径
|
||||
QueryParams string // 查询参数(URL Query String)
|
||||
StatusCode string // HTTP 响应状态码
|
||||
ClientIp string // 客户端 IP
|
||||
UserAgent string // 客户端 User-Agent
|
||||
RequestBody string // 请求体(敏感字段脱敏后存储)
|
||||
ResponseBody string // 响应体(截断后存储)
|
||||
LatencyMs string // 请求延迟(毫秒)
|
||||
AuditLevel string // 审计级别:full(完整记录)/ masked(脱敏记录)/ question_only(仅记录提问)/ none(不记录)
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedAt string // 更新时间
|
||||
TenantRequestBody string // 租户级请求体(按租户审计级别处理)
|
||||
TenantResponseBody string // 租户级响应体(按租户审计级别处理)
|
||||
TenantAuditLevel string // 租户审计级别:full/full_text/masked/question_only/none
|
||||
ProjectId string // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目)
|
||||
FirstTokenMs string // 首个 Token 出现的用时(毫秒),仅流式请求有值
|
||||
RequestHeaders string // 请求头信息(仅审计级别为 all 时记录,管理后台调试用)
|
||||
ResponseHeaders string // 响应头信息(仅审计级别为 all 时记录,管理后台调试用)
|
||||
ForwardingTrace string // 请求转发路径追踪(仅管理员可见)
|
||||
TaskId string // 异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id
|
||||
TaskStatus string // 异步任务终态:SUCCESS / FAILURE
|
||||
TaskResult string // 异步任务完成时上游返回的原始响应体
|
||||
TaskUpstreamHeaders string // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)
|
||||
TaskCompletedAt string // 异步任务达到终态的时间
|
||||
}
|
||||
|
||||
// audRequestLogsColumns holds the columns for the table aud_request_logs.
|
||||
var audRequestLogsColumns = AudRequestLogsColumns{
|
||||
Id: "id",
|
||||
TenantId: "tenant_id",
|
||||
UserId: "user_id",
|
||||
ApiKeyId: "api_key_id",
|
||||
RequestId: "request_id",
|
||||
Method: "method",
|
||||
Path: "path",
|
||||
QueryParams: "query_params",
|
||||
StatusCode: "status_code",
|
||||
ClientIp: "client_ip",
|
||||
UserAgent: "user_agent",
|
||||
RequestBody: "request_body",
|
||||
ResponseBody: "response_body",
|
||||
LatencyMs: "latency_ms",
|
||||
AuditLevel: "audit_level",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedAt: "updated_at",
|
||||
TenantRequestBody: "tenant_request_body",
|
||||
TenantResponseBody: "tenant_response_body",
|
||||
TenantAuditLevel: "tenant_audit_level",
|
||||
ProjectId: "project_id",
|
||||
FirstTokenMs: "first_token_ms",
|
||||
RequestHeaders: "request_headers",
|
||||
ResponseHeaders: "response_headers",
|
||||
ForwardingTrace: "forwarding_trace",
|
||||
Id: "id",
|
||||
TenantId: "tenant_id",
|
||||
UserId: "user_id",
|
||||
ApiKeyId: "api_key_id",
|
||||
RequestId: "request_id",
|
||||
Method: "method",
|
||||
Path: "path",
|
||||
QueryParams: "query_params",
|
||||
StatusCode: "status_code",
|
||||
ClientIp: "client_ip",
|
||||
UserAgent: "user_agent",
|
||||
RequestBody: "request_body",
|
||||
ResponseBody: "response_body",
|
||||
LatencyMs: "latency_ms",
|
||||
AuditLevel: "audit_level",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedAt: "updated_at",
|
||||
TenantRequestBody: "tenant_request_body",
|
||||
TenantResponseBody: "tenant_response_body",
|
||||
TenantAuditLevel: "tenant_audit_level",
|
||||
ProjectId: "project_id",
|
||||
FirstTokenMs: "first_token_ms",
|
||||
RequestHeaders: "request_headers",
|
||||
ResponseHeaders: "response_headers",
|
||||
ForwardingTrace: "forwarding_trace",
|
||||
TaskId: "task_id",
|
||||
TaskStatus: "task_status",
|
||||
TaskResult: "task_result",
|
||||
TaskUpstreamHeaders: "task_upstream_headers",
|
||||
TaskCompletedAt: "task_completed_at",
|
||||
}
|
||||
|
||||
// NewAudRequestLogsDao creates and returns a new DAO object for table data access.
|
||||
|
||||
@@ -48,7 +48,7 @@ type BilUsageLogsColumns struct {
|
||||
ActualCost string // 实际扣除费用(含折扣后)
|
||||
RequestedModel string // 用户请求的模型名
|
||||
UpstreamModel string // 上游实际模型名(模型映射后)
|
||||
RequestType string // 请求类型: 1=sync, 2=stream, 3=websocket
|
||||
RequestType string // 请求类型: 1=sync, 2=stream, 3=async, 4=websocket
|
||||
UserAgent string // 客户端 User-Agent
|
||||
FirstTokenMs string // 首 token 延迟(毫秒)
|
||||
ServiceTier string // 服务等级 (default/flex等)
|
||||
@@ -77,6 +77,7 @@ type BilUsageLogsColumns struct {
|
||||
UpstreamEndpoint string // 上游实际请求路径
|
||||
BillingSnapshot string // 完整计费计算过程快照(JSONB)
|
||||
ProjectId string // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目)
|
||||
TaskId string // 异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空
|
||||
}
|
||||
|
||||
// bilUsageLogsColumns holds the columns for the table bil_usage_logs.
|
||||
@@ -137,6 +138,7 @@ var bilUsageLogsColumns = BilUsageLogsColumns{
|
||||
UpstreamEndpoint: "upstream_endpoint",
|
||||
BillingSnapshot: "billing_snapshot",
|
||||
ProjectId: "project_id",
|
||||
TaskId: "task_id",
|
||||
}
|
||||
|
||||
// NewBilUsageLogsDao creates and returns a new DAO object for table data access.
|
||||
|
||||
@@ -23,6 +23,7 @@ type TskModelTasksDao struct {
|
||||
type TskModelTasksColumns struct {
|
||||
Id string //
|
||||
PublicTaskId string //
|
||||
RequestId string //
|
||||
Platform string //
|
||||
Action string //
|
||||
Status string //
|
||||
@@ -51,6 +52,7 @@ type TskModelTasksColumns struct {
|
||||
var tskModelTasksColumns = TskModelTasksColumns{
|
||||
Id: "id",
|
||||
PublicTaskId: "public_task_id",
|
||||
RequestId: "request_id",
|
||||
Platform: "platform",
|
||||
Action: "action",
|
||||
Status: "status",
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
|
||||
"github.com/qianfree/team-api/internal/logic/billing"
|
||||
"github.com/qianfree/team-api/internal/logic/relay"
|
||||
"github.com/qianfree/team-api/internal/logic/task"
|
||||
"github.com/qianfree/team-api/internal/middleware"
|
||||
"github.com/qianfree/team-api/relay/common"
|
||||
relay_common "github.com/qianfree/team-api/relay/common"
|
||||
relay_handler "github.com/qianfree/team-api/relay/handler"
|
||||
)
|
||||
|
||||
var (
|
||||
taskDataProvider = task.DefaultAsyncProvider
|
||||
taskBillingProvider = billing.NewTaskBillingProvider()
|
||||
relayDataProvider = relay.NewDataProvider()
|
||||
)
|
||||
|
||||
// HandleTaskSubmit 处理异步任务提交(POST /v1/video/generations, POST /suno/submit/:action)
|
||||
@@ -32,6 +36,7 @@ func HandleTaskSubmit(r *ghttp.Request) {
|
||||
TenantID: middleware.GetTenantID(r.Context()),
|
||||
UserID: middleware.GetUserID(r.Context()),
|
||||
ApiKeyID: middleware.GetApiKeyID(r.Context()),
|
||||
ProjectID: middleware.GetProjectID(r.Context()),
|
||||
RequestID: r.GetCtxVar("RequestId").String(),
|
||||
Writer: r.Response.Writer,
|
||||
Scope: r.GetCtxVar("ApiKeyScope").String(),
|
||||
@@ -41,16 +46,37 @@ func HandleTaskSubmit(r *ghttp.Request) {
|
||||
// 选择渠道
|
||||
channelMeta, err := selectTaskChannel(r, body)
|
||||
if err != nil {
|
||||
r.Response.WriteStatus(503, g.Map{
|
||||
"error": g.Map{"type": "server_error", "message": err.Error()},
|
||||
statusCode := 503
|
||||
errType := "server_error"
|
||||
errMsg := err.Error()
|
||||
|
||||
if err == relay_common.ErrTenantModelNotEnabled {
|
||||
statusCode = 403
|
||||
errType = "permission_denied"
|
||||
errMsg = "当前租户未启用该模型,请联系管理员"
|
||||
} else if err == relay_common.ErrChannelUnavailable {
|
||||
statusCode = 503
|
||||
errType = "server_error"
|
||||
errMsg = "该模型暂无可用的渠道,请稍后重试或联系管理员"
|
||||
}
|
||||
|
||||
r.Response.WriteStatus(statusCode, g.Map{
|
||||
"error": g.Map{"type": errType, "message": errMsg},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 记录任务提交审计日志(使用 capture 模式以支持异步写入)
|
||||
capture := NewResponseCaptureWriter(r.Response.Writer)
|
||||
rc.Writer = capture
|
||||
|
||||
relay_handler.HandleTaskSubmit(
|
||||
r.Context(), body, r.URL.Path, r.Header,
|
||||
rc, taskDataProvider, taskBillingProvider, channelMeta,
|
||||
)
|
||||
|
||||
// 异步记录审计日志
|
||||
go recordTaskSubmitAudit(r, rc, capture, body)
|
||||
}
|
||||
|
||||
// HandleTaskFetch 处理异步任务查询(GET /v1/video/generations/:id, GET /suno/fetch/:id)
|
||||
@@ -67,6 +93,7 @@ func HandleTaskFetch(r *ghttp.Request) {
|
||||
TenantID: middleware.GetTenantID(r.Context()),
|
||||
UserID: middleware.GetUserID(r.Context()),
|
||||
ApiKeyID: middleware.GetApiKeyID(r.Context()),
|
||||
ProjectID: middleware.GetProjectID(r.Context()),
|
||||
RequestID: r.GetCtxVar("RequestId").String(),
|
||||
Writer: r.Response.Writer,
|
||||
}
|
||||
@@ -84,6 +111,7 @@ func HandleSunoFetchBatch(r *ghttp.Request) {
|
||||
TenantID: middleware.GetTenantID(r.Context()),
|
||||
UserID: middleware.GetUserID(r.Context()),
|
||||
ApiKeyID: middleware.GetApiKeyID(r.Context()),
|
||||
ProjectID: middleware.GetProjectID(r.Context()),
|
||||
RequestID: r.GetCtxVar("RequestId").String(),
|
||||
Writer: r.Response.Writer,
|
||||
}
|
||||
@@ -109,14 +137,34 @@ func extractTaskID(r *ghttp.Request) string {
|
||||
return r.Get("task_id").String()
|
||||
}
|
||||
|
||||
// selectTaskChannel 为异步任务选择渠道
|
||||
func selectTaskChannel(r *ghttp.Request, body []byte) (*common.ChannelMeta, error) {
|
||||
// 复用现有的渠道调度逻辑
|
||||
// 暂时返回一个占位符,后续集成到 scheduler
|
||||
return &common.ChannelMeta{
|
||||
ChannelID: 1,
|
||||
BaseURL: "https://api.openai.com",
|
||||
ApiKey: "",
|
||||
// selectTaskChannel 为异步任务选择渠道(复用实时请求的渠道调度逻辑)
|
||||
func selectTaskChannel(r *ghttp.Request, body []byte) (*relay_common.ChannelMeta, error) {
|
||||
// 从请求体提取模型名
|
||||
var req struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil || req.Model == "" {
|
||||
return nil, relay_common.ErrChannelUnavailable
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
userID := middleware.GetUserID(ctx)
|
||||
|
||||
selection, err := relayDataProvider.GetChannelForModel(ctx, tenantID, userID, req.Model, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &relay_common.ChannelMeta{
|
||||
ChannelID: selection.ChannelID,
|
||||
ChannelType: selection.ChannelType,
|
||||
ChannelName: selection.ChannelName,
|
||||
BaseURL: selection.BaseURL,
|
||||
ApiKey: selection.ApiKey,
|
||||
UpstreamModelName: selection.UpstreamModelName,
|
||||
IsModelMapped: selection.IsModelMapped,
|
||||
Settings: selection.Settings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -134,6 +182,7 @@ func HandleMjSubmit(r *ghttp.Request) {
|
||||
TenantID: middleware.GetTenantID(r.Context()),
|
||||
UserID: middleware.GetUserID(r.Context()),
|
||||
ApiKeyID: middleware.GetApiKeyID(r.Context()),
|
||||
ProjectID: middleware.GetProjectID(r.Context()),
|
||||
RequestID: r.GetCtxVar("RequestId").String(),
|
||||
Writer: r.Response.Writer,
|
||||
Scope: r.GetCtxVar("ApiKeyScope").String(),
|
||||
@@ -148,10 +197,15 @@ func HandleMjSubmit(r *ghttp.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
capture := NewResponseCaptureWriter(r.Response.Writer)
|
||||
rc.Writer = capture
|
||||
|
||||
relay_handler.HandleTaskSubmit(
|
||||
r.Context(), body, r.URL.Path, r.Header,
|
||||
rc, taskDataProvider, taskBillingProvider, channelMeta,
|
||||
)
|
||||
|
||||
go recordTaskSubmitAudit(r, rc, capture, body)
|
||||
}
|
||||
|
||||
// HandleAliImageSubmit 处理异步图片生成任务提交(POST /v1/images/generations/async)
|
||||
@@ -205,6 +259,7 @@ func HandleMjFetch(r *ghttp.Request) {
|
||||
TenantID: middleware.GetTenantID(r.Context()),
|
||||
UserID: middleware.GetUserID(r.Context()),
|
||||
ApiKeyID: middleware.GetApiKeyID(r.Context()),
|
||||
ProjectID: middleware.GetProjectID(r.Context()),
|
||||
RequestID: r.GetCtxVar("RequestId").String(),
|
||||
Writer: r.Response.Writer,
|
||||
}
|
||||
@@ -229,6 +284,7 @@ func HandleMjImage(r *ghttp.Request) {
|
||||
TenantID: middleware.GetTenantID(r.Context()),
|
||||
UserID: middleware.GetUserID(r.Context()),
|
||||
ApiKeyID: middleware.GetApiKeyID(r.Context()),
|
||||
ProjectID: middleware.GetProjectID(r.Context()),
|
||||
RequestID: r.GetCtxVar("RequestId").String(),
|
||||
Writer: r.Response.Writer,
|
||||
}
|
||||
@@ -247,3 +303,27 @@ func extractMjTaskID(r *ghttp.Request) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// recordTaskSubmitAudit 记录异步任务提交的审计日志
|
||||
func recordTaskSubmitAudit(r *ghttp.Request, rc *relay_handler.TaskRelayContext, capture *ResponseCaptureWriter, body []byte) {
|
||||
relayDataProvider.RecordAudit(context.Background(), &relay_common.AuditRecord{
|
||||
TenantID: rc.TenantID,
|
||||
UserID: rc.UserID,
|
||||
ApiKeyID: rc.ApiKeyID,
|
||||
ProjectID: rc.ProjectID,
|
||||
RequestID: rc.RequestID,
|
||||
Method: "POST",
|
||||
Path: r.URL.Path,
|
||||
QueryParams: r.URL.RawQuery,
|
||||
StatusCode: capture.StatusCode(),
|
||||
ClientIP: rc.ClientIP,
|
||||
UserAgent: r.Header.Get("User-Agent"),
|
||||
RequestBody: string(body),
|
||||
ResponseBody: capture.Body(),
|
||||
IsStream: false,
|
||||
RequestHeaders: captureRequestHeaders(r),
|
||||
ResponseHeaders: capture.ResponseHeaders(),
|
||||
TaskID: rc.TaskID,
|
||||
TaskStatus: "SUBMITTED",
|
||||
})
|
||||
}
|
||||
@@ -251,6 +251,10 @@ func (s *sAdmin) ListRequestAuditLogs(ctx context.Context, req *v1.RequestAuditL
|
||||
conditions = append(conditions, "a.request_id = ?")
|
||||
args = append(args, req.RequestId)
|
||||
}
|
||||
if req.TaskId != "" {
|
||||
conditions = append(conditions, "a.task_id = ?")
|
||||
args = append(args, req.TaskId)
|
||||
}
|
||||
if req.Method != "" {
|
||||
conditions = append(conditions, "a.method = ?")
|
||||
args = append(args, req.Method)
|
||||
|
||||
@@ -245,7 +245,7 @@ func (s *sAdmin) GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListR
|
||||
}
|
||||
|
||||
dataSQL := fmt.Sprintf(
|
||||
`SELECT u.id, u.tenant_id, COALESCE(tn.name, '') AS tenant_name, u.user_id, COALESCE(t.username, '') AS username, u.project_id, COALESCE(p.name, '') AS project_name, u.api_key_id, COALESCE(ak.name, '') AS api_key_name, u.channel_id, u.channel_name, u.channel_type, u.model_name, u.requested_model, u.upstream_model, u.relay_mode, u.request_type, u.input_tokens, u.output_tokens, u.cache_creation_tokens, u.cache_read_tokens, u.cache_creation_5m_tokens, u.cache_creation_1h_tokens, u.reasoning_tokens, u.audio_input_tokens, u.audio_output_tokens, u.image_output_tokens, u.input_cost, u.output_cost, u.cache_creation_cost, u.cache_read_cost, u.total_cost, u.actual_cost, u.currency, u.billing_mode, u.billing_source, u.rate_multiplier, u.latency_ms, u.first_token_ms, u.status, u.error_message, u.retry_index, u.client_ip, u.user_agent, u.service_tier, u.reasoning_effort, u.stream_end_reason, u.image_count, u.image_size, u.pre_deduct_amount, u.refund_amount, u.supplement_amount, u.billing_summary, u.billing_snapshot, u.inbound_endpoint, u.request_id, u.created_at
|
||||
`SELECT u.id, u.tenant_id, COALESCE(tn.name, '') AS tenant_name, u.user_id, COALESCE(t.username, '') AS username, u.project_id, COALESCE(p.name, '') AS project_name, u.api_key_id, COALESCE(ak.name, '') AS api_key_name, u.channel_id, u.channel_name, u.channel_type, u.model_name, u.requested_model, u.upstream_model, u.relay_mode, u.request_type, u.input_tokens, u.output_tokens, u.cache_creation_tokens, u.cache_read_tokens, u.cache_creation_5m_tokens, u.cache_creation_1h_tokens, u.reasoning_tokens, u.audio_input_tokens, u.audio_output_tokens, u.image_output_tokens, u.input_cost, u.output_cost, u.cache_creation_cost, u.cache_read_cost, u.total_cost, u.actual_cost, u.currency, u.billing_mode, u.billing_source, u.rate_multiplier, u.latency_ms, u.first_token_ms, u.status, u.error_message, u.retry_index, u.client_ip, u.user_agent, u.service_tier, u.reasoning_effort, u.stream_end_reason, u.image_count, u.image_size, u.pre_deduct_amount, u.refund_amount, u.supplement_amount, u.billing_summary, u.billing_snapshot, u.inbound_endpoint, u.request_id, u.task_id, u.created_at
|
||||
FROM %s%s ORDER BY u.created_at DESC LIMIT %d OFFSET %d`,
|
||||
fromClause, where, pageSize, (page-1)*pageSize,
|
||||
)
|
||||
|
||||
@@ -252,9 +252,10 @@ func (s *sAdmin) UpdateModel(ctx context.Context, req *v1.ModelUpdateReq) (*v1.M
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// DeleteModel 删除模型(同时删除定价记录)
|
||||
// DeleteModel 删除模型(同时删除定价记录和租户分配记录)
|
||||
func (s *sAdmin) DeleteModel(ctx context.Context, req *v1.ModelDeleteReq) (*v1.ModelDeleteRes, error) {
|
||||
_, _ = dao.MdlPricing.Ctx(ctx).Where("model_id", req.ID).Delete()
|
||||
_, _ = dao.MdlTenantModels.Ctx(ctx).Where("model_id", req.ID).Delete()
|
||||
_, err := dao.MdlModels.Ctx(ctx).Where("id", req.ID).Delete()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -23,6 +23,9 @@ func (s *sAdmin) TaskList(ctx context.Context, req *v1.TaskListReq) (*v1.TaskLis
|
||||
if req.Platform != "" {
|
||||
m = m.Where("platform", req.Platform)
|
||||
}
|
||||
if req.PublicTaskID != "" {
|
||||
m = m.Where("public_task_id", req.PublicTaskID)
|
||||
}
|
||||
|
||||
var tasks []struct {
|
||||
Id int64 `json:"id"`
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
v1 "github.com/qianfree/team-api/api/admin/v1"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
"github.com/qianfree/team-api/internal/logic/billing"
|
||||
@@ -83,22 +84,28 @@ func (s *sAdmin) BatchAssignModels(ctx context.Context, req *v1.TenantModelBatch
|
||||
assigned := 0
|
||||
|
||||
for _, a := range req.Assignments {
|
||||
var model *struct {
|
||||
g.Log().Debugf(ctx, "BatchAssignModels: tenant_id=%d, model_id=%d, enabled=%v", req.TenantID, a.ModelID, a.Enabled)
|
||||
|
||||
var model struct {
|
||||
ID int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
err := dao.MdlModels.Ctx(ctx).
|
||||
Where("id", a.ModelID).
|
||||
Scan(&model)
|
||||
if err != nil || model == nil || model.Status != "active" {
|
||||
g.Log().Debugf(ctx, "BatchAssignModels: model scan result id=%d, status=%q, err=%v", model.ID, model.Status, err)
|
||||
if err != nil || model.ID == 0 || model.Status != "active" {
|
||||
g.Log().Warningf(ctx, "BatchAssignModels: skip model_id=%d, err=%v, id=%d, status=%q", a.ModelID, err, model.ID, model.Status)
|
||||
continue
|
||||
}
|
||||
|
||||
count, _ := dao.MdlTenantModels.Ctx(ctx).
|
||||
count, countErr := dao.MdlTenantModels.Ctx(ctx).
|
||||
Where("tenant_id", req.TenantID).
|
||||
Where("model_id", a.ModelID).
|
||||
Count()
|
||||
g.Log().Debugf(ctx, "BatchAssignModels: existing count=%d, countErr=%v", count, countErr)
|
||||
if count > 0 {
|
||||
g.Log().Debugf(ctx, "BatchAssignModels: skip already assigned, model_id=%d", a.ModelID)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -108,13 +115,13 @@ func (s *sAdmin) BatchAssignModels(ctx context.Context, req *v1.TenantModelBatch
|
||||
maxConc = &concurrency
|
||||
}
|
||||
|
||||
var tiersJSON string
|
||||
var tiersJSON any
|
||||
if len(a.CustomPricingTiers) > 0 {
|
||||
b, _ := json.Marshal(a.CustomPricingTiers)
|
||||
tiersJSON = string(b)
|
||||
}
|
||||
|
||||
_, err = dao.MdlTenantModels.Ctx(ctx).Insert(do.MdlTenantModels{
|
||||
insertData := do.MdlTenantModels{
|
||||
TenantId: req.TenantID,
|
||||
ModelId: a.ModelID,
|
||||
Enabled: a.Enabled,
|
||||
@@ -129,9 +136,14 @@ func (s *sAdmin) BatchAssignModels(ctx context.Context, req *v1.TenantModelBatch
|
||||
CustomCacheCreationPrice: a.CustomCacheCreationPrice,
|
||||
CustomPricingTiers: tiersJSON,
|
||||
Multiplier: 1.0,
|
||||
})
|
||||
if err == nil {
|
||||
}
|
||||
g.Log().Debugf(ctx, "BatchAssignModels: inserting data tenant_id=%v, model_id=%v, enabled=%v, max_concurrency=%v", insertData.TenantId, insertData.ModelId, insertData.Enabled, insertData.MaxConcurrency)
|
||||
_, err = dao.MdlTenantModels.Ctx(ctx).Insert(insertData)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "BatchAssignModels: insert failed, model_id=%d, err=%v", a.ModelID, err)
|
||||
} else {
|
||||
assigned++
|
||||
g.Log().Debugf(ctx, "BatchAssignModels: insert success, model_id=%d, assigned=%d", a.ModelID, assigned)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -366,6 +366,7 @@ func buildUsageLogDO(record *common.UsageRecord) do.BilUsageLogs {
|
||||
|
||||
BillingSnapshot: jsonNullIfEmpty(record.BillingSnapshot),
|
||||
BillingSummary: record.BillingSummary,
|
||||
TaskId: record.TaskID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,6 +449,15 @@ func (p *DataProviderImpl) RecordAudit(ctx context.Context, record *common.Audit
|
||||
"response_headers": responseHeadersJSON,
|
||||
"forwarding_trace": forwardingTraceJSON,
|
||||
}
|
||||
|
||||
// 异步任务字段
|
||||
if record.TaskID != "" {
|
||||
insertData["task_id"] = record.TaskID
|
||||
}
|
||||
if record.TaskStatus != "" {
|
||||
insertData["task_status"] = record.TaskStatus
|
||||
}
|
||||
|
||||
_, insertErr := dao.AudRequestLogs.Ctx(bgCtx).Data(insertData).Insert()
|
||||
if insertErr != nil {
|
||||
g.Log().Errorf(bgCtx,
|
||||
@@ -457,6 +467,63 @@ func (p *DataProviderImpl) RecordAudit(ctx context.Context, record *common.Audit
|
||||
}()
|
||||
}
|
||||
|
||||
// UpdateTaskAudit implements DataProvider.UpdateTaskAudit
|
||||
// 异步任务完成时更新提交阶段写入的审计记录
|
||||
func (p *DataProviderImpl) UpdateTaskAudit(ctx context.Context, record *common.AuditRecord) {
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
g.Log().Errorf(context.Background(),
|
||||
"update task audit panic: task_id=%s panic=%v",
|
||||
record.TaskID, r)
|
||||
}
|
||||
}()
|
||||
|
||||
if record.TaskID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
bgCtx := context.Background()
|
||||
globalLevel, tenantLevel := lcommon.GetAuditLevels(bgCtx, record.TenantID)
|
||||
|
||||
// 按审计级别处理任务结果
|
||||
taskResult := record.TaskResult
|
||||
if globalLevel == lcommon.AuditLevelNone && tenantLevel == lcommon.AuditLevelNone {
|
||||
taskResult = ""
|
||||
} else if globalLevel == lcommon.AuditLevelMasked {
|
||||
taskResult = lcommon.MaskSensitiveData(taskResult)
|
||||
}
|
||||
taskResult = truncateBody(taskResult, 65536)
|
||||
|
||||
// 上游响应头仅在 full 级别记录
|
||||
upstreamHeadersJSON := "null"
|
||||
if globalLevel == lcommon.AuditLevelFull && record.TaskUpstreamHeaders != nil {
|
||||
b, _ := json.Marshal(record.TaskUpstreamHeaders)
|
||||
upstreamHeadersJSON = string(b)
|
||||
}
|
||||
|
||||
updateData := g.Map{
|
||||
"task_status": record.TaskStatus,
|
||||
"task_result": taskResult,
|
||||
"task_upstream_headers": upstreamHeadersJSON,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if record.TaskCompletedAt != nil {
|
||||
updateData["task_completed_at"] = record.TaskCompletedAt
|
||||
}
|
||||
|
||||
_, err := dao.AudRequestLogs.Ctx(bgCtx).
|
||||
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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func truncateBody(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
"github.com/qianfree/team-api/internal/logic/billing"
|
||||
"github.com/qianfree/team-api/internal/logic/relay"
|
||||
"github.com/qianfree/team-api/relay/common"
|
||||
"github.com/qianfree/team-api/relay/constant"
|
||||
"github.com/qianfree/team-api/relay/taskchannel"
|
||||
@@ -102,6 +104,9 @@ func handleTimedOutTasks(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
g.Log().Infof(ctx, "poll: task %s timed out", t.PublicTaskID)
|
||||
|
||||
// 更新审计记录
|
||||
recordTaskCompletionAudit(t, "TIMEOUT", "", nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +208,12 @@ func pollSingleTask(ctx context.Context, adaptor common.TaskAdaptor, channel *co
|
||||
if task.StartTime == nil {
|
||||
task.StartTime = &now
|
||||
}
|
||||
// 保留上游返回的 token 用量
|
||||
if taskInfo.TotalTokens > 0 {
|
||||
task.CompletionTokens = taskInfo.CompletionTokens
|
||||
task.PromptTokens = taskInfo.PromptTokens
|
||||
task.TotalTokens = taskInfo.TotalTokens
|
||||
}
|
||||
}
|
||||
|
||||
if err := DefaultAsyncProvider.UpdateTaskCAS(ctx, task, oldStatus); err != nil {
|
||||
@@ -244,6 +255,12 @@ func pollSingleTask(ctx context.Context, adaptor common.TaskAdaptor, channel *co
|
||||
}
|
||||
g.Log().Infof(ctx, "poll: task %s completed", task.PublicTaskID)
|
||||
|
||||
// 记录用量日志
|
||||
recordTaskUsage(task, true, "")
|
||||
|
||||
// 更新审计记录
|
||||
recordTaskCompletionAudit(task, "SUCCESS", string(body), upstreamRespHeaders(resp))
|
||||
|
||||
} else if taskInfo.Status == common.TaskStatusFailure {
|
||||
// 退还预扣费用
|
||||
if task.PreDeductAmount > 0 && !task.BillingSettled {
|
||||
@@ -256,5 +273,76 @@ func pollSingleTask(ctx context.Context, adaptor common.TaskAdaptor, channel *co
|
||||
}
|
||||
}
|
||||
g.Log().Infof(ctx, "poll: task %s failed: %s", task.PublicTaskID, task.FailReason)
|
||||
|
||||
// 记录用量日志
|
||||
recordTaskUsage(task, false, task.FailReason)
|
||||
|
||||
// 更新审计记录
|
||||
recordTaskCompletionAudit(task, "FAILURE", string(body), upstreamRespHeaders(resp))
|
||||
}
|
||||
}
|
||||
|
||||
// recordTaskUsage 异步记录视频/音乐等异步任务的用量日志
|
||||
func recordTaskUsage(task *common.AsyncTask, success bool, errMsg string) {
|
||||
latencyMs := 0
|
||||
if task.SubmitTime != nil && task.FinishTime != nil {
|
||||
latencyMs = int(task.FinishTime.Sub(*task.SubmitTime).Milliseconds())
|
||||
}
|
||||
|
||||
status := "success"
|
||||
if !success {
|
||||
status = "error"
|
||||
}
|
||||
|
||||
relay.NewDataProvider().RecordUsage(context.Background(), &common.UsageRecord{
|
||||
TenantID: task.TenantID,
|
||||
UserID: task.UserID,
|
||||
ApiKeyID: task.ApiKeyID,
|
||||
ChannelID: task.ChannelID,
|
||||
ModelName: task.ModelName,
|
||||
RelayMode: int(constant.RelayModeVideoGenerations),
|
||||
RequestType: 3, // async
|
||||
LatencyMs: float64(latencyMs),
|
||||
IsStream: false,
|
||||
Success: success,
|
||||
RequestID: task.RequestID,
|
||||
Status: status,
|
||||
ErrorMessage: errMsg,
|
||||
PromptTokens: task.PromptTokens,
|
||||
CompletionTokens: task.CompletionTokens,
|
||||
TotalTokens: task.TotalTokens,
|
||||
TotalCost: task.ActualCost,
|
||||
ActualCost: task.ActualCost,
|
||||
PreDeductAmount: task.PreDeductAmount,
|
||||
BillingMode: "per_request",
|
||||
BillingSource: "task",
|
||||
TaskID: task.PublicTaskID,
|
||||
})
|
||||
}
|
||||
|
||||
// recordTaskCompletionAudit 更新提交阶段写入的审计记录,补充异步任务最终结果
|
||||
func recordTaskCompletionAudit(task *common.AsyncTask, status string, resultBody string, upstreamHeaders map[string]string) {
|
||||
now := time.Now()
|
||||
relay.NewDataProvider().UpdateTaskAudit(context.Background(), &common.AuditRecord{
|
||||
TenantID: task.TenantID,
|
||||
TaskID: task.PublicTaskID,
|
||||
TaskStatus: status,
|
||||
TaskResult: resultBody,
|
||||
TaskUpstreamHeaders: upstreamHeaders,
|
||||
TaskCompletedAt: &now,
|
||||
})
|
||||
}
|
||||
|
||||
// upstreamRespHeaders 从上游 HTTP 响应中提取响应头
|
||||
func upstreamRespHeaders(resp *http.Response) map[string]string {
|
||||
if resp == nil || resp.Header == nil {
|
||||
return nil
|
||||
}
|
||||
headers := make(map[string]string)
|
||||
for k, vals := range resp.Header {
|
||||
if len(vals) > 0 {
|
||||
headers[k] = vals[0]
|
||||
}
|
||||
}
|
||||
return headers
|
||||
}
|
||||
@@ -7,8 +7,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
"github.com/qianfree/team-api/internal/logic/relay"
|
||||
do "github.com/qianfree/team-api/internal/model/do"
|
||||
"github.com/qianfree/team-api/internal/utility/crypto"
|
||||
"github.com/qianfree/team-api/relay/common"
|
||||
)
|
||||
|
||||
@@ -21,6 +25,7 @@ var DefaultAsyncProvider = &AsyncProvider{}
|
||||
func (p *AsyncProvider) CreateTask(ctx context.Context, task *common.AsyncTask) error {
|
||||
_, err := dao.TskModelTasks.Ctx(ctx).Insert(map[string]any{
|
||||
"public_task_id": task.PublicTaskID,
|
||||
"request_id": task.RequestID,
|
||||
"platform": task.Platform,
|
||||
"action": task.Action,
|
||||
"status": task.Status,
|
||||
@@ -101,6 +106,7 @@ func (p *AsyncProvider) GetTaskByPublicID(ctx context.Context, publicTaskID stri
|
||||
var row struct {
|
||||
ID int64 `json:"id"`
|
||||
PublicTaskID string `json:"public_task_id"`
|
||||
RequestId string `json:"request_id"`
|
||||
Platform string `json:"platform"`
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status"`
|
||||
@@ -136,6 +142,7 @@ func (p *AsyncProvider) GetTaskByPublicID(ctx context.Context, publicTaskID stri
|
||||
return &common.AsyncTask{
|
||||
ID: row.ID,
|
||||
PublicTaskID: row.PublicTaskID,
|
||||
RequestID: row.RequestId,
|
||||
Platform: row.Platform,
|
||||
Action: row.Action,
|
||||
Status: row.Status,
|
||||
@@ -166,6 +173,7 @@ func (p *AsyncProvider) GetTaskByPublicIDAndUser(ctx context.Context, publicTask
|
||||
var row struct {
|
||||
ID int64 `json:"id"`
|
||||
PublicTaskID string `json:"public_task_id"`
|
||||
RequestId string `json:"request_id"`
|
||||
Platform string `json:"platform"`
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status"`
|
||||
@@ -201,6 +209,7 @@ func (p *AsyncProvider) GetTaskByPublicIDAndUser(ctx context.Context, publicTask
|
||||
return &common.AsyncTask{
|
||||
ID: row.ID,
|
||||
PublicTaskID: row.PublicTaskID,
|
||||
RequestID: row.RequestId,
|
||||
Platform: row.Platform,
|
||||
Action: row.Action,
|
||||
Status: row.Status,
|
||||
@@ -230,6 +239,7 @@ func (p *AsyncProvider) GetNonTerminalTasks(ctx context.Context, limit int) ([]*
|
||||
var rows []struct {
|
||||
ID int64 `json:"id"`
|
||||
PublicTaskID string `json:"public_task_id"`
|
||||
RequestId string `json:"request_id"`
|
||||
Platform string `json:"platform"`
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status"`
|
||||
@@ -262,6 +272,7 @@ func (p *AsyncProvider) GetNonTerminalTasks(ctx context.Context, limit int) ([]*
|
||||
tasks = append(tasks, &common.AsyncTask{
|
||||
ID: r.ID,
|
||||
PublicTaskID: r.PublicTaskID,
|
||||
RequestID: r.RequestId,
|
||||
Platform: r.Platform,
|
||||
Action: r.Action,
|
||||
Status: r.Status,
|
||||
@@ -290,6 +301,7 @@ func (p *AsyncProvider) GetTimedOutTasks(ctx context.Context, cutoffUnix int64,
|
||||
var rows []struct {
|
||||
ID int64 `json:"id"`
|
||||
PublicTaskID string `json:"public_task_id"`
|
||||
RequestId string `json:"request_id"`
|
||||
Platform string `json:"platform"`
|
||||
Status string `json:"status"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
@@ -316,6 +328,7 @@ func (p *AsyncProvider) GetTimedOutTasks(ctx context.Context, cutoffUnix int64,
|
||||
tasks = append(tasks, &common.AsyncTask{
|
||||
ID: r.ID,
|
||||
PublicTaskID: r.PublicTaskID,
|
||||
RequestID: r.RequestId,
|
||||
Platform: r.Platform,
|
||||
Status: r.Status,
|
||||
TenantID: r.TenantID,
|
||||
@@ -331,19 +344,18 @@ func (p *AsyncProvider) GetTimedOutTasks(ctx context.Context, cutoffUnix int64,
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
// GetChannelByID 获取渠道基本信息
|
||||
// GetChannelByID 获取渠道基本信息(含从 chn_channel_keys 解密的 API Key)
|
||||
func (p *AsyncProvider) GetChannelByID(ctx context.Context, channelID int64) (*common.ChannelBasicInfo, error) {
|
||||
var row struct {
|
||||
ID int64 `json:"id"`
|
||||
Type int `json:"type"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"base_url"`
|
||||
ApiKey string `json:"api_key"`
|
||||
Settings json.RawMessage `json:"settings"`
|
||||
}
|
||||
err := dao.ChnChannels.Ctx(ctx).
|
||||
Where("id", channelID).
|
||||
Fields("id, type, name, base_url, api_key, settings").
|
||||
Fields("id, type, name, base_url, settings").
|
||||
Scan(&row)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "query channel failed: id=%d", channelID)
|
||||
@@ -351,12 +363,50 @@ func (p *AsyncProvider) GetChannelByID(ctx context.Context, channelID int64) (*c
|
||||
if row.ID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 从 chn_channel_keys 获取解密后的 API Key
|
||||
apiKey, keyErr := getChannelApiKey(ctx, channelID)
|
||||
if keyErr != nil {
|
||||
return nil, gerror.Wrapf(keyErr, "get channel key failed: channelID=%d", channelID)
|
||||
}
|
||||
|
||||
return &common.ChannelBasicInfo{
|
||||
ID: row.ID,
|
||||
Type: row.Type,
|
||||
Name: row.Name,
|
||||
BaseURL: row.BaseURL,
|
||||
ApiKey: row.ApiKey,
|
||||
ApiKey: apiKey,
|
||||
Settings: row.Settings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getChannelApiKey 从 chn_channel_keys 获取并解密渠道 API Key
|
||||
func getChannelApiKey(ctx context.Context, channelID int64) (string, error) {
|
||||
type keyRow struct {
|
||||
ID int64 `json:"id"`
|
||||
EncryptedKey string `json:"encrypted_key"`
|
||||
}
|
||||
|
||||
var key *keyRow
|
||||
err := dao.ChnChannelKeys.Ctx(ctx).
|
||||
Where("channel_id", channelID).
|
||||
Where("status", "active").
|
||||
Fields("id, encrypted_key").
|
||||
Scan(&key)
|
||||
if err != nil || key == nil {
|
||||
return "", fmt.Errorf("no active key found for channel %d", channelID)
|
||||
}
|
||||
|
||||
// 更新最后使用时间
|
||||
dao.ChnChannelKeys.Ctx(ctx).
|
||||
Where("id", key.ID).
|
||||
Data(do.ChnChannelKeys{LastUsedAt: gtime.Now()}).
|
||||
Update()
|
||||
|
||||
encKey := relay.GetEncryptionKey()
|
||||
decrypted, err := crypto.DecryptString(encKey, key.EncryptedKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decrypt key failed: %w", err)
|
||||
}
|
||||
return decrypted, nil
|
||||
}
|
||||
@@ -173,6 +173,10 @@ func (s *sTenant) TenantRequestAuditLogs(ctx context.Context, req *v1.TenantRequ
|
||||
conditions = append(conditions, "a.request_id = ?")
|
||||
args = append(args, req.RequestId)
|
||||
}
|
||||
if req.TaskId != "" {
|
||||
conditions = append(conditions, "a.task_id = ?")
|
||||
args = append(args, req.TaskId)
|
||||
}
|
||||
if req.Path != "" {
|
||||
conditions = append(conditions, "a.path LIKE ?")
|
||||
args = append(args, "%"+req.Path+"%")
|
||||
@@ -204,7 +208,7 @@ func (s *sTenant) TenantRequestAuditLogs(ctx context.Context, req *v1.TenantRequ
|
||||
}
|
||||
|
||||
dataSQL := fmt.Sprintf(
|
||||
`SELECT a.id, a.request_id, COALESCE(t.username, '') AS username, a.project_id, COALESCE(p.name, '') AS project_name, a.user_id, a.method, a.path, a.query_params, a.status_code, a.client_ip, a.user_agent, a.latency_ms, a.first_token_ms, a.tenant_audit_level AS audit_level, a.created_at
|
||||
`SELECT a.id, a.request_id, COALESCE(t.username, '') AS username, a.project_id, COALESCE(p.name, '') AS project_name, a.user_id, a.method, a.path, a.query_params, a.status_code, a.client_ip, a.user_agent, a.latency_ms, a.first_token_ms, a.tenant_audit_level AS audit_level, a.task_id, a.task_status, a.task_completed_at, a.created_at
|
||||
FROM %s WHERE %s ORDER BY a.created_at DESC LIMIT %d OFFSET %d`,
|
||||
fromClause, where, pageSize, (page-1)*pageSize,
|
||||
)
|
||||
@@ -263,5 +267,7 @@ func (s *sTenant) TenantRequestAuditLogDetail(ctx context.Context, req *v1.Tenan
|
||||
// 请求头/响应头仅管理后台可见,租户不可见
|
||||
delete(detail, "request_headers")
|
||||
delete(detail, "response_headers")
|
||||
// 异步任务上游响应头仅管理后台可见
|
||||
delete(detail, "task_upstream_headers")
|
||||
return &v1.TenantRequestAuditLogDetailRes{Data: detail}, nil
|
||||
}
|
||||
@@ -76,8 +76,8 @@ func (s *sTenant) PersonalDashboard(ctx context.Context, req *v1.PersonalDashboa
|
||||
|
||||
errorRate := v1.PersonalErrorRate{}
|
||||
reqTypeItems := []v1.PersonalReqTypeItem{}
|
||||
reqTypeLabels := map[int]string{1: "同步", 2: "流式", 3: "WebSocket"}
|
||||
reqTypeNames := map[int]string{1: "sync", 2: "stream", 3: "websocket"}
|
||||
reqTypeLabels := map[int]string{1: "同步", 2: "流式", 3: "异步", 4: "WebSocket"}
|
||||
reqTypeNames := map[int]string{1: "sync", 2: "stream", 3: "async", 4: "websocket"}
|
||||
totalReqs := 0
|
||||
for _, r := range reqTypeRows {
|
||||
errorRate.Total += r.Total
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/tenant/v1"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
)
|
||||
|
||||
// TenantTaskList 租户异步任务列表
|
||||
// owner/admin 可查看租户所有任务,member 只能查看自己的任务
|
||||
func (s *sTenant) TenantTaskList(ctx context.Context, req *v1.TenantTaskListReq) (*v1.TenantTaskListRes, error) {
|
||||
tenantID := ctxTenantID(ctx)
|
||||
role := ctxUserRole(ctx)
|
||||
userID := ctxUserID(ctx)
|
||||
|
||||
m := dao.TskModelTasks.Ctx(ctx).
|
||||
LeftJoin("tnt_users u", "u.id = tsk_model_tasks.user_id AND u.tenant_id = tsk_model_tasks.tenant_id").
|
||||
Fields("tsk_model_tasks.*, COALESCE(u.username, '') as username").
|
||||
Where("tsk_model_tasks.tenant_id", tenantID).
|
||||
OrderDesc("tsk_model_tasks.id")
|
||||
|
||||
// member 只能看自己的任务
|
||||
if role == "member" {
|
||||
m = m.Where("tsk_model_tasks.user_id", userID)
|
||||
}
|
||||
|
||||
if req.Status != "" {
|
||||
m = m.Where("tsk_model_tasks.status", req.Status)
|
||||
}
|
||||
if req.Platform != "" {
|
||||
m = m.Where("tsk_model_tasks.platform", req.Platform)
|
||||
}
|
||||
if req.PublicTaskID != "" {
|
||||
m = m.Where("tsk_model_tasks.public_task_id", req.PublicTaskID)
|
||||
}
|
||||
|
||||
var tasks []struct {
|
||||
Id int64 `json:"id"`
|
||||
PublicTaskId string `json:"public_task_id"`
|
||||
Platform string `json:"platform"`
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status"`
|
||||
Progress string `json:"progress"`
|
||||
ModelName string `json:"model_name"`
|
||||
FailReason string `json:"fail_reason"`
|
||||
PreDeductAmount float64 `json:"pre_deduct_amount"`
|
||||
ActualCost float64 `json:"actual_cost"`
|
||||
BillingSettled bool `json:"billing_settled"`
|
||||
ResultUrl string `json:"result_url"`
|
||||
Username string `json:"username"`
|
||||
SubmitTime *string `json:"submit_time"`
|
||||
FinishTime *string `json:"finish_time"`
|
||||
CreatedAt *string `json:"created_at"`
|
||||
}
|
||||
|
||||
var total int
|
||||
err := m.Page(req.Page, req.PageSize).ScanAndCount(&tasks, &total, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list := make([]v1.TenantTaskItem, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
item := v1.TenantTaskItem{
|
||||
ID: t.Id,
|
||||
PublicTaskID: t.PublicTaskId,
|
||||
Platform: t.Platform,
|
||||
Action: t.Action,
|
||||
Status: t.Status,
|
||||
Progress: t.Progress,
|
||||
ModelName: t.ModelName,
|
||||
FailReason: t.FailReason,
|
||||
PreDeductAmount: t.PreDeductAmount,
|
||||
ActualCost: t.ActualCost,
|
||||
BillingSettled: t.BillingSettled,
|
||||
ResultURL: t.ResultUrl,
|
||||
Username: t.Username,
|
||||
}
|
||||
if t.SubmitTime != nil {
|
||||
item.SubmitTime = *t.SubmitTime
|
||||
}
|
||||
if t.FinishTime != nil {
|
||||
item.FinishTime = *t.FinishTime
|
||||
}
|
||||
if t.CreatedAt != nil {
|
||||
item.CreatedAt = *t.CreatedAt
|
||||
}
|
||||
list = append(list, item)
|
||||
}
|
||||
|
||||
return &v1.TenantTaskListRes{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TenantTaskDetail 租户异步任务详情
|
||||
// owner/admin 可查看租户所有任务,member 只能查看自己的任务
|
||||
func (s *sTenant) TenantTaskDetail(ctx context.Context, req *v1.TenantTaskDetailReq) (*v1.TenantTaskDetailRes, error) {
|
||||
tenantID := ctxTenantID(ctx)
|
||||
role := ctxUserRole(ctx)
|
||||
userID := ctxUserID(ctx)
|
||||
|
||||
var task struct {
|
||||
Id int64 `json:"id"`
|
||||
PublicTaskId string `json:"public_task_id"`
|
||||
Platform string `json:"platform"`
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status"`
|
||||
Progress string `json:"progress"`
|
||||
ModelName string `json:"model_name"`
|
||||
FailReason string `json:"fail_reason"`
|
||||
PreDeductAmount float64 `json:"pre_deduct_amount"`
|
||||
ActualCost float64 `json:"actual_cost"`
|
||||
BillingSettled bool `json:"billing_settled"`
|
||||
ResultUrl string `json:"result_url"`
|
||||
UserId int64 `json:"user_id"`
|
||||
SubmitTime *string `json:"submit_time"`
|
||||
FinishTime *string `json:"finish_time"`
|
||||
CreatedAt *string `json:"created_at"`
|
||||
}
|
||||
|
||||
m := dao.TskModelTasks.Ctx(ctx).Where("id", req.ID).Where("tenant_id", tenantID)
|
||||
// member 只能看自己的任务
|
||||
if role == "member" {
|
||||
m = m.Where("user_id", userID)
|
||||
}
|
||||
|
||||
err := m.Scan(&task)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if task.Id == 0 {
|
||||
return nil, common.NewNotFoundError("任务")
|
||||
}
|
||||
|
||||
item := v1.TenantTaskItem{
|
||||
ID: task.Id,
|
||||
PublicTaskID: task.PublicTaskId,
|
||||
Platform: task.Platform,
|
||||
Action: task.Action,
|
||||
Status: task.Status,
|
||||
Progress: task.Progress,
|
||||
ModelName: task.ModelName,
|
||||
FailReason: task.FailReason,
|
||||
PreDeductAmount: task.PreDeductAmount,
|
||||
ActualCost: task.ActualCost,
|
||||
BillingSettled: task.BillingSettled,
|
||||
ResultURL: task.ResultUrl,
|
||||
}
|
||||
if task.SubmitTime != nil {
|
||||
item.SubmitTime = *task.SubmitTime
|
||||
}
|
||||
if task.FinishTime != nil {
|
||||
item.FinishTime = *task.FinishTime
|
||||
}
|
||||
if task.CreatedAt != nil {
|
||||
item.CreatedAt = *task.CreatedAt
|
||||
}
|
||||
|
||||
return &v1.TenantTaskDetailRes{
|
||||
Task: item,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// =================================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// =================================================================================
|
||||
|
||||
package do
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// AudContentFilterLogs is the golang structure of table aud_content_filter_logs for DAO operations like Where/Data.
|
||||
type AudContentFilterLogs struct {
|
||||
g.Meta `orm:"table:aud_content_filter_logs, do:true"`
|
||||
Id any // 主键ID
|
||||
TenantId any // 租户ID
|
||||
UserId any // 用户ID
|
||||
ApiKeyId any // API Key ID
|
||||
RequestId any // 请求唯一ID
|
||||
Method any // HTTP 方法
|
||||
Path any // 请求路径
|
||||
ClientIp any // 客户端 IP
|
||||
FilterMode any // 过滤模式:log / replace / block
|
||||
MatchedWords any // 命中的敏感词列表(JSONB 数组)
|
||||
OriginalSnippet any // 原始请求体片段(截断存储,仅 replace 模式)
|
||||
Blocked any // 是否被拦截(mode=block 时为 true)
|
||||
CreatedAt *gtime.Time // 创建时间
|
||||
ProjectId any // 项目ID
|
||||
}
|
||||
@@ -11,30 +11,35 @@ import (
|
||||
|
||||
// AudRequestLogs is the golang structure of table aud_request_logs for DAO operations like Where/Data.
|
||||
type AudRequestLogs struct {
|
||||
g.Meta `orm:"table:aud_request_logs, do:true"`
|
||||
Id any // 主键ID
|
||||
TenantId any // 租户ID
|
||||
UserId any // 用户ID
|
||||
ApiKeyId any // 使用的 API Key ID
|
||||
RequestId any // 请求唯一ID(关联全链路追踪)
|
||||
Method any // HTTP 方法(GET/POST/PUT/DELETE)
|
||||
Path any // 请求路径
|
||||
QueryParams any // 查询参数(URL Query String)
|
||||
StatusCode any // HTTP 响应状态码
|
||||
ClientIp any // 客户端 IP
|
||||
UserAgent any // 客户端 User-Agent
|
||||
RequestBody any // 请求体(敏感字段脱敏后存储)
|
||||
ResponseBody any // 响应体(截断后存储)
|
||||
LatencyMs any // 请求延迟(毫秒)
|
||||
AuditLevel any // 审计级别:full(完整记录)/ masked(脱敏记录)/ question_only(仅记录提问)/ none(不记录)
|
||||
CreatedAt *gtime.Time // 创建时间
|
||||
UpdatedAt *gtime.Time // 更新时间
|
||||
TenantRequestBody any // 租户级请求体(按租户审计级别处理)
|
||||
TenantResponseBody any // 租户级响应体(按租户审计级别处理)
|
||||
TenantAuditLevel any // 租户审计级别:full/full_text/masked/question_only/none
|
||||
ProjectId any // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目)
|
||||
FirstTokenMs any // 首个 Token 出现的用时(毫秒),仅流式请求有值
|
||||
RequestHeaders any // 请求头信息(仅审计级别为 all 时记录,管理后台调试用)
|
||||
ResponseHeaders any // 响应头信息(仅审计级别为 all 时记录,管理后台调试用)
|
||||
ForwardingTrace any // 请求转发路径追踪(仅管理员可见)
|
||||
g.Meta `orm:"table:aud_request_logs, do:true"`
|
||||
Id any // 主键ID
|
||||
TenantId any // 租户ID
|
||||
UserId any // 用户ID
|
||||
ApiKeyId any // 使用的 API Key ID
|
||||
RequestId any // 请求唯一ID(关联全链路追踪)
|
||||
Method any // HTTP 方法(GET/POST/PUT/DELETE)
|
||||
Path any // 请求路径
|
||||
QueryParams any // 查询参数(URL Query String)
|
||||
StatusCode any // HTTP 响应状态码
|
||||
ClientIp any // 客户端 IP
|
||||
UserAgent any // 客户端 User-Agent
|
||||
RequestBody any // 请求体(敏感字段脱敏后存储)
|
||||
ResponseBody any // 响应体(截断后存储)
|
||||
LatencyMs any // 请求延迟(毫秒)
|
||||
AuditLevel any // 审计级别:full(完整记录)/ masked(脱敏记录)/ question_only(仅记录提问)/ none(不记录)
|
||||
CreatedAt *gtime.Time // 创建时间
|
||||
UpdatedAt *gtime.Time // 更新时间
|
||||
TenantRequestBody any // 租户级请求体(按租户审计级别处理)
|
||||
TenantResponseBody any // 租户级响应体(按租户审计级别处理)
|
||||
TenantAuditLevel any // 租户审计级别:full/full_text/masked/question_only/none
|
||||
ProjectId any // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目)
|
||||
FirstTokenMs any // 首个 Token 出现的用时(毫秒),仅流式请求有值
|
||||
RequestHeaders any // 请求头信息(仅审计级别为 all 时记录,管理后台调试用)
|
||||
ResponseHeaders any // 响应头信息(仅审计级别为 all 时记录,管理后台调试用)
|
||||
ForwardingTrace any // 请求转发路径追踪(仅管理员可见)
|
||||
TaskId any // 异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id
|
||||
TaskStatus any // 异步任务终态:SUCCESS / FAILURE
|
||||
TaskResult any // 异步任务完成时上游返回的原始响应体
|
||||
TaskUpstreamHeaders any // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)
|
||||
TaskCompletedAt *gtime.Time // 异步任务达到终态的时间
|
||||
}
|
||||
@@ -39,7 +39,7 @@ type BilUsageLogs struct {
|
||||
ActualCost any // 实际扣除费用(含折扣后)
|
||||
RequestedModel any // 用户请求的模型名
|
||||
UpstreamModel any // 上游实际模型名(模型映射后)
|
||||
RequestType any // 请求类型: 1=sync, 2=stream, 3=websocket
|
||||
RequestType any // 请求类型: 1=sync, 2=stream, 3=async, 4=websocket
|
||||
UserAgent any // 客户端 User-Agent
|
||||
FirstTokenMs any // 首 token 延迟(毫秒)
|
||||
ServiceTier any // 服务等级 (default/flex等)
|
||||
@@ -68,4 +68,5 @@ type BilUsageLogs struct {
|
||||
UpstreamEndpoint any // 上游实际请求路径
|
||||
BillingSnapshot any // 完整计费计算过程快照(JSONB)
|
||||
ProjectId any // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目)
|
||||
TaskId any // 异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空
|
||||
}
|
||||
@@ -14,6 +14,7 @@ type TskModelTasks struct {
|
||||
g.Meta `orm:"table:tsk_model_tasks, do:true"`
|
||||
Id any //
|
||||
PublicTaskId any //
|
||||
RequestId any // 任务提交时的原始请求 ID
|
||||
Platform any //
|
||||
Action any //
|
||||
Status any //
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// =================================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// =================================================================================
|
||||
|
||||
package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// AudContentFilterLogs is the golang structure for table aud_content_filter_logs.
|
||||
type AudContentFilterLogs struct {
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
|
||||
UserId int64 `json:"user_id" orm:"user_id" description:"用户ID"` // 用户ID
|
||||
ApiKeyId int64 `json:"api_key_id" orm:"api_key_id" description:"API Key ID"` // API Key ID
|
||||
RequestId string `json:"request_id" orm:"request_id" description:"请求唯一ID"` // 请求唯一ID
|
||||
Method string `json:"method" orm:"method" description:"HTTP 方法"` // HTTP 方法
|
||||
Path string `json:"path" orm:"path" description:"请求路径"` // 请求路径
|
||||
ClientIp string `json:"client_ip" orm:"client_ip" description:"客户端 IP"` // 客户端 IP
|
||||
FilterMode string `json:"filter_mode" orm:"filter_mode" description:"过滤模式:log / replace / block"` // 过滤模式:log / replace / block
|
||||
MatchedWords string `json:"matched_words" orm:"matched_words" description:"命中的敏感词列表(JSONB 数组)"` // 命中的敏感词列表(JSONB 数组)
|
||||
OriginalSnippet string `json:"original_snippet" orm:"original_snippet" description:"原始请求体片段(截断存储,仅 replace 模式)"` // 原始请求体片段(截断存储,仅 replace 模式)
|
||||
Blocked bool `json:"blocked" orm:"blocked" description:"是否被拦截(mode=block 时为 true)"` // 是否被拦截(mode=block 时为 true)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
ProjectId int64 `json:"project_id" orm:"project_id" description:"项目ID"` // 项目ID
|
||||
}
|
||||
@@ -10,29 +10,34 @@ import (
|
||||
|
||||
// AudRequestLogs is the golang structure for table aud_request_logs.
|
||||
type AudRequestLogs struct {
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
|
||||
UserId int64 `json:"user_id" orm:"user_id" description:"用户ID"` // 用户ID
|
||||
ApiKeyId int64 `json:"api_key_id" orm:"api_key_id" description:"使用的 API Key ID"` // 使用的 API Key ID
|
||||
RequestId string `json:"request_id" orm:"request_id" description:"请求唯一ID(关联全链路追踪)"` // 请求唯一ID(关联全链路追踪)
|
||||
Method string `json:"method" orm:"method" description:"HTTP 方法(GET/POST/PUT/DELETE)"` // HTTP 方法(GET/POST/PUT/DELETE)
|
||||
Path string `json:"path" orm:"path" description:"请求路径"` // 请求路径
|
||||
QueryParams string `json:"query_params" orm:"query_params" description:"查询参数(URL Query String)"` // 查询参数(URL Query String)
|
||||
StatusCode int `json:"status_code" orm:"status_code" description:"HTTP 响应状态码"` // HTTP 响应状态码
|
||||
ClientIp string `json:"client_ip" orm:"client_ip" description:"客户端 IP"` // 客户端 IP
|
||||
UserAgent string `json:"user_agent" orm:"user_agent" description:"客户端 User-Agent"` // 客户端 User-Agent
|
||||
RequestBody string `json:"request_body" orm:"request_body" description:"请求体(敏感字段脱敏后存储)"` // 请求体(敏感字段脱敏后存储)
|
||||
ResponseBody string `json:"response_body" orm:"response_body" description:"响应体(截断后存储)"` // 响应体(截断后存储)
|
||||
LatencyMs int `json:"latency_ms" orm:"latency_ms" description:"请求延迟(毫秒)"` // 请求延迟(毫秒)
|
||||
AuditLevel string `json:"audit_level" orm:"audit_level" description:"审计级别:full(完整记录)/ masked(脱敏记录)/ question_only(仅记录提问)/ none(不记录)"` // 审计级别:full(完整记录)/ masked(脱敏记录)/ question_only(仅记录提问)/ none(不记录)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
TenantRequestBody string `json:"tenant_request_body" orm:"tenant_request_body" description:"租户级请求体(按租户审计级别处理)"` // 租户级请求体(按租户审计级别处理)
|
||||
TenantResponseBody string `json:"tenant_response_body" orm:"tenant_response_body" description:"租户级响应体(按租户审计级别处理)"` // 租户级响应体(按租户审计级别处理)
|
||||
TenantAuditLevel string `json:"tenant_audit_level" orm:"tenant_audit_level" description:"租户审计级别:full/full_text/masked/question_only/none"` // 租户审计级别:full/full_text/masked/question_only/none
|
||||
ProjectId int64 `json:"project_id" orm:"project_id" description:"关联项目ID(通过API Key关联,NULL表示个人密钥无项目)"` // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目)
|
||||
FirstTokenMs int `json:"first_token_ms" orm:"first_token_ms" description:"首个 Token 出现的用时(毫秒),仅流式请求有值"` // 首个 Token 出现的用时(毫秒),仅流式请求有值
|
||||
RequestHeaders string `json:"request_headers" orm:"request_headers" description:"请求头信息(仅审计级别为 all 时记录,管理后台调试用)"` // 请求头信息(仅审计级别为 all 时记录,管理后台调试用)
|
||||
ResponseHeaders string `json:"response_headers" orm:"response_headers" description:"响应头信息(仅审计级别为 all 时记录,管理后台调试用)"` // 响应头信息(仅审计级别为 all 时记录,管理后台调试用)
|
||||
ForwardingTrace string `json:"forwarding_trace" orm:"forwarding_trace" description:"请求转发路径追踪(仅管理员可见)"` // 请求转发路径追踪(仅管理员可见)
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
|
||||
UserId int64 `json:"user_id" orm:"user_id" description:"用户ID"` // 用户ID
|
||||
ApiKeyId int64 `json:"api_key_id" orm:"api_key_id" description:"使用的 API Key ID"` // 使用的 API Key ID
|
||||
RequestId string `json:"request_id" orm:"request_id" description:"请求唯一ID(关联全链路追踪)"` // 请求唯一ID(关联全链路追踪)
|
||||
Method string `json:"method" orm:"method" description:"HTTP 方法(GET/POST/PUT/DELETE)"` // HTTP 方法(GET/POST/PUT/DELETE)
|
||||
Path string `json:"path" orm:"path" description:"请求路径"` // 请求路径
|
||||
QueryParams string `json:"query_params" orm:"query_params" description:"查询参数(URL Query String)"` // 查询参数(URL Query String)
|
||||
StatusCode int `json:"status_code" orm:"status_code" description:"HTTP 响应状态码"` // HTTP 响应状态码
|
||||
ClientIp string `json:"client_ip" orm:"client_ip" description:"客户端 IP"` // 客户端 IP
|
||||
UserAgent string `json:"user_agent" orm:"user_agent" description:"客户端 User-Agent"` // 客户端 User-Agent
|
||||
RequestBody string `json:"request_body" orm:"request_body" description:"请求体(敏感字段脱敏后存储)"` // 请求体(敏感字段脱敏后存储)
|
||||
ResponseBody string `json:"response_body" orm:"response_body" description:"响应体(截断后存储)"` // 响应体(截断后存储)
|
||||
LatencyMs int `json:"latency_ms" orm:"latency_ms" description:"请求延迟(毫秒)"` // 请求延迟(毫秒)
|
||||
AuditLevel string `json:"audit_level" orm:"audit_level" description:"审计级别:full(完整记录)/ masked(脱敏记录)/ question_only(仅记录提问)/ none(不记录)"` // 审计级别:full(完整记录)/ masked(脱敏记录)/ question_only(仅记录提问)/ none(不记录)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
TenantRequestBody string `json:"tenant_request_body" orm:"tenant_request_body" description:"租户级请求体(按租户审计级别处理)"` // 租户级请求体(按租户审计级别处理)
|
||||
TenantResponseBody string `json:"tenant_response_body" orm:"tenant_response_body" description:"租户级响应体(按租户审计级别处理)"` // 租户级响应体(按租户审计级别处理)
|
||||
TenantAuditLevel string `json:"tenant_audit_level" orm:"tenant_audit_level" description:"租户审计级别:full/full_text/masked/question_only/none"` // 租户审计级别:full/full_text/masked/question_only/none
|
||||
ProjectId int64 `json:"project_id" orm:"project_id" description:"关联项目ID(通过API Key关联,NULL表示个人密钥无项目)"` // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目)
|
||||
FirstTokenMs int `json:"first_token_ms" orm:"first_token_ms" description:"首个 Token 出现的用时(毫秒),仅流式请求有值"` // 首个 Token 出现的用时(毫秒),仅流式请求有值
|
||||
RequestHeaders string `json:"request_headers" orm:"request_headers" description:"请求头信息(仅审计级别为 all 时记录,管理后台调试用)"` // 请求头信息(仅审计级别为 all 时记录,管理后台调试用)
|
||||
ResponseHeaders string `json:"response_headers" orm:"response_headers" description:"响应头信息(仅审计级别为 all 时记录,管理后台调试用)"` // 响应头信息(仅审计级别为 all 时记录,管理后台调试用)
|
||||
ForwardingTrace string `json:"forwarding_trace" orm:"forwarding_trace" description:"请求转发路径追踪(仅管理员可见)"` // 请求转发路径追踪(仅管理员可见)
|
||||
TaskId string `json:"task_id" orm:"task_id" description:"异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id"` // 异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id
|
||||
TaskStatus string `json:"task_status" orm:"task_status" description:"异步任务终态:SUCCESS / FAILURE"` // 异步任务终态:SUCCESS / FAILURE
|
||||
TaskResult string `json:"task_result" orm:"task_result" description:"异步任务完成时上游返回的原始响应体"` // 异步任务完成时上游返回的原始响应体
|
||||
TaskUpstreamHeaders string `json:"task_upstream_headers" orm:"task_upstream_headers" description:"异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)"` // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)
|
||||
TaskCompletedAt *gtime.Time `json:"task_completed_at" orm:"task_completed_at" description:"异步任务达到终态的时间"` // 异步任务达到终态的时间
|
||||
}
|
||||
@@ -10,60 +10,61 @@ import (
|
||||
|
||||
// BilUsageLogs is the golang structure for table bil_usage_logs.
|
||||
type BilUsageLogs struct {
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
|
||||
UserId int64 `json:"user_id" orm:"user_id" description:"用户ID"` // 用户ID
|
||||
ApiKeyId int64 `json:"api_key_id" orm:"api_key_id" description:"使用的 API Key ID"` // 使用的 API Key ID
|
||||
ChannelId int64 `json:"channel_id" orm:"channel_id" description:"使用的渠道ID"` // 使用的渠道ID
|
||||
ModelName string `json:"model_name" orm:"model_name" description:"调用的模型名"` // 调用的模型名
|
||||
RequestId string `json:"request_id" orm:"request_id" description:"请求唯一ID"` // 请求唯一ID
|
||||
RelayMode string `json:"relay_mode" orm:"relay_mode" description:"代理模式:chat_completions / embeddings / images_generations 等"` // 代理模式:chat_completions / embeddings / images_generations 等
|
||||
InputTokens int `json:"input_tokens" orm:"input_tokens" description:"输入 token 数"` // 输入 token 数
|
||||
OutputTokens int `json:"output_tokens" orm:"output_tokens" description:"输出 token 数"` // 输出 token 数
|
||||
TotalCost float64 `json:"total_cost" orm:"total_cost" description:"本次调用费用"` // 本次调用费用
|
||||
Currency string `json:"currency" orm:"currency" description:"货币(USD)"` // 货币(USD)
|
||||
LatencyMs int `json:"latency_ms" orm:"latency_ms" description:"请求延迟(毫秒)"` // 请求延迟(毫秒)
|
||||
Status string `json:"status" orm:"status" description:"状态:success(成功)/ error(错误)/ timeout(超时)/ cancelled(取消)"` // 状态:success(成功)/ error(错误)/ timeout(超时)/ cancelled(取消)
|
||||
ErrorMessage string `json:"error_message" orm:"error_message" description:"错误信息(成功时为 NULL)"` // 错误信息(成功时为 NULL)
|
||||
ClientIp string `json:"client_ip" orm:"client_ip" description:"客户端 IP"` // 客户端 IP
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
CacheCreationTokens int `json:"cache_creation_tokens" orm:"cache_creation_tokens" description:"缓存创建 token 数 (Claude)"` // 缓存创建 token 数 (Claude)
|
||||
CacheReadTokens int `json:"cache_read_tokens" orm:"cache_read_tokens" description:"缓存读取 token 数 (Claude/OpenAI)"` // 缓存读取 token 数 (Claude/OpenAI)
|
||||
InputCost float64 `json:"input_cost" orm:"input_cost" description:"输入 token 费用"` // 输入 token 费用
|
||||
OutputCost float64 `json:"output_cost" orm:"output_cost" description:"输出 token 费用"` // 输出 token 费用
|
||||
CacheCreationCost float64 `json:"cache_creation_cost" orm:"cache_creation_cost" description:"缓存创建费用"` // 缓存创建费用
|
||||
CacheReadCost float64 `json:"cache_read_cost" orm:"cache_read_cost" description:"缓存读取费用"` // 缓存读取费用
|
||||
ActualCost float64 `json:"actual_cost" orm:"actual_cost" description:"实际扣除费用(含折扣后)"` // 实际扣除费用(含折扣后)
|
||||
RequestedModel string `json:"requested_model" orm:"requested_model" description:"用户请求的模型名"` // 用户请求的模型名
|
||||
UpstreamModel string `json:"upstream_model" orm:"upstream_model" description:"上游实际模型名(模型映射后)"` // 上游实际模型名(模型映射后)
|
||||
RequestType int `json:"request_type" orm:"request_type" description:"请求类型: 1=sync, 2=stream, 3=websocket"` // 请求类型: 1=sync, 2=stream, 3=websocket
|
||||
UserAgent string `json:"user_agent" orm:"user_agent" description:"客户端 User-Agent"` // 客户端 User-Agent
|
||||
FirstTokenMs int `json:"first_token_ms" orm:"first_token_ms" description:"首 token 延迟(毫秒)"` // 首 token 延迟(毫秒)
|
||||
ServiceTier string `json:"service_tier" orm:"service_tier" description:"服务等级 (default/flex等)"` // 服务等级 (default/flex等)
|
||||
ReasoningEffort string `json:"reasoning_effort" orm:"reasoning_effort" description:"推理力度 (low/medium/high)"` // 推理力度 (low/medium/high)
|
||||
ChannelName string `json:"channel_name" orm:"channel_name" description:"渠道名称"` // 渠道名称
|
||||
ChannelType int `json:"channel_type" orm:"channel_type" description:"渠道类型 (ProviderType)"` // 渠道类型 (ProviderType)
|
||||
BillingMode string `json:"billing_mode" orm:"billing_mode" description:"计费模式 (token/per_request/tiered)"` // 计费模式 (token/per_request/tiered)
|
||||
BillingSource string `json:"billing_source" orm:"billing_source" description:"定价来源 (base/tenant/custom)"` // 定价来源 (base/tenant/custom)
|
||||
RateMultiplier float64 `json:"rate_multiplier" orm:"rate_multiplier" description:"费率倍率/折扣"` // 费率倍率/折扣
|
||||
PreDeductAmount float64 `json:"pre_deduct_amount" orm:"pre_deduct_amount" description:"预扣金额"` // 预扣金额
|
||||
RefundAmount float64 `json:"refund_amount" orm:"refund_amount" description:"退款金额"` // 退款金额
|
||||
SupplementAmount float64 `json:"supplement_amount" orm:"supplement_amount" description:"补扣金额"` // 补扣金额
|
||||
ImageCount int `json:"image_count" orm:"image_count" description:"生成图片数量"` // 生成图片数量
|
||||
ImageSize string `json:"image_size" orm:"image_size" description:"图片尺寸"` // 图片尺寸
|
||||
StreamEndReason string `json:"stream_end_reason" orm:"stream_end_reason" description:"流结束原因 (done/timeout/client_gone/error/panic)"` // 流结束原因 (done/timeout/client_gone/error/panic)
|
||||
RetryIndex int `json:"retry_index" orm:"retry_index" description:"重试次数(0=首次成功)"` // 重试次数(0=首次成功)
|
||||
BillingSummary string `json:"billing_summary" orm:"billing_summary" description:"计费快照文本(人类可读的计费过程描述)"` // 计费快照文本(人类可读的计费过程描述)
|
||||
CacheCreation5MTokens int `json:"cache_creation_5m_tokens" orm:"cache_creation_5m_tokens" description:"Claude 5分钟缓存创建 token 数"` // Claude 5分钟缓存创建 token 数
|
||||
CacheCreation1HTokens int `json:"cache_creation_1h_tokens" orm:"cache_creation_1h_tokens" description:"Claude 1小时缓存创建 token 数"` // Claude 1小时缓存创建 token 数
|
||||
AudioInputTokens int `json:"audio_input_tokens" orm:"audio_input_tokens" description:"音频输入 token 数"` // 音频输入 token 数
|
||||
AudioOutputTokens int `json:"audio_output_tokens" orm:"audio_output_tokens" description:"音频输出 token 数"` // 音频输出 token 数
|
||||
ImageOutputTokens int `json:"image_output_tokens" orm:"image_output_tokens" description:"图像输出 token 数(DALL-E 等)"` // 图像输出 token 数(DALL-E 等)
|
||||
ReasoningTokens int `json:"reasoning_tokens" orm:"reasoning_tokens" description:"推理 token 数(O1/o3 等)"` // 推理 token 数(O1/o3 等)
|
||||
AccountCost float64 `json:"account_cost" orm:"account_cost" description:"上游账户成本(用于利润分析)"` // 上游账户成本(用于利润分析)
|
||||
InboundEndpoint string `json:"inbound_endpoint" orm:"inbound_endpoint" description:"客户端请求路径(如 /v1/chat/completions)"` // 客户端请求路径(如 /v1/chat/completions)
|
||||
UpstreamEndpoint string `json:"upstream_endpoint" orm:"upstream_endpoint" description:"上游实际请求路径"` // 上游实际请求路径
|
||||
BillingSnapshot string `json:"billing_snapshot" orm:"billing_snapshot" description:"完整计费计算过程快照(JSONB)"` // 完整计费计算过程快照(JSONB)
|
||||
ProjectId int64 `json:"project_id" orm:"project_id" description:"关联项目ID(通过API Key关联,NULL表示个人密钥无项目)"` // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目)
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
|
||||
UserId int64 `json:"user_id" orm:"user_id" description:"用户ID"` // 用户ID
|
||||
ApiKeyId int64 `json:"api_key_id" orm:"api_key_id" description:"使用的 API Key ID"` // 使用的 API Key ID
|
||||
ChannelId int64 `json:"channel_id" orm:"channel_id" description:"使用的渠道ID"` // 使用的渠道ID
|
||||
ModelName string `json:"model_name" orm:"model_name" description:"调用的模型名"` // 调用的模型名
|
||||
RequestId string `json:"request_id" orm:"request_id" description:"请求唯一ID"` // 请求唯一ID
|
||||
RelayMode string `json:"relay_mode" orm:"relay_mode" description:"代理模式:chat_completions / embeddings / images_generations 等"` // 代理模式:chat_completions / embeddings / images_generations 等
|
||||
InputTokens int `json:"input_tokens" orm:"input_tokens" description:"输入 token 数"` // 输入 token 数
|
||||
OutputTokens int `json:"output_tokens" orm:"output_tokens" description:"输出 token 数"` // 输出 token 数
|
||||
TotalCost float64 `json:"total_cost" orm:"total_cost" description:"本次调用费用"` // 本次调用费用
|
||||
Currency string `json:"currency" orm:"currency" description:"货币(USD)"` // 货币(USD)
|
||||
LatencyMs int `json:"latency_ms" orm:"latency_ms" description:"请求延迟(毫秒)"` // 请求延迟(毫秒)
|
||||
Status string `json:"status" orm:"status" description:"状态:success(成功)/ error(错误)/ timeout(超时)/ cancelled(取消)"` // 状态:success(成功)/ error(错误)/ timeout(超时)/ cancelled(取消)
|
||||
ErrorMessage string `json:"error_message" orm:"error_message" description:"错误信息(成功时为 NULL)"` // 错误信息(成功时为 NULL)
|
||||
ClientIp string `json:"client_ip" orm:"client_ip" description:"客户端 IP"` // 客户端 IP
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
CacheCreationTokens int `json:"cache_creation_tokens" orm:"cache_creation_tokens" description:"缓存创建 token 数 (Claude)"` // 缓存创建 token 数 (Claude)
|
||||
CacheReadTokens int `json:"cache_read_tokens" orm:"cache_read_tokens" description:"缓存读取 token 数 (Claude/OpenAI)"` // 缓存读取 token 数 (Claude/OpenAI)
|
||||
InputCost float64 `json:"input_cost" orm:"input_cost" description:"输入 token 费用"` // 输入 token 费用
|
||||
OutputCost float64 `json:"output_cost" orm:"output_cost" description:"输出 token 费用"` // 输出 token 费用
|
||||
CacheCreationCost float64 `json:"cache_creation_cost" orm:"cache_creation_cost" description:"缓存创建费用"` // 缓存创建费用
|
||||
CacheReadCost float64 `json:"cache_read_cost" orm:"cache_read_cost" description:"缓存读取费用"` // 缓存读取费用
|
||||
ActualCost float64 `json:"actual_cost" orm:"actual_cost" description:"实际扣除费用(含折扣后)"` // 实际扣除费用(含折扣后)
|
||||
RequestedModel string `json:"requested_model" orm:"requested_model" description:"用户请求的模型名"` // 用户请求的模型名
|
||||
UpstreamModel string `json:"upstream_model" orm:"upstream_model" description:"上游实际模型名(模型映射后)"` // 上游实际模型名(模型映射后)
|
||||
RequestType int `json:"request_type" orm:"request_type" description:"请求类型: 1=sync, 2=stream, 3=async, 4=websocket"` // 请求类型: 1=sync, 2=stream, 3=async, 4=websocket
|
||||
UserAgent string `json:"user_agent" orm:"user_agent" description:"客户端 User-Agent"` // 客户端 User-Agent
|
||||
FirstTokenMs int `json:"first_token_ms" orm:"first_token_ms" description:"首 token 延迟(毫秒)"` // 首 token 延迟(毫秒)
|
||||
ServiceTier string `json:"service_tier" orm:"service_tier" description:"服务等级 (default/flex等)"` // 服务等级 (default/flex等)
|
||||
ReasoningEffort string `json:"reasoning_effort" orm:"reasoning_effort" description:"推理力度 (low/medium/high)"` // 推理力度 (low/medium/high)
|
||||
ChannelName string `json:"channel_name" orm:"channel_name" description:"渠道名称"` // 渠道名称
|
||||
ChannelType int `json:"channel_type" orm:"channel_type" description:"渠道类型 (ProviderType)"` // 渠道类型 (ProviderType)
|
||||
BillingMode string `json:"billing_mode" orm:"billing_mode" description:"计费模式 (token/per_request/tiered)"` // 计费模式 (token/per_request/tiered)
|
||||
BillingSource string `json:"billing_source" orm:"billing_source" description:"定价来源 (base/tenant/custom)"` // 定价来源 (base/tenant/custom)
|
||||
RateMultiplier float64 `json:"rate_multiplier" orm:"rate_multiplier" description:"费率倍率/折扣"` // 费率倍率/折扣
|
||||
PreDeductAmount float64 `json:"pre_deduct_amount" orm:"pre_deduct_amount" description:"预扣金额"` // 预扣金额
|
||||
RefundAmount float64 `json:"refund_amount" orm:"refund_amount" description:"退款金额"` // 退款金额
|
||||
SupplementAmount float64 `json:"supplement_amount" orm:"supplement_amount" description:"补扣金额"` // 补扣金额
|
||||
ImageCount int `json:"image_count" orm:"image_count" description:"生成图片数量"` // 生成图片数量
|
||||
ImageSize string `json:"image_size" orm:"image_size" description:"图片尺寸"` // 图片尺寸
|
||||
StreamEndReason string `json:"stream_end_reason" orm:"stream_end_reason" description:"流结束原因 (done/timeout/client_gone/error/panic)"` // 流结束原因 (done/timeout/client_gone/error/panic)
|
||||
RetryIndex int `json:"retry_index" orm:"retry_index" description:"重试次数(0=首次成功)"` // 重试次数(0=首次成功)
|
||||
BillingSummary string `json:"billing_summary" orm:"billing_summary" description:"计费快照文本(人类可读的计费过程描述)"` // 计费快照文本(人类可读的计费过程描述)
|
||||
CacheCreation5MTokens int `json:"cache_creation_5m_tokens" orm:"cache_creation_5m_tokens" description:"Claude 5分钟缓存创建 token 数"` // Claude 5分钟缓存创建 token 数
|
||||
CacheCreation1HTokens int `json:"cache_creation_1h_tokens" orm:"cache_creation_1h_tokens" description:"Claude 1小时缓存创建 token 数"` // Claude 1小时缓存创建 token 数
|
||||
AudioInputTokens int `json:"audio_input_tokens" orm:"audio_input_tokens" description:"音频输入 token 数"` // 音频输入 token 数
|
||||
AudioOutputTokens int `json:"audio_output_tokens" orm:"audio_output_tokens" description:"音频输出 token 数"` // 音频输出 token 数
|
||||
ImageOutputTokens int `json:"image_output_tokens" orm:"image_output_tokens" description:"图像输出 token 数(DALL-E 等)"` // 图像输出 token 数(DALL-E 等)
|
||||
ReasoningTokens int `json:"reasoning_tokens" orm:"reasoning_tokens" description:"推理 token 数(O1/o3 等)"` // 推理 token 数(O1/o3 等)
|
||||
AccountCost float64 `json:"account_cost" orm:"account_cost" description:"上游账户成本(用于利润分析)"` // 上游账户成本(用于利润分析)
|
||||
InboundEndpoint string `json:"inbound_endpoint" orm:"inbound_endpoint" description:"客户端请求路径(如 /v1/chat/completions)"` // 客户端请求路径(如 /v1/chat/completions)
|
||||
UpstreamEndpoint string `json:"upstream_endpoint" orm:"upstream_endpoint" description:"上游实际请求路径"` // 上游实际请求路径
|
||||
BillingSnapshot string `json:"billing_snapshot" orm:"billing_snapshot" description:"完整计费计算过程快照(JSONB)"` // 完整计费计算过程快照(JSONB)
|
||||
ProjectId int64 `json:"project_id" orm:"project_id" description:"关联项目ID(通过API Key关联,NULL表示个人密钥无项目)"` // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目)
|
||||
TaskId string `json:"task_id" orm:"task_id" description:"异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空"` // 异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空
|
||||
}
|
||||
@@ -10,28 +10,29 @@ import (
|
||||
|
||||
// TskModelTasks is the golang structure for table tsk_model_tasks.
|
||||
type TskModelTasks struct {
|
||||
Id int64 `json:"id" orm:"id" description:""` //
|
||||
PublicTaskId string `json:"public_task_id" orm:"public_task_id" description:""` //
|
||||
Platform string `json:"platform" orm:"platform" description:""` //
|
||||
Action string `json:"action" orm:"action" description:""` //
|
||||
Status string `json:"status" orm:"status" description:""` //
|
||||
Progress string `json:"progress" orm:"progress" description:""` //
|
||||
FailReason string `json:"fail_reason" orm:"fail_reason" description:""` //
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:""` //
|
||||
UserId int64 `json:"user_id" orm:"user_id" description:""` //
|
||||
ApiKeyId int64 `json:"api_key_id" orm:"api_key_id" description:""` //
|
||||
ChannelId int64 `json:"channel_id" orm:"channel_id" description:""` //
|
||||
ModelName string `json:"model_name" orm:"model_name" description:""` //
|
||||
UpstreamModel string `json:"upstream_model" orm:"upstream_model" description:""` //
|
||||
PreDeductAmount float64 `json:"pre_deduct_amount" orm:"pre_deduct_amount" description:""` //
|
||||
ActualCost float64 `json:"actual_cost" orm:"actual_cost" description:""` //
|
||||
BillingSettled bool `json:"billing_settled" orm:"billing_settled" description:""` //
|
||||
ResultUrl string `json:"result_url" orm:"result_url" description:""` //
|
||||
Data string `json:"data" orm:"data" description:""` //
|
||||
PrivateData string `json:"private_data" orm:"private_data" description:""` //
|
||||
SubmitTime *gtime.Time `json:"submit_time" orm:"submit_time" description:""` //
|
||||
StartTime *gtime.Time `json:"start_time" orm:"start_time" description:""` //
|
||||
FinishTime *gtime.Time `json:"finish_time" orm:"finish_time" description:""` //
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` //
|
||||
Id int64 `json:"id" orm:"id" description:""` //
|
||||
PublicTaskId string `json:"public_task_id" orm:"public_task_id" description:""` //
|
||||
RequestId string `json:"request_id" orm:"request_id" description:"任务提交时的原始请求 ID"` //
|
||||
Platform string `json:"platform" orm:"platform" description:""` //
|
||||
Action string `json:"action" orm:"action" description:""` //
|
||||
Status string `json:"status" orm:"status" description:""` //
|
||||
Progress string `json:"progress" orm:"progress" description:""` //
|
||||
FailReason string `json:"fail_reason" orm:"fail_reason" description:""` //
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:""` //
|
||||
UserId int64 `json:"user_id" orm:"user_id" description:""` //
|
||||
ApiKeyId int64 `json:"api_key_id" orm:"api_key_id" description:""` //
|
||||
ChannelId int64 `json:"channel_id" orm:"channel_id" description:""` //
|
||||
ModelName string `json:"model_name" orm:"model_name" description:""` //
|
||||
UpstreamModel string `json:"upstream_model" orm:"upstream_model" description:""` //
|
||||
PreDeductAmount float64 `json:"pre_deduct_amount" orm:"pre_deduct_amount" description:""` //
|
||||
ActualCost float64 `json:"actual_cost" orm:"actual_cost" description:""` //
|
||||
BillingSettled bool `json:"billing_settled" orm:"billing_settled" description:""` //
|
||||
ResultUrl string `json:"result_url" orm:"result_url" description:""` //
|
||||
Data string `json:"data" orm:"data" description:""` //
|
||||
PrivateData string `json:"private_data" orm:"private_data" description:""` //
|
||||
SubmitTime *gtime.Time `json:"submit_time" orm:"submit_time" description:""` //
|
||||
StartTime *gtime.Time `json:"start_time" orm:"start_time" description:""` //
|
||||
FinishTime *gtime.Time `json:"finish_time" orm:"finish_time" description:""` //
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` //
|
||||
}
|
||||
@@ -202,7 +202,7 @@ type (
|
||||
CreateModel(ctx context.Context, req *v1.ModelCreateReq) (*v1.ModelCreateRes, error)
|
||||
// UpdateModel 更新模型(含弃用状态管理)
|
||||
UpdateModel(ctx context.Context, req *v1.ModelUpdateReq) (*v1.ModelUpdateRes, error)
|
||||
// DeleteModel 删除模型(同时删除定价记录)
|
||||
// DeleteModel 删除模型(同时删除定价记录和租户分配记录)
|
||||
DeleteModel(ctx context.Context, req *v1.ModelDeleteReq) (*v1.ModelDeleteRes, error)
|
||||
// ListModelPricing 模型定价列表(模型定价页面专用)
|
||||
ListModelPricing(ctx context.Context, req *v1.PricingListReq) (*v1.PricingListRes, error)
|
||||
|
||||
@@ -286,6 +286,12 @@ type (
|
||||
GetIPWhitelist(ctx context.Context, _ *v1.TenantIPWhitelistGetReq) (*v1.TenantIPWhitelistGetRes, error)
|
||||
// UpdateIPWhitelist updates the tenant's IP whitelist configuration.
|
||||
UpdateIPWhitelist(ctx context.Context, req *v1.TenantIPWhitelistUpdateReq) (*v1.TenantIPWhitelistUpdateRes, error)
|
||||
// TenantTaskList 租户异步任务列表
|
||||
// owner/admin 可查看租户所有任务,member 只能查看自己的任务
|
||||
TenantTaskList(ctx context.Context, req *v1.TenantTaskListReq) (*v1.TenantTaskListRes, error)
|
||||
// TenantTaskDetail 租户异步任务详情
|
||||
// owner/admin 可查看租户所有任务,member 只能查看自己的任务
|
||||
TenantTaskDetail(ctx context.Context, req *v1.TenantTaskDetailReq) (*v1.TenantTaskDetailRes, error)
|
||||
// TicketCreate 创建工单
|
||||
TicketCreate(ctx context.Context, req *v1.TenantTicketCreateReq) (*v1.TenantTicketCreateRes, error)
|
||||
// TicketList 获取租户工单列表
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
-- +goose Up
|
||||
-- 异步任务支持:审计日志任务字段 + 用量日志任务字段 + 任务表request_id + 请求类型注释更新
|
||||
|
||||
-- 1. aud_request_logs 新增任务结果相关字段
|
||||
ALTER TABLE aud_request_logs
|
||||
ADD COLUMN IF NOT EXISTS task_id VARCHAR(64),
|
||||
ADD COLUMN IF NOT EXISTS task_status VARCHAR(20),
|
||||
ADD COLUMN IF NOT EXISTS task_result TEXT,
|
||||
ADD COLUMN IF NOT EXISTS task_upstream_headers JSONB,
|
||||
ADD COLUMN IF NOT EXISTS task_completed_at TIMESTAMPTZ;
|
||||
|
||||
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 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 '异步任务达到终态的时间';
|
||||
|
||||
-- 2. bil_usage_logs 新增 task_id 字段
|
||||
ALTER TABLE bil_usage_logs
|
||||
ADD COLUMN IF NOT EXISTS task_id VARCHAR(64);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bil_usage_logs_task_id
|
||||
ON bil_usage_logs (task_id) WHERE task_id IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN bil_usage_logs.task_id IS '异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空';
|
||||
|
||||
-- 3. bil_usage_logs.request_type 注释更新(新增 async=3)
|
||||
COMMENT ON COLUMN bil_usage_logs.request_type IS '请求类型: 1=sync, 2=stream, 3=async, 4=websocket';
|
||||
|
||||
-- 4. tsk_model_tasks 新增 request_id 字段,存储任务提交时的原始请求 ID
|
||||
ALTER TABLE tsk_model_tasks
|
||||
ADD COLUMN IF NOT EXISTS request_id VARCHAR(64);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tsk_model_tasks_request_id
|
||||
ON tsk_model_tasks (request_id) WHERE request_id IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN tsk_model_tasks.request_id IS '任务提交时的原始请求 ID(req_xxxxx),关联 aud_request_logs.request_id';
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX IF EXISTS idx_tsk_model_tasks_request_id;
|
||||
ALTER TABLE tsk_model_tasks
|
||||
DROP COLUMN IF EXISTS request_id;
|
||||
|
||||
DROP INDEX IF EXISTS idx_aud_request_logs_task_id;
|
||||
ALTER TABLE aud_request_logs
|
||||
DROP COLUMN IF EXISTS task_id,
|
||||
DROP COLUMN IF EXISTS task_status,
|
||||
DROP COLUMN IF EXISTS task_result,
|
||||
DROP COLUMN IF EXISTS task_upstream_headers,
|
||||
DROP COLUMN IF EXISTS task_completed_at;
|
||||
|
||||
DROP INDEX IF EXISTS idx_bil_usage_logs_task_id;
|
||||
ALTER TABLE bil_usage_logs
|
||||
DROP COLUMN IF EXISTS task_id;
|
||||
|
||||
COMMENT ON COLUMN bil_usage_logs.request_type IS '请求类型: 1=sync, 2=stream, 3=websocket';
|
||||
@@ -3,6 +3,7 @@ package common
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrChannelUnavailable 没有可用渠道
|
||||
@@ -47,6 +48,10 @@ type DataProvider interface {
|
||||
// RecordAudit 异步记录请求审计日志。
|
||||
RecordAudit(ctx context.Context, record *AuditRecord)
|
||||
|
||||
// UpdateTaskAudit 更新异步任务的审计记录(任务完成时调用)。
|
||||
// 通过 task_id 查找提交阶段写入的审计记录,补充最终结果。
|
||||
UpdateTaskAudit(ctx context.Context, record *AuditRecord)
|
||||
|
||||
// UpdateChannelHealth 更新渠道健康度(请求成功/失败后调用)。
|
||||
UpdateChannelHealth(ctx context.Context, channelID int64, success bool, latencyMs float64)
|
||||
|
||||
@@ -145,7 +150,7 @@ type UsageRecord struct {
|
||||
// 请求元数据
|
||||
RequestedModel string // 用户请求的模型名
|
||||
UpstreamModel string // 上游实际模型名
|
||||
RequestType int // 1=sync, 2=stream, 3=websocket
|
||||
RequestType int // 1=sync, 2=stream, 3=async, 4=websocket
|
||||
UserAgent string
|
||||
ClientIP string
|
||||
FirstTokenMs int
|
||||
@@ -186,6 +191,9 @@ type UsageRecord struct {
|
||||
// 计费快照
|
||||
BillingSnapshot string // JSON 字符串
|
||||
BillingSummary string // 人类可读中文文本
|
||||
|
||||
// 异步任务关联
|
||||
TaskID string // 异步任务公开ID(task_xxxxx),普通请求为空
|
||||
}
|
||||
|
||||
// AuditRecord 请求审计日志记录
|
||||
@@ -209,6 +217,13 @@ type AuditRecord struct {
|
||||
RequestHeaders map[string]string // 请求头(仅审计级别为 all 时记录)
|
||||
ResponseHeaders map[string]string // 响应头(仅审计级别为 all 时记录)
|
||||
ForwardingTrace *ForwardingTrace // 转发路径追踪(仅管理员可见)
|
||||
|
||||
// 异步任务完成字段(仅 UpdateTaskAudit 时填充)
|
||||
TaskID string // 异步任务公开ID,关联 tsk_model_tasks.public_task_id
|
||||
TaskStatus string // SUCCESS / FAILURE
|
||||
TaskResult string // 上游最终响应体
|
||||
TaskUpstreamHeaders map[string]string // 上游最终轮询的响应头
|
||||
TaskCompletedAt *time.Time // 任务到达终态的时间
|
||||
}
|
||||
|
||||
// ModelInfo 模型信息(用于 /v1/models 端点)
|
||||
|
||||
@@ -60,9 +60,10 @@ type TaskInfo struct {
|
||||
SubTasks []SubTask // 子任务结果(如多图/多视频场景)
|
||||
ActualCost float64 // 上游返回的实际费用(0 表示未提供,使用预扣金额)
|
||||
|
||||
// 上游 usage 信息,用于按 token 重算费用
|
||||
CompletionTokens int // 上游返回的 completion tokens
|
||||
TotalTokens int // 上游返回的 total tokens
|
||||
// Token 用量(部分异步任务上游会返回,如火山引擎视频模型)
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
TotalTokens int
|
||||
}
|
||||
|
||||
// TaskStatusEnum 任务状态枚举
|
||||
|
||||
@@ -37,6 +37,7 @@ type TaskDataProvider interface {
|
||||
type AsyncTask struct {
|
||||
ID int64 `json:"id"`
|
||||
PublicTaskID string `json:"public_task_id"` // task_xxxxx
|
||||
RequestID string `json:"request_id"` // 原始请求 ID(req_xxxxx)
|
||||
Platform string `json:"platform"` // sora, kling, suno
|
||||
Action string `json:"action"` // generate, music, lyrics
|
||||
Status string `json:"status"`
|
||||
@@ -55,6 +56,10 @@ type AsyncTask struct {
|
||||
ActualCost float64 `json:"actual_cost"`
|
||||
BillingSettled bool `json:"billing_settled"`
|
||||
|
||||
PromptTokens int `json:"prompt_tokens,omitempty"`
|
||||
CompletionTokens int `json:"completion_tokens,omitempty"`
|
||||
TotalTokens int `json:"total_tokens,omitempty"`
|
||||
|
||||
ResultURL string `json:"result_url,omitempty"`
|
||||
Data json.RawMessage `json:"data,omitempty"` // 上游原始响应
|
||||
PrivateData json.RawMessage `json:"private_data,omitempty"` // 上游任务 ID、API Key 等敏感数据
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/qianfree/team-api/relay/common"
|
||||
"github.com/qianfree/team-api/relay/constant"
|
||||
"github.com/qianfree/team-api/relay/taskchannel"
|
||||
@@ -26,6 +27,8 @@ type TaskRelayContext struct {
|
||||
TenantID int64
|
||||
UserID int64
|
||||
ApiKeyID int64
|
||||
ProjectID int64
|
||||
TaskID string // 由 HandleTaskSubmit 在创建任务后设置
|
||||
RequestID string
|
||||
Writer http.ResponseWriter
|
||||
Scope string
|
||||
@@ -66,6 +69,7 @@ func HandleTaskSubmit(
|
||||
providerType := constant.ProviderType(channelMeta.ChannelType)
|
||||
platform, ok := constant.ProviderTypeToTaskPlatform(providerType)
|
||||
if !ok {
|
||||
g.Log().Warningf(ctx, "HandleTaskSubmit: unsupported task platform, channelType=%d, modelName=%s", channelMeta.ChannelType, modelName)
|
||||
writeTaskError(rc.Writer, http.StatusBadRequest, "unsupported task platform", "")
|
||||
return
|
||||
}
|
||||
@@ -77,6 +81,8 @@ func HandleTaskSubmit(
|
||||
return
|
||||
}
|
||||
|
||||
g.Log().Debugf(ctx, "HandleTaskSubmit: modelName=%s, platform=%s, channelID=%d, channelType=%d, baseURL=%s, upstreamModel=%s", modelName, platform, channelMeta.ChannelID, channelMeta.ChannelType, channelMeta.BaseURL, channelMeta.UpstreamModelName)
|
||||
|
||||
// 4. 构建 RelayInfo
|
||||
info := &common.RelayInfo{
|
||||
Context: ctx,
|
||||
@@ -95,6 +101,7 @@ func HandleTaskSubmit(
|
||||
|
||||
// 5. 校验请求
|
||||
if taskErr := adaptor.ValidateRequest(ctx, info, body); taskErr != nil {
|
||||
g.Log().Warningf(ctx, "HandleTaskSubmit: validate failed, model=%s, err=%s", modelName, taskErr.Message)
|
||||
writeTaskError(rc.Writer, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
|
||||
return
|
||||
}
|
||||
@@ -103,12 +110,15 @@ func HandleTaskSubmit(
|
||||
ratios := adaptor.EstimateBilling(ctx, info, body)
|
||||
estimatedCost, err := billingProvider.EstimateTaskCost(ctx, rc.TenantID, modelName, ratios)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "HandleTaskSubmit: estimate cost failed, model=%s, err=%v", modelName, err)
|
||||
writeTaskError(rc.Writer, http.StatusInternalServerError, "estimate cost failed: "+err.Error(), "")
|
||||
return
|
||||
}
|
||||
g.Log().Debugf(ctx, "HandleTaskSubmit: estimatedCost=%.4f", estimatedCost)
|
||||
|
||||
preDeductAmount, err := billingProvider.PreDeductTask(ctx, rc.TenantID, rc.RequestID, estimatedCost)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "HandleTaskSubmit: pre-deduct failed, model=%s, err=%v", modelName, err)
|
||||
writeTaskError(rc.Writer, http.StatusPaymentRequired, "insufficient balance", "")
|
||||
return
|
||||
}
|
||||
@@ -117,6 +127,7 @@ func HandleTaskSubmit(
|
||||
requestBody, err := adaptor.BuildRequestBody(ctx, info, body)
|
||||
if err != nil {
|
||||
billingProvider.SettleTaskFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount)
|
||||
g.Log().Errorf(ctx, "HandleTaskSubmit: build request failed, model=%s, err=%v", modelName, err)
|
||||
writeTaskError(rc.Writer, http.StatusInternalServerError, "build request failed: "+err.Error(), "")
|
||||
return
|
||||
}
|
||||
@@ -124,15 +135,19 @@ func HandleTaskSubmit(
|
||||
resp, err := adaptor.DoRequest(ctx, info, requestBody)
|
||||
if err != nil {
|
||||
billingProvider.SettleTaskFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount)
|
||||
g.Log().Errorf(ctx, "HandleTaskSubmit: upstream request failed, model=%s, err=%v", modelName, err)
|
||||
writeTaskError(rc.Writer, http.StatusBadGateway, "upstream request failed: "+err.Error(), "")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
g.Log().Debugf(ctx, "HandleTaskSubmit: upstream responded status=%d", resp.StatusCode)
|
||||
|
||||
// 8. 解析响应
|
||||
upstreamTaskID, taskData, taskErr := adaptor.DoResponse(ctx, resp, info)
|
||||
if taskErr != nil {
|
||||
billingProvider.SettleTaskFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount)
|
||||
g.Log().Warningf(ctx, "HandleTaskSubmit: upstream response error, model=%s, status=%d, message=%q, body=%s", modelName, taskErr.StatusCode, taskErr.Message, string(taskData))
|
||||
writeTaskError(rc.Writer, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
|
||||
return
|
||||
}
|
||||
@@ -177,6 +192,7 @@ func HandleTaskSubmit(
|
||||
|
||||
task := &common.AsyncTask{
|
||||
PublicTaskID: publicTaskID,
|
||||
RequestID: rc.RequestID,
|
||||
Platform: string(platform),
|
||||
Action: "generate",
|
||||
Status: "SUBMITTED",
|
||||
@@ -199,6 +215,9 @@ func HandleTaskSubmit(
|
||||
return
|
||||
}
|
||||
|
||||
// 设置 TaskID 供外层审计使用
|
||||
rc.TaskID = publicTaskID
|
||||
|
||||
// 11. 返回响应
|
||||
respBody := map[string]any{
|
||||
"id": publicTaskID,
|
||||
|
||||
@@ -15,20 +15,102 @@ import (
|
||||
|
||||
func init() {
|
||||
taskchannel.Register(constant.ProviderAli, func() common.TaskAdaptor {
|
||||
return &AliImageAdaptor{}
|
||||
return &AliAdaptor{}
|
||||
})
|
||||
}
|
||||
|
||||
// AliImageAdaptor 阿里云 DashScope 异步图片生成适配器
|
||||
type AliImageAdaptor struct {
|
||||
info *common.RelayInfo
|
||||
// ==================== 请求/响应结构体 ====================
|
||||
|
||||
// dashScopeVideoRequest DashScope 视频生成请求
|
||||
type dashScopeVideoRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input dashScopeVideoInput `json:"input"`
|
||||
Parameters *dashScopeVideoParams `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
func (a *AliImageAdaptor) Init(info *common.RelayInfo) {
|
||||
type dashScopeVideoInput struct {
|
||||
Prompt string `json:"prompt"`
|
||||
NegativePrompt string `json:"negative_prompt,omitempty"`
|
||||
AudioURL string `json:"audio_url,omitempty"`
|
||||
}
|
||||
|
||||
type dashScopeVideoParams struct {
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
Ratio string `json:"ratio,omitempty"`
|
||||
Size string `json:"size,omitempty"`
|
||||
Duration *int `json:"duration,omitempty"`
|
||||
PromptExtend *bool `json:"prompt_extend,omitempty"`
|
||||
Watermark *bool `json:"watermark,omitempty"`
|
||||
Seed *int `json:"seed,omitempty"`
|
||||
}
|
||||
|
||||
// dashScopeImageRequest DashScope 图片生成请求
|
||||
type dashScopeImageRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input dashScopeImageInput `json:"input"`
|
||||
Parameters map[string]any `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
type dashScopeImageInput struct {
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
NegativePrompt string `json:"negative_prompt,omitempty"`
|
||||
}
|
||||
|
||||
// dashScopeSubmitResponse DashScope 异步提交响应
|
||||
type dashScopeSubmitResponse struct {
|
||||
Output struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskStatus string `json:"task_status"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"output"`
|
||||
RequestID string `json:"request_id"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// dashScopeTaskResponse DashScope 异步任务查询响应
|
||||
type dashScopeTaskResponse struct {
|
||||
Output struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskStatus string `json:"task_status"`
|
||||
VideoURL string `json:"video_url"`
|
||||
Results []dashScopeResult `json:"results"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"output"`
|
||||
Usage struct {
|
||||
Duration float64 `json:"duration"`
|
||||
VideoCount int `json:"video_count"`
|
||||
OutputVideoDuration int `json:"output_video_duration"`
|
||||
SR int `json:"SR"`
|
||||
Ratio string `json:"ratio"`
|
||||
ImageCount int `json:"image_count"`
|
||||
} `json:"usage"`
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
|
||||
type dashScopeResult struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// ==================== Adaptor 实现 ====================
|
||||
|
||||
type AliAdaptor struct {
|
||||
info *common.RelayInfo
|
||||
isVideo bool // 是否视频生成(否则图片生成)
|
||||
}
|
||||
|
||||
func (a *AliAdaptor) Init(info *common.RelayInfo) {
|
||||
a.info = info
|
||||
}
|
||||
|
||||
func (a *AliImageAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, body []byte) *common.TaskError {
|
||||
// detectVideo 根据模型名判断是否为视频生成
|
||||
func (a *AliAdaptor) detectVideo(modelName string) bool {
|
||||
return strings.HasPrefix(modelName, "wan2.") || strings.HasPrefix(modelName, "wanx2.1-t2v")
|
||||
}
|
||||
|
||||
func (a *AliAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, body []byte) *common.TaskError {
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid request body"}
|
||||
@@ -36,34 +118,51 @@ func (a *AliImageAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo
|
||||
if _, ok := req["model"]; !ok {
|
||||
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "model is required"}
|
||||
}
|
||||
if _, ok := req["prompt"]; !ok {
|
||||
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "prompt is required"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AliImageAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]float64 {
|
||||
func (a *AliAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]float64 {
|
||||
ratios := map[string]float64{"base": 1.0}
|
||||
var req map[string]any
|
||||
if json.Unmarshal(body, &req) != nil {
|
||||
return ratios
|
||||
}
|
||||
if n, ok := req["n"].(float64); ok && n > 1 {
|
||||
ratios["count"] = n
|
||||
|
||||
if a.isVideo {
|
||||
// duration 影响计费
|
||||
if metadata, ok := req["metadata"].(map[string]any); ok {
|
||||
if v, ok := metadata["duration"].(float64); ok && v > 0 {
|
||||
ratios["duration"] = v
|
||||
}
|
||||
}
|
||||
if seconds, ok := req["seconds"].(string); ok {
|
||||
if d, err := parseInt(seconds); err == nil && d > 0 {
|
||||
ratios["duration"] = float64(d)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 图片数量
|
||||
if n, ok := req["n"].(float64); ok && n > 1 {
|
||||
ratios["count"] = n
|
||||
}
|
||||
}
|
||||
|
||||
return ratios
|
||||
}
|
||||
|
||||
func (a *AliImageAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 {
|
||||
func (a *AliAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AliImageAdaptor) BuildRequestURL(info *common.RelayInfo) (string, error) {
|
||||
func (a *AliAdaptor) BuildRequestURL(info *common.RelayInfo) (string, error) {
|
||||
baseURL := strings.TrimRight(info.ChannelMeta.BaseURL, "/")
|
||||
if a.isVideo {
|
||||
return baseURL + "/api/v1/services/aigc/video-generation/video-synthesis", nil
|
||||
}
|
||||
return baseURL + "/api/v1/services/aigc/text2image/image-synthesis", nil
|
||||
}
|
||||
|
||||
func (a *AliImageAdaptor) BuildRequestHeader(header http.Header, info *common.RelayInfo) error {
|
||||
func (a *AliAdaptor) BuildRequestHeader(header http.Header, info *common.RelayInfo) error {
|
||||
header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey)
|
||||
header.Set("Content-Type", "application/json")
|
||||
header.Set("Accept", "application/json")
|
||||
@@ -71,15 +170,143 @@ func (a *AliImageAdaptor) BuildRequestHeader(header http.Header, info *common.Re
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AliImageAdaptor) BuildRequestBody(_ context.Context, info *common.RelayInfo, body []byte) (io.Reader, error) {
|
||||
converted, err := convertToDashScopeRequest(body, info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func (a *AliAdaptor) BuildRequestBody(_ context.Context, info *common.RelayInfo, body []byte) (io.Reader, error) {
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return strings.NewReader(string(body)), nil
|
||||
}
|
||||
return strings.NewReader(string(converted)), nil
|
||||
|
||||
// 确定模型名,同时判断视频/图片
|
||||
modelName := ""
|
||||
if info.ChannelMeta.IsModelMapped && info.ChannelMeta.UpstreamModelName != "" {
|
||||
modelName = info.ChannelMeta.UpstreamModelName
|
||||
} else if m, ok := req["model"].(string); ok {
|
||||
modelName = m
|
||||
}
|
||||
a.isVideo = a.detectVideo(modelName)
|
||||
|
||||
if a.isVideo {
|
||||
return a.buildVideoRequest(info, req, modelName)
|
||||
}
|
||||
return a.buildImageRequest(info, req, modelName)
|
||||
}
|
||||
|
||||
func (a *AliImageAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) {
|
||||
func (a *AliAdaptor) buildVideoRequest(info *common.RelayInfo, req map[string]any, modelName string) (io.Reader, error) {
|
||||
dsReq := dashScopeVideoRequest{
|
||||
Input: dashScopeVideoInput{},
|
||||
}
|
||||
dsReq.Model = modelName
|
||||
|
||||
if v, ok := req["prompt"].(string); ok {
|
||||
dsReq.Input.Prompt = v
|
||||
}
|
||||
|
||||
params := &dashScopeVideoParams{}
|
||||
hasParams := false
|
||||
|
||||
if metadata, ok := req["metadata"].(map[string]any); ok {
|
||||
if v, ok := metadata["negative_prompt"].(string); ok {
|
||||
dsReq.Input.NegativePrompt = v
|
||||
}
|
||||
if v, ok := metadata["audio_url"].(string); ok {
|
||||
dsReq.Input.AudioURL = v
|
||||
}
|
||||
if v, ok := metadata["resolution"].(string); ok {
|
||||
params.Resolution = v
|
||||
hasParams = true
|
||||
}
|
||||
if v, ok := metadata["ratio"].(string); ok {
|
||||
params.Ratio = v
|
||||
hasParams = true
|
||||
}
|
||||
if v, ok := metadata["size"].(string); ok {
|
||||
params.Size = strings.ReplaceAll(v, "x", "*")
|
||||
hasParams = true
|
||||
}
|
||||
if v, ok := metadata["duration"].(float64); ok && v > 0 {
|
||||
d := int(v)
|
||||
params.Duration = &d
|
||||
hasParams = true
|
||||
}
|
||||
if v, ok := metadata["prompt_extend"].(bool); ok {
|
||||
params.PromptExtend = &v
|
||||
hasParams = true
|
||||
}
|
||||
if v, ok := metadata["watermark"].(bool); ok {
|
||||
params.Watermark = &v
|
||||
hasParams = true
|
||||
}
|
||||
if v, ok := metadata["seed"].(float64); ok {
|
||||
s := int(v)
|
||||
params.Seed = &s
|
||||
hasParams = true
|
||||
}
|
||||
}
|
||||
|
||||
if seconds, ok := req["seconds"].(string); ok {
|
||||
if d, err := parseInt(seconds); err == nil && d > 0 {
|
||||
params.Duration = &d
|
||||
hasParams = true
|
||||
}
|
||||
}
|
||||
|
||||
if hasParams {
|
||||
dsReq.Parameters = params
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dsReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal dashscope video request: %w", err)
|
||||
}
|
||||
return strings.NewReader(string(data)), nil
|
||||
}
|
||||
|
||||
func (a *AliAdaptor) buildImageRequest(info *common.RelayInfo, req map[string]any, modelName string) (io.Reader, error) {
|
||||
dsReq := dashScopeImageRequest{
|
||||
Input: dashScopeImageInput{},
|
||||
Parameters: make(map[string]any),
|
||||
}
|
||||
dsReq.Model = modelName
|
||||
|
||||
if v, ok := req["prompt"].(string); ok {
|
||||
dsReq.Input.Prompt = v
|
||||
}
|
||||
if v, ok := req["negative_prompt"].(string); ok {
|
||||
dsReq.Input.NegativePrompt = v
|
||||
}
|
||||
|
||||
// size: 1024x1024 → 1024*1024
|
||||
if v, ok := req["size"].(string); ok && v != "" {
|
||||
dsReq.Parameters["size"] = strings.ReplaceAll(v, "x", "*")
|
||||
}
|
||||
if v, ok := req["n"]; ok {
|
||||
dsReq.Parameters["n"] = v
|
||||
}
|
||||
if v, ok := req["seed"]; ok {
|
||||
dsReq.Parameters["seed"] = v
|
||||
}
|
||||
if v, ok := req["style"]; ok {
|
||||
dsReq.Parameters["style"] = v
|
||||
}
|
||||
if v, ok := req["ref_strength"]; ok {
|
||||
dsReq.Parameters["ref_strength"] = v
|
||||
}
|
||||
if v, ok := req["ref_img"]; ok {
|
||||
dsReq.Parameters["ref_img"] = v
|
||||
}
|
||||
|
||||
if len(dsReq.Parameters) == 0 {
|
||||
dsReq.Parameters = nil
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dsReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal dashscope image request: %w", err)
|
||||
}
|
||||
return strings.NewReader(string(data)), nil
|
||||
}
|
||||
|
||||
func (a *AliAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) {
|
||||
url, err := a.BuildRequestURL(info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -95,12 +322,22 @@ func (a *AliImageAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, r
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
func (a *AliImageAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *common.RelayInfo) (string, []byte, *common.TaskError) {
|
||||
func (a *AliAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *common.RelayInfo) (string, []byte, *common.TaskError) {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", nil, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "read response failed"}
|
||||
}
|
||||
|
||||
// DashScope 错误时可能在顶层返回 code + message
|
||||
var errResp dashScopeSubmitResponse
|
||||
if json.Unmarshal(body, &errResp) == nil && errResp.Code != "" {
|
||||
return "", body, &common.TaskError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: errResp.Message,
|
||||
ErrCode: errResp.Code,
|
||||
}
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", body, &common.TaskError{
|
||||
StatusCode: resp.StatusCode,
|
||||
@@ -114,7 +351,6 @@ func (a *AliImageAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *
|
||||
}
|
||||
|
||||
if result.Output.TaskID == "" {
|
||||
// DashScope 有时在错误时返回 200 但带有 code/message
|
||||
if result.Output.Code != "" {
|
||||
return "", body, &common.TaskError{
|
||||
StatusCode: resp.StatusCode,
|
||||
@@ -128,7 +364,7 @@ func (a *AliImageAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *
|
||||
return result.Output.TaskID, body, nil
|
||||
}
|
||||
|
||||
func (a *AliImageAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) {
|
||||
func (a *AliAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) {
|
||||
var data struct {
|
||||
TaskID string `json:"task_id"`
|
||||
}
|
||||
@@ -149,7 +385,7 @@ func (a *AliImageAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*h
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
func (a *AliImageAdaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error) {
|
||||
func (a *AliAdaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error) {
|
||||
var resp dashScopeTaskResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, fmt.Errorf("ali: parse task result: %w", err)
|
||||
@@ -167,6 +403,11 @@ func (a *AliImageAdaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error)
|
||||
case "SUCCEEDED":
|
||||
info.Status = common.TaskStatusSuccess
|
||||
info.Progress = "100%"
|
||||
// 视频结果
|
||||
if resp.Output.VideoURL != "" {
|
||||
info.ResultURL = resp.Output.VideoURL
|
||||
}
|
||||
// 图片结果
|
||||
if len(resp.Output.Results) > 0 {
|
||||
info.ResultURL = resp.Output.Results[0].URL
|
||||
for i, r := range resp.Output.Results {
|
||||
@@ -190,10 +431,18 @@ func (a *AliImageAdaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (a *AliImageAdaptor) GetModelList() []string {
|
||||
func (a *AliAdaptor) GetModelList() []string {
|
||||
return ModelList
|
||||
}
|
||||
|
||||
func (a *AliImageAdaptor) GetChannelName() string {
|
||||
func (a *AliAdaptor) GetChannelName() string {
|
||||
return channelName
|
||||
}
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
|
||||
func parseInt(s string) (int, error) {
|
||||
var n int
|
||||
_, err := fmt.Sscanf(s, "%d", &n)
|
||||
return n, err
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
package ali
|
||||
|
||||
const channelName = "AliImage"
|
||||
const channelName = "Ali"
|
||||
|
||||
// ModelList 支持异步图片生成的 DashScope 模型
|
||||
// ModelList 阿里云 DashScope 支持异步生成的模型列表(图片 + 视频)
|
||||
var ModelList = []string{
|
||||
// 视频生成
|
||||
"wan2.7-t2v-2026-04-25",
|
||||
"wan2.6-t2v",
|
||||
"wan2.6-t2v-us",
|
||||
"wan2.5-t2v-preview",
|
||||
"wan2.2-t2v-plus",
|
||||
"wanx2.1-t2v-turbo",
|
||||
"wanx2.1-t2v-plus",
|
||||
// 图片生成
|
||||
"wanx-v1",
|
||||
"wanx-v2",
|
||||
"wanx2.1-t2i-ediff",
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
package ali
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/qianfree/team-api/relay/common"
|
||||
)
|
||||
|
||||
// convertToDashScopeRequest 将 OpenAI Images 格式转换为 DashScope 原生格式
|
||||
// OpenAI: {model, prompt, size, n, style, ...}
|
||||
// DashScope: {model, input: {prompt, negative_prompt}, parameters: {size, n, style, ...}}
|
||||
func convertToDashScopeRequest(body []byte, info *common.RelayInfo) ([]byte, error) {
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, fmt.Errorf("parse request: %w", err)
|
||||
}
|
||||
|
||||
req := dashScopeRequest{
|
||||
Input: dashScopeInput{},
|
||||
Parameters: make(map[string]any),
|
||||
}
|
||||
|
||||
// model
|
||||
if info.ChannelMeta.IsModelMapped && info.ChannelMeta.UpstreamModelName != "" {
|
||||
req.Model = info.ChannelMeta.UpstreamModelName
|
||||
} else if v, ok := raw["model"]; ok {
|
||||
_ = json.Unmarshal(v, &req.Model)
|
||||
}
|
||||
|
||||
// prompt → input.prompt
|
||||
if v, ok := raw["prompt"]; ok {
|
||||
_ = json.Unmarshal(v, &req.Input.Prompt)
|
||||
}
|
||||
|
||||
// negative_prompt → input.negative_prompt
|
||||
if v, ok := raw["negative_prompt"]; ok {
|
||||
_ = json.Unmarshal(v, &req.Input.NegativePrompt)
|
||||
}
|
||||
|
||||
// size: 1024x1024 → 1024*1024
|
||||
if v, ok := raw["size"]; ok {
|
||||
var size string
|
||||
if err := json.Unmarshal(v, &size); err == nil && size != "" {
|
||||
req.Parameters["size"] = strings.ReplaceAll(size, "x", "*")
|
||||
}
|
||||
}
|
||||
|
||||
// n
|
||||
if v, ok := raw["n"]; ok {
|
||||
var n int
|
||||
if err := json.Unmarshal(v, &n); err == nil && n > 0 {
|
||||
req.Parameters["n"] = n
|
||||
}
|
||||
}
|
||||
|
||||
// seed
|
||||
if v, ok := raw["seed"]; ok {
|
||||
var seed int
|
||||
if err := json.Unmarshal(v, &seed); err == nil {
|
||||
req.Parameters["seed"] = seed
|
||||
}
|
||||
}
|
||||
|
||||
// style
|
||||
if v, ok := raw["style"]; ok {
|
||||
var style string
|
||||
if err := json.Unmarshal(v, &style); err == nil && style != "" {
|
||||
req.Parameters["style"] = style
|
||||
}
|
||||
}
|
||||
|
||||
// ref_strength
|
||||
if v, ok := raw["ref_strength"]; ok {
|
||||
var strength float64
|
||||
if err := json.Unmarshal(v, &strength); err == nil {
|
||||
req.Parameters["ref_strength"] = strength
|
||||
}
|
||||
}
|
||||
|
||||
// ref_img
|
||||
if v, ok := raw["ref_img"]; ok {
|
||||
var refImg string
|
||||
if err := json.Unmarshal(v, &refImg); err == nil && refImg != "" {
|
||||
req.Parameters["ref_img"] = refImg
|
||||
}
|
||||
}
|
||||
|
||||
if len(req.Parameters) == 0 {
|
||||
req.Parameters = nil
|
||||
}
|
||||
|
||||
result, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal dashscope request: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package ali
|
||||
|
||||
// dashScopeRequest DashScope 异步图片生成请求
|
||||
type dashScopeRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input dashScopeInput `json:"input"`
|
||||
Parameters map[string]any `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
// dashScopeInput DashScope 请求输入
|
||||
type dashScopeInput struct {
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
NegativePrompt string `json:"negative_prompt,omitempty"`
|
||||
}
|
||||
|
||||
// dashScopeSubmitResponse 异步提交响应
|
||||
type dashScopeSubmitResponse struct {
|
||||
Output struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskStatus string `json:"task_status"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
} `json:"output"`
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
|
||||
// dashScopeTaskResponse 异步任务轮询响应
|
||||
type dashScopeTaskResponse struct {
|
||||
Output struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskStatus string `json:"task_status"`
|
||||
Results []dashScopeResult `json:"results"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
} `json:"output"`
|
||||
Usage struct {
|
||||
ImageCount int `json:"image_count"`
|
||||
} `json:"usage,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
}
|
||||
|
||||
// dashScopeResult 单个图片结果
|
||||
type dashScopeResult struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
@@ -15,33 +15,118 @@ import (
|
||||
|
||||
const channelName = "Kling"
|
||||
|
||||
// ModelList 可灵视频生成模型列表
|
||||
var ModelList = []string{
|
||||
"kling-v1",
|
||||
"kling-v1-5",
|
||||
"kling-v1-6",
|
||||
"kling-v2-master",
|
||||
"kling-v2-1-master",
|
||||
"kling-v2-5-turbo",
|
||||
"kling-v2-6",
|
||||
"kling-v3",
|
||||
"kling-video-o1",
|
||||
}
|
||||
|
||||
func init() {
|
||||
taskchannel.Register(constant.ProviderKling, func() common.TaskAdaptor {
|
||||
return &KlingAdaptor{}
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 请求/响应结构体 ====================
|
||||
|
||||
// klingRequest 可灵视频生成请求(文生视频 + 图生视频共用)
|
||||
type klingRequest struct {
|
||||
ModelName string `json:"model_name"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
NegativePrompt string `json:"negative_prompt,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Duration string `json:"duration,omitempty"`
|
||||
AspectRatio string `json:"aspect_ratio,omitempty"`
|
||||
CfgScale *float64 `json:"cfg_scale,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
ImageTail string `json:"image_tail,omitempty"`
|
||||
Sound string `json:"sound,omitempty"`
|
||||
CameraControl map[string]any `json:"camera_control,omitempty"`
|
||||
}
|
||||
|
||||
// klingSubmitResponse 可灵任务提交响应
|
||||
type klingSubmitResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskStatus string `json:"task_status"`
|
||||
} `json:"data"`
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
|
||||
// klingTaskResponse 可灵任务查询响应
|
||||
type klingTaskResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskStatus string `json:"task_status"`
|
||||
TaskStatusMsg string `json:"task_status_msg"`
|
||||
TaskResult struct {
|
||||
Videos []struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Duration string `json:"duration"`
|
||||
} `json:"videos"`
|
||||
} `json:"task_result"`
|
||||
} `json:"data"`
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
|
||||
// ==================== Adaptor 实现 ====================
|
||||
|
||||
type KlingAdaptor struct {
|
||||
info *common.RelayInfo
|
||||
info *common.RelayInfo
|
||||
taskType string // text2video / image2video / omni-video
|
||||
}
|
||||
|
||||
func (a *KlingAdaptor) Init(info *common.RelayInfo) {
|
||||
a.info = info
|
||||
}
|
||||
|
||||
func (a *KlingAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, _ []byte) *common.TaskError {
|
||||
func (a *KlingAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, body []byte) *common.TaskError {
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid request body"}
|
||||
}
|
||||
if _, ok := req["model"]; !ok {
|
||||
return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "model is required"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *KlingAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]float64 {
|
||||
ratios := map[string]float64{"base": 1.0}
|
||||
var req map[string]any
|
||||
if json.Unmarshal(body, &req) == nil {
|
||||
// 高清模式加价
|
||||
if mode, ok := req["mode"]; ok && fmt.Sprintf("%v", mode) == "hq" {
|
||||
ratios["quality"] = 2.0
|
||||
if json.Unmarshal(body, &req) != nil {
|
||||
return ratios
|
||||
}
|
||||
|
||||
// 高清模式加价
|
||||
if mode, ok := req["mode"].(string); ok && mode == "pro" {
|
||||
ratios["quality"] = 3.5
|
||||
}
|
||||
|
||||
// 时长加价:10s = 2x
|
||||
if metadata, ok := req["metadata"].(map[string]any); ok {
|
||||
if v, ok := metadata["duration"].(float64); ok && v >= 10 {
|
||||
ratios["duration_multiplier"] = 2.0
|
||||
}
|
||||
}
|
||||
if seconds, ok := req["seconds"].(string); ok {
|
||||
if d, err := parseInt(seconds); err == nil && d >= 10 {
|
||||
ratios["duration_multiplier"] = 2.0
|
||||
}
|
||||
}
|
||||
|
||||
return ratios
|
||||
}
|
||||
|
||||
@@ -49,20 +134,47 @@ func (a *KlingAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[
|
||||
return nil
|
||||
}
|
||||
|
||||
// determineTaskType 根据请求内容判断任务类型
|
||||
func determineTaskType(req map[string]any) string {
|
||||
// Omni-Video 模型
|
||||
if modelName, ok := req["model"].(string); ok {
|
||||
if strings.HasPrefix(modelName, "kling-video-o") || strings.HasPrefix(modelName, "kling-v3") {
|
||||
return "omni-video"
|
||||
}
|
||||
}
|
||||
// 有图片输入则 image2video
|
||||
if metadata, ok := req["metadata"].(map[string]any); ok {
|
||||
if _, hasImg := metadata["image"]; hasImg {
|
||||
return "image2video"
|
||||
}
|
||||
}
|
||||
if _, hasImg := req["images"].([]any); hasImg {
|
||||
return "image2video"
|
||||
}
|
||||
return "text2video"
|
||||
}
|
||||
|
||||
func (a *KlingAdaptor) BuildRequestURL(info *common.RelayInfo) (string, error) {
|
||||
baseURL := strings.TrimRight(info.ChannelMeta.BaseURL, "/")
|
||||
// 根据是否有 image 参数决定 text2video 或 image2video
|
||||
// 默认使用 text2video
|
||||
// 根据 model 名判断路径(URL 在 BuildRequestBody 阶段确定 taskType,此处用 model 前置判断)
|
||||
modelName := info.ChannelMeta.UpstreamModelName
|
||||
if modelName == "" {
|
||||
modelName = info.OriginModelName
|
||||
}
|
||||
|
||||
if strings.HasPrefix(modelName, "kling-video-o") || strings.HasPrefix(modelName, "kling-v3") {
|
||||
return fmt.Sprintf("%s/v1/videos/omni-video", baseURL), nil
|
||||
}
|
||||
|
||||
// 默认 text2video,image2video 由 BuildRequestBody 设置 task_type 后 FetchTask 使用
|
||||
return fmt.Sprintf("%s/v1/videos/text2video", baseURL), nil
|
||||
}
|
||||
|
||||
func (a *KlingAdaptor) BuildRequestHeader(header http.Header, info *common.RelayInfo) error {
|
||||
apiKey := info.ChannelMeta.ApiKey
|
||||
// 如果是 sk- 开头的 key,直接用 Bearer token
|
||||
if strings.HasPrefix(apiKey, "sk-") {
|
||||
header.Set("Authorization", "Bearer "+apiKey)
|
||||
} else {
|
||||
// 否则生成 JWT token(accessKey|secretKey 格式)
|
||||
token := buildKlingJWT(apiKey)
|
||||
header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
@@ -76,34 +188,107 @@ func (a *KlingAdaptor) BuildRequestBody(_ context.Context, info *common.RelayInf
|
||||
return strings.NewReader(string(body)), nil
|
||||
}
|
||||
|
||||
if info.ChannelMeta.UpstreamModelName != "" {
|
||||
req["model_name"] = info.ChannelMeta.UpstreamModelName
|
||||
req["model"] = info.ChannelMeta.UpstreamModelName
|
||||
taskType := determineTaskType(req)
|
||||
kReq := klingRequest{}
|
||||
|
||||
// 模型名
|
||||
if info.ChannelMeta.IsModelMapped && info.ChannelMeta.UpstreamModelName != "" {
|
||||
kReq.ModelName = info.ChannelMeta.UpstreamModelName
|
||||
} else if m, ok := req["model"].(string); ok {
|
||||
kReq.ModelName = m
|
||||
}
|
||||
|
||||
// 如果没有指定 mode,默认 std
|
||||
if _, ok := req["mode"]; !ok {
|
||||
req["mode"] = "std"
|
||||
}
|
||||
// 如果没有指定 duration,默认 5
|
||||
if _, ok := req["duration"]; !ok {
|
||||
req["duration"] = "5"
|
||||
// prompt
|
||||
if v, ok := req["prompt"].(string); ok {
|
||||
kReq.Prompt = v
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(req)
|
||||
// 从 metadata 提取参数
|
||||
if metadata, ok := req["metadata"].(map[string]any); ok {
|
||||
if v, ok := metadata["negative_prompt"].(string); ok {
|
||||
kReq.NegativePrompt = v
|
||||
}
|
||||
if v, ok := metadata["mode"].(string); ok {
|
||||
kReq.Mode = v
|
||||
}
|
||||
if v, ok := metadata["duration"].(string); ok {
|
||||
kReq.Duration = v
|
||||
} else if v, ok := metadata["duration"].(float64); ok {
|
||||
kReq.Duration = fmt.Sprintf("%d", int(v))
|
||||
}
|
||||
if v, ok := metadata["aspect_ratio"].(string); ok {
|
||||
kReq.AspectRatio = v
|
||||
}
|
||||
if v, ok := metadata["cfg_scale"].(float64); ok {
|
||||
kReq.CfgScale = &v
|
||||
}
|
||||
if v, ok := metadata["image"].(string); ok {
|
||||
kReq.Image = v
|
||||
}
|
||||
if v, ok := metadata["image_tail"].(string); ok {
|
||||
kReq.ImageTail = v
|
||||
}
|
||||
if v, ok := metadata["sound"].(string); ok {
|
||||
kReq.Sound = v
|
||||
}
|
||||
if v, ok := metadata["camera_control"].(map[string]any); ok {
|
||||
kReq.CameraControl = v
|
||||
}
|
||||
}
|
||||
|
||||
// seconds 字段映射到 duration
|
||||
if seconds, ok := req["seconds"].(string); ok {
|
||||
kReq.Duration = seconds
|
||||
}
|
||||
|
||||
// images 数组 → 取第一张作为 image
|
||||
if images, ok := req["images"].([]any); ok && len(images) > 0 {
|
||||
if url, ok := images[0].(string); ok && kReq.Image == "" {
|
||||
kReq.Image = url
|
||||
}
|
||||
}
|
||||
|
||||
// 默认值
|
||||
if kReq.Mode == "" {
|
||||
kReq.Mode = "std"
|
||||
}
|
||||
if kReq.Duration == "" {
|
||||
kReq.Duration = "5"
|
||||
}
|
||||
|
||||
// 记录 taskType 供 DoRequest 使用正确的 URL
|
||||
if taskType == "image2video" || taskType == "omni-video" {
|
||||
a.taskType = taskType
|
||||
}
|
||||
|
||||
data, err := json.Marshal(kReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal kling request: %w", err)
|
||||
}
|
||||
return strings.NewReader(string(data)), nil
|
||||
}
|
||||
|
||||
func (a *KlingAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) {
|
||||
url, err := a.BuildRequestURL(info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
baseURL := strings.TrimRight(info.ChannelMeta.BaseURL, "/")
|
||||
|
||||
// 根据 taskType 决定 URL
|
||||
var reqURL string
|
||||
switch a.taskType {
|
||||
case "image2video":
|
||||
reqURL = fmt.Sprintf("%s/v1/videos/image2video", baseURL)
|
||||
case "omni-video":
|
||||
reqURL = fmt.Sprintf("%s/v1/videos/omni-video", baseURL)
|
||||
default:
|
||||
reqURL = fmt.Sprintf("%s/v1/videos/text2video", baseURL)
|
||||
}
|
||||
req, err := http.NewRequest("POST", url, requestBody)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, reqURL, requestBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
if err := a.BuildRequestHeader(req.Header, info); err != nil {
|
||||
return nil, fmt.Errorf("setup header: %w", err)
|
||||
}
|
||||
a.BuildRequestHeader(req.Header, info)
|
||||
client := &http.Client{Timeout: 120 * 1e9}
|
||||
return client.Do(req)
|
||||
}
|
||||
@@ -111,19 +296,14 @@ func (a *KlingAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requ
|
||||
func (a *KlingAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *common.RelayInfo) (string, []byte, *common.TaskError) {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", nil, &common.TaskError{StatusCode: 500, Message: "read response failed"}
|
||||
return "", nil, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "read response failed"}
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
TaskID string `json:"task_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
var result klingSubmitResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", body, &common.TaskError{StatusCode: 500, Message: "parse response failed"}
|
||||
return "", body, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "parse response failed"}
|
||||
}
|
||||
|
||||
if result.Code != 0 {
|
||||
return "", body, &common.TaskError{
|
||||
StatusCode: resp.StatusCode,
|
||||
@@ -131,16 +311,18 @@ func (a *KlingAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *com
|
||||
ErrCode: fmt.Sprintf("%d", result.Code),
|
||||
}
|
||||
}
|
||||
|
||||
if result.Data.TaskID == "" {
|
||||
return "", body, &common.TaskError{StatusCode: 500, Message: "upstream returned empty task id"}
|
||||
return "", body, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "upstream returned empty task id"}
|
||||
}
|
||||
|
||||
return result.Data.TaskID, body, nil
|
||||
}
|
||||
|
||||
func (a *KlingAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) {
|
||||
var data struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskType string `json:"task_type"` // text2video 或 image2video
|
||||
TaskType string `json:"task_type"`
|
||||
}
|
||||
if err := json.Unmarshal(taskData, &data); err != nil {
|
||||
return nil, fmt.Errorf("kling: invalid task data: %w", err)
|
||||
@@ -150,9 +332,10 @@ func (a *KlingAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http
|
||||
if taskType == "" {
|
||||
taskType = "text2video"
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/v1/videos/%s/%s", strings.TrimRight(baseURL, "/"), taskType, data.TaskID)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -162,27 +345,14 @@ func (a *KlingAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http
|
||||
} else {
|
||||
req.Header.Set("Authorization", "Bearer "+buildKlingJWT(apiKey))
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 30 * 1e9}
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
func (a *KlingAdaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error) {
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
TaskStatus string `json:"task_status"`
|
||||
TaskStatusMsg string `json:"task_status_msg"`
|
||||
TaskResult struct {
|
||||
Videos []struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Duration string `json:"duration"`
|
||||
} `json:"videos"`
|
||||
} `json:"task_result"`
|
||||
} `json:"data"`
|
||||
}
|
||||
var resp klingTaskResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, fmt.Errorf("kling: parse task result: %w", err)
|
||||
}
|
||||
@@ -198,6 +368,7 @@ func (a *KlingAdaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error) {
|
||||
switch resp.Data.TaskStatus {
|
||||
case "submitted":
|
||||
info.Status = common.TaskStatusSubmitted
|
||||
info.Progress = "10%"
|
||||
case "processing":
|
||||
info.Status = common.TaskStatusInProgress
|
||||
info.Progress = "50%"
|
||||
@@ -212,15 +383,24 @@ func (a *KlingAdaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error) {
|
||||
info.FailReason = resp.Data.TaskStatusMsg
|
||||
default:
|
||||
info.Status = common.TaskStatusSubmitted
|
||||
info.Progress = "10%"
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (a *KlingAdaptor) GetModelList() []string {
|
||||
return []string{"kling-v1", "kling-v1-6", "kling-v2-master"}
|
||||
return ModelList
|
||||
}
|
||||
|
||||
func (a *KlingAdaptor) GetChannelName() string {
|
||||
return channelName
|
||||
}
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
|
||||
func parseInt(s string) (int, error) {
|
||||
var n int
|
||||
_, err := fmt.Sscanf(s, "%d", &n)
|
||||
return n, err
|
||||
}
|
||||
@@ -134,6 +134,8 @@ func (a *VolcengineVideoAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []
|
||||
|
||||
func (a *VolcengineVideoAdaptor) BuildRequestURL(info *common.RelayInfo) (string, error) {
|
||||
baseURL := strings.TrimRight(info.ChannelMeta.BaseURL, "/")
|
||||
// 兼容 base URL 带 /api 后缀的情况
|
||||
baseURL = strings.TrimSuffix(baseURL, "/api")
|
||||
return fmt.Sprintf("%s/api/v3/contents/generations/tasks", baseURL), nil
|
||||
}
|
||||
|
||||
@@ -296,9 +298,13 @@ func (a *VolcengineVideoAdaptor) DoResponse(_ context.Context, resp *http.Respon
|
||||
|
||||
// 非成功状态码
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
msg := string(body)
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("upstream returned status %d", resp.StatusCode)
|
||||
}
|
||||
return "", body, &common.TaskError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: string(body),
|
||||
Message: msg,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,7 +326,7 @@ func (a *VolcengineVideoAdaptor) FetchTask(baseURL, apiKey string, taskData []by
|
||||
return nil, fmt.Errorf("volcengine: invalid task data: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/v3/contents/generations/tasks/%s", strings.TrimRight(baseURL, "/"), data.TaskID)
|
||||
url := fmt.Sprintf("%s/api/v3/contents/generations/tasks/%s", strings.TrimSuffix(strings.TrimRight(baseURL, "/"), "/api"), data.TaskID)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
@@ -343,6 +349,13 @@ func (a *VolcengineVideoAdaptor) ParseTaskResult(body []byte) (*common.TaskInfo,
|
||||
|
||||
info := &common.TaskInfo{Data: body}
|
||||
|
||||
// 提取 token 用量
|
||||
if resp.Usage.TotalTokens > 0 {
|
||||
info.CompletionTokens = resp.Usage.CompletionTokens
|
||||
info.TotalTokens = resp.Usage.TotalTokens
|
||||
info.PromptTokens = resp.Usage.TotalTokens - resp.Usage.CompletionTokens
|
||||
}
|
||||
|
||||
switch resp.Status {
|
||||
case "pending", "queued":
|
||||
info.Status = common.TaskStatusQueued
|
||||
@@ -354,9 +367,6 @@ func (a *VolcengineVideoAdaptor) ParseTaskResult(body []byte) (*common.TaskInfo,
|
||||
info.Status = common.TaskStatusSuccess
|
||||
info.Progress = "100%"
|
||||
info.ResultURL = resp.Content.VideoURL
|
||||
// 提取上游 usage 用于按 token 计费
|
||||
info.CompletionTokens = resp.Usage.CompletionTokens
|
||||
info.TotalTokens = resp.Usage.TotalTokens
|
||||
case "failed":
|
||||
info.Status = common.TaskStatusFailure
|
||||
info.FailReason = resp.Error.Message
|
||||
|
||||
@@ -28,6 +28,7 @@ const categoryOptions = [
|
||||
{ label: '向量', value: 'embedding' },
|
||||
{ label: '图像', value: 'image' },
|
||||
{ label: '音频', value: 'audio' },
|
||||
{ label: '视频', value: 'video' },
|
||||
{ label: '重排', value: 'rerank' },
|
||||
]
|
||||
|
||||
@@ -111,6 +112,7 @@ const categoryTagColor: Record<string, string> = {
|
||||
embedding: 'green',
|
||||
image: 'orangered',
|
||||
audio: 'red',
|
||||
video: 'purple',
|
||||
rerank: 'arcoblue',
|
||||
}
|
||||
|
||||
@@ -125,6 +127,7 @@ const categoryTagLabel: Record<string, string> = {
|
||||
embedding: '向量',
|
||||
image: '图像',
|
||||
audio: '音频',
|
||||
video: '视频',
|
||||
rerank: '重排',
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, h } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import {
|
||||
Tag, Button, Space, Popconfirm, Message,
|
||||
} from '@arco-design/web-vue'
|
||||
@@ -19,6 +20,7 @@ const pagination = reactive({
|
||||
|
||||
const filterStatus = ref<string | null>(null)
|
||||
const filterPlatform = ref<string | null>(null)
|
||||
const filterTaskId = ref('')
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: '' },
|
||||
@@ -148,6 +150,7 @@ async function fetchData() {
|
||||
}
|
||||
if (filterStatus.value) params.status = filterStatus.value
|
||||
if (filterPlatform.value) params.platform = filterPlatform.value
|
||||
if (filterTaskId.value) params.public_task_id = filterTaskId.value
|
||||
const res: any = await request.get('/admin/tasks', { params })
|
||||
const raw = res.data?.data
|
||||
data.value = (raw?.list || []).filter(Boolean)
|
||||
@@ -168,6 +171,7 @@ function handleFilter() {
|
||||
function resetFilter() {
|
||||
filterStatus.value = null
|
||||
filterPlatform.value = null
|
||||
filterTaskId.value = ''
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
}
|
||||
@@ -205,6 +209,10 @@ async function openDetail(row: any) {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const route = useRoute()
|
||||
if (route.query.public_task_id) {
|
||||
filterTaskId.value = String(route.query.public_task_id)
|
||||
}
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
@@ -216,6 +224,13 @@ onMounted(() => {
|
||||
<!-- Filters -->
|
||||
<ACard :bordered="false" class="mb-4">
|
||||
<ASpace wrap>
|
||||
<AInput
|
||||
v-model="filterTaskId"
|
||||
placeholder="任务ID"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@keydown.enter="handleFilter"
|
||||
/>
|
||||
<ASelect
|
||||
v-model="filterStatus"
|
||||
:options="statusOptions"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, h } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
Tag, Button,
|
||||
} from '@arco-design/web-vue'
|
||||
@@ -8,6 +8,8 @@ import type { TableColumnData } from '@arco-design/web-vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const data = ref<any[]>([])
|
||||
const pagination = reactive({
|
||||
@@ -22,6 +24,7 @@ const filter = reactive({
|
||||
api_key_id: '',
|
||||
username: '',
|
||||
request_id: '',
|
||||
task_id: '',
|
||||
method: '',
|
||||
status_code: '',
|
||||
date_range: [] as string[],
|
||||
@@ -59,6 +62,24 @@ const auditLevelLabel: Record<string, string> = {
|
||||
none: '关闭',
|
||||
}
|
||||
|
||||
const taskStatusLabel: Record<string, string> = {
|
||||
SUBMITTED: '已提交',
|
||||
QUEUED: '排队中',
|
||||
IN_PROGRESS: '处理中',
|
||||
SUCCESS: '成功',
|
||||
FAILURE: '失败',
|
||||
TIMEOUT: '超时',
|
||||
}
|
||||
|
||||
const taskStatusColor: Record<string, string> = {
|
||||
SUBMITTED: 'arcoblue',
|
||||
QUEUED: 'cyan',
|
||||
IN_PROGRESS: 'orange',
|
||||
SUCCESS: 'green',
|
||||
FAILURE: 'red',
|
||||
TIMEOUT: 'orangered',
|
||||
}
|
||||
|
||||
function formatJson(str: string): string {
|
||||
if (!str) return ''
|
||||
try {
|
||||
@@ -82,6 +103,11 @@ function formatMs(ms: number): string {
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
function goToTask(taskId: string) {
|
||||
showDetail.value = false
|
||||
router.push({ path: '/admin/task-logs', query: { public_task_id: taskId } })
|
||||
}
|
||||
|
||||
const columns: TableColumnData[] = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{ title: '租户', dataIndex: 'tenant_name', width: 120, ellipsis: true, render({ record }) {
|
||||
@@ -159,6 +185,7 @@ async function fetchData() {
|
||||
if (filter.username) params.username = filter.username
|
||||
if (filter.method) params.method = filter.method
|
||||
if (filter.request_id) params.request_id = filter.request_id
|
||||
if (filter.task_id) params.task_id = filter.task_id
|
||||
if (filter.status_code) params.status_code = parseInt(filter.status_code)
|
||||
if (filter.date_range.length === 2) {
|
||||
params.start_date = filter.date_range[0]
|
||||
@@ -181,11 +208,14 @@ async function fetchDetail(id: number) {
|
||||
showDetail.value = true
|
||||
try {
|
||||
const res: any = await request.get(`/admin/audit/request-logs/${id}`)
|
||||
const data = res.data?.data?.data || null
|
||||
if (data?.forwarding_trace) {
|
||||
data.forwarding_trace = parseJson(data.forwarding_trace)
|
||||
const detail = res.data?.data?.data || null
|
||||
if (detail?.forwarding_trace) {
|
||||
detail.forwarding_trace = parseJson(detail.forwarding_trace)
|
||||
}
|
||||
detailRecord.value = data
|
||||
if (detail?.task_upstream_headers) {
|
||||
detail.task_upstream_headers = parseJson(detail.task_upstream_headers)
|
||||
}
|
||||
detailRecord.value = detail
|
||||
} catch {
|
||||
detailRecord.value = null
|
||||
} finally {
|
||||
@@ -203,6 +233,7 @@ function handleReset() {
|
||||
filter.api_key_id = ''
|
||||
filter.username = ''
|
||||
filter.request_id = ''
|
||||
filter.task_id = ''
|
||||
filter.method = ''
|
||||
filter.status_code = ''
|
||||
filter.date_range = []
|
||||
@@ -215,6 +246,9 @@ onMounted(() => {
|
||||
if (route.query.request_id) {
|
||||
filter.request_id = String(route.query.request_id)
|
||||
}
|
||||
if (route.query.task_id) {
|
||||
filter.task_id = String(route.query.task_id)
|
||||
}
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
@@ -253,6 +287,13 @@ onMounted(() => {
|
||||
style="width: 200px"
|
||||
@keydown.enter="handleFilter"
|
||||
/>
|
||||
<a-input
|
||||
v-model="filter.task_id"
|
||||
placeholder="Task ID"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@keydown.enter="handleFilter"
|
||||
/>
|
||||
<a-select
|
||||
v-model="filter.method"
|
||||
placeholder="方法"
|
||||
@@ -306,9 +347,10 @@ onMounted(() => {
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-drawer v-model:visible="showDetail" :width="600" title="请求详情">
|
||||
<a-drawer v-model:visible="showDetail" :width="640" title="请求详情">
|
||||
<a-spin :loading="detailLoading" class="w-full">
|
||||
<template v-if="detailRecord">
|
||||
<!-- 基础信息 -->
|
||||
<a-descriptions :column="1" bordered size="medium">
|
||||
<a-descriptions-item label="Request ID">{{ detailRecord.request_id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="租户/用户/Key">{{ detailRecord.tenant_id }} / {{ detailRecord.user_id }} / {{ detailRecord.api_key_id }}</a-descriptions-item>
|
||||
@@ -319,10 +361,40 @@ onMounted(() => {
|
||||
<a-descriptions-item label="客户端IP">{{ detailRecord.client_ip }}</a-descriptions-item>
|
||||
<a-descriptions-item label="User-Agent">{{ detailRecord.user_agent }}</a-descriptions-item>
|
||||
<a-descriptions-item label="延迟">{{ formatMs(detailRecord.latency_ms) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="首Token用时">{{ detailRecord.first_token_ms ? formatMs(detailRecord.first_token_ms) : '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="首Token用时">{{ detailRecord.first_token_ms ? formatMs(detailRecord.first_token_ms) : '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="审计级别">{{ auditLevelLabel[detailRecord.audit_level] || detailRecord.audit_level }}</a-descriptions-item>
|
||||
<a-descriptions-item label="时间">{{ detailRecord.created_at }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<!-- 异步任务信息(紧跟基础信息) -->
|
||||
<div v-if="detailRecord.task_id" class="mt-4">
|
||||
<h4 style="margin-bottom:8px;color:var(--ta-text-primary)">异步任务</h4>
|
||||
<a-descriptions :column="1" bordered size="medium">
|
||||
<a-descriptions-item label="任务ID">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span>{{ detailRecord.task_id }}</span>
|
||||
<a-button size="mini" type="text" @click="goToTask(detailRecord.task_id)">
|
||||
<template #icon><icon-external-link /></template>
|
||||
查看任务
|
||||
</a-button>
|
||||
</div>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="任务状态">
|
||||
<Tag :color="taskStatusColor[detailRecord.task_status] || 'gray'" size="small">{{ taskStatusLabel[detailRecord.task_status] || detailRecord.task_status }}</Tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item v-if="detailRecord.task_completed_at" label="完成时间">{{ detailRecord.task_completed_at }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<div v-if="detailRecord.task_result" style="margin-top:12px">
|
||||
<h4 style="margin-bottom:8px;color:var(--ta-text-primary)">上游响应体</h4>
|
||||
<pre class="audit-body">{{ formatJson(detailRecord.task_result) }}</pre>
|
||||
</div>
|
||||
<div v-if="detailRecord.task_upstream_headers" style="margin-top:12px">
|
||||
<h4 style="margin-bottom:8px;color:var(--ta-text-primary)">上游响应头</h4>
|
||||
<pre class="audit-body">{{ formatJson(detailRecord.task_upstream_headers) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 转发路径追踪 -->
|
||||
<div v-if="detailRecord.forwarding_trace" class="mt-4">
|
||||
<h4 style="margin-bottom:8px;color:var(--ta-text-primary)">转发路径追踪</h4>
|
||||
<a-descriptions :column="1" bordered size="medium">
|
||||
@@ -369,6 +441,8 @@ onMounted(() => {
|
||||
</a-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 请求/响应内容 -->
|
||||
<div v-if="detailRecord.request_body" class="mt-4">
|
||||
<h4 style="margin-bottom:8px;color:var(--ta-text-primary)">请求体</h4>
|
||||
<pre class="audit-body">{{ formatJson(detailRecord.request_body) }}</pre>
|
||||
|
||||
@@ -37,7 +37,8 @@ const requestTypeOptions = [
|
||||
{ label: '全部类型', value: '' },
|
||||
{ label: '同步', value: '1' },
|
||||
{ label: '流式', value: '2' },
|
||||
{ label: 'WebSocket', value: '3' },
|
||||
{ label: '异步', value: '3' },
|
||||
{ label: 'WebSocket', value: '4' },
|
||||
]
|
||||
|
||||
const statusTagColor: Record<string, string> = {
|
||||
@@ -61,13 +62,15 @@ const statusLabel: Record<string, string> = {
|
||||
const requestTypeLabel: Record<string, string> = {
|
||||
'1': '同步',
|
||||
'2': '流式',
|
||||
'3': 'WebSocket',
|
||||
'3': '异步',
|
||||
'4': 'WebSocket',
|
||||
}
|
||||
|
||||
const requestTypeColor: Record<string, string> = {
|
||||
'1': 'gray',
|
||||
'2': 'blue',
|
||||
'3': 'purple',
|
||||
'3': 'orange',
|
||||
'4': 'purple',
|
||||
}
|
||||
|
||||
const billingModeLabel: Record<string, string> = {
|
||||
@@ -136,8 +139,11 @@ function copyText(text: string) {
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function viewAuditLog(requestId: string) {
|
||||
router.push({ name: 'AdminRequestAuditLogs', query: { request_id: requestId } })
|
||||
function viewAuditLog(requestId: string, taskId?: string) {
|
||||
const query: Record<string, string> = {}
|
||||
if (taskId) query.task_id = taskId
|
||||
else query.request_id = requestId
|
||||
router.push({ name: 'AdminRequestAuditLogs', query })
|
||||
}
|
||||
|
||||
function openDetail(record: any) {
|
||||
@@ -443,7 +449,14 @@ const { exporting, exportFile } = useExport({
|
||||
<span class="detail-value mono-text">
|
||||
{{ detailLog.request_id }}
|
||||
<a-link class="copy-btn" @click="copyText(detailLog.request_id)">复制</a-link>
|
||||
<a-link class="copy-btn" @click="viewAuditLog(detailLog.request_id)">查看审计日志</a-link>
|
||||
<a-link class="copy-btn" @click="viewAuditLog(detailLog.request_id, detailLog.task_id || undefined)">查看审计日志</a-link>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="detailLog.task_id" class="detail-item">
|
||||
<span class="detail-label">关联任务</span>
|
||||
<span class="detail-value mono-text">
|
||||
{{ detailLog.task_id }}
|
||||
<a-link class="copy-btn" @click="$router.push({ path: '/admin/task-logs', query: { public_task_id: detailLog.task_id } })">查看任务</a-link>
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
|
||||
@@ -54,6 +54,7 @@ const categoryLabel: Record<string, string> = {
|
||||
embedding: '嵌入',
|
||||
image: '图像',
|
||||
audio: '语音',
|
||||
video: '视频',
|
||||
rerank: '重排',
|
||||
}
|
||||
const categoryBadgeClass: Record<string, string> = {
|
||||
@@ -61,6 +62,7 @@ const categoryBadgeClass: Record<string, string> = {
|
||||
embedding: 'badge-purple',
|
||||
image: 'badge-warning',
|
||||
audio: 'badge-success',
|
||||
video: 'badge-purple',
|
||||
rerank: 'badge-gray',
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,12 @@ const tenantRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/service/RequestAuditLogsPage.vue'),
|
||||
meta: { title: '请求审计日志', icon: 'clipboard', sort: 34, roles: ['owner', 'admin'] },
|
||||
},
|
||||
{
|
||||
path: 'task-logs',
|
||||
name: 'TenantTaskLogs',
|
||||
component: () => import('@/views/service/TaskLogsPage.vue'),
|
||||
meta: { title: '任务日志', icon: 'clock', sort: 35, roles: ['owner', 'admin', 'member'] },
|
||||
},
|
||||
// 财务 (40-49)
|
||||
{
|
||||
path: 'wallet',
|
||||
|
||||
@@ -15,6 +15,9 @@ interface RequestLog {
|
||||
latency_ms: number
|
||||
first_token_ms: number
|
||||
audit_level: string
|
||||
task_id?: string
|
||||
task_status?: string
|
||||
task_completed_at?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -28,6 +31,7 @@ const logTotalPages = computed(() => Math.ceil(logTotal.value / logPageSize))
|
||||
const logFilter = reactive({
|
||||
username: '',
|
||||
request_id: '',
|
||||
task_id: '',
|
||||
path: '',
|
||||
status_code: '',
|
||||
start_date: '',
|
||||
@@ -45,6 +49,24 @@ const auditLevelLabel: Record<string, string> = {
|
||||
none: '关闭',
|
||||
}
|
||||
|
||||
const taskStatusLabel: Record<string, string> = {
|
||||
SUBMITTED: '已提交',
|
||||
QUEUED: '排队中',
|
||||
IN_PROGRESS: '处理中',
|
||||
SUCCESS: '成功',
|
||||
FAILURE: '失败',
|
||||
TIMEOUT: '超时',
|
||||
}
|
||||
|
||||
const taskStatusBadge: Record<string, string> = {
|
||||
SUBMITTED: 'bg-blue-100 text-blue-800',
|
||||
QUEUED: 'bg-cyan-100 text-cyan-800',
|
||||
IN_PROGRESS: 'bg-amber-100 text-amber-800',
|
||||
SUCCESS: 'bg-emerald-100 text-emerald-800',
|
||||
FAILURE: 'bg-red-100 text-red-800',
|
||||
TIMEOUT: 'bg-orange-100 text-orange-800',
|
||||
}
|
||||
|
||||
function formatMs(ms: number): string {
|
||||
if (!ms && ms !== 0) return '-'
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
@@ -99,6 +121,7 @@ async function fetchRequestLogs() {
|
||||
}
|
||||
if (logFilter.username) params.username = logFilter.username
|
||||
if (logFilter.request_id) params.request_id = logFilter.request_id
|
||||
if (logFilter.task_id) params.task_id = logFilter.task_id
|
||||
if (logFilter.path) params.path = logFilter.path
|
||||
if (logFilter.status_code) params.status_code = parseInt(logFilter.status_code)
|
||||
if (logFilter.start_date) params.start_date = logFilter.start_date
|
||||
@@ -143,6 +166,7 @@ function handleFilter() {
|
||||
function handleReset() {
|
||||
logFilter.username = ''
|
||||
logFilter.request_id = ''
|
||||
logFilter.task_id = ''
|
||||
logFilter.path = ''
|
||||
logFilter.status_code = ''
|
||||
logFilter.start_date = ''
|
||||
@@ -156,6 +180,9 @@ onMounted(() => {
|
||||
if (route.query.request_id) {
|
||||
logFilter.request_id = String(route.query.request_id)
|
||||
}
|
||||
if (route.query.task_id) {
|
||||
logFilter.task_id = String(route.query.task_id)
|
||||
}
|
||||
fetchRequestLogs()
|
||||
})
|
||||
</script>
|
||||
@@ -191,6 +218,10 @@ onMounted(() => {
|
||||
<label class="text-sm text-gray-500 whitespace-nowrap">Request ID</label>
|
||||
<input v-model="logFilter.request_id" class="input" placeholder="请求 ID" style="width:180px" @keydown.enter="handleFilter" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-500 whitespace-nowrap">Task ID</label>
|
||||
<input v-model="logFilter.task_id" class="input" placeholder="任务 ID" style="width:180px" @keydown.enter="handleFilter" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-500 whitespace-nowrap">请求路径</label>
|
||||
<input v-model="logFilter.path" class="input" placeholder="例如:/v1/chat" style="width:160px" @keydown.enter="handleFilter" />
|
||||
@@ -240,6 +271,7 @@ onMounted(() => {
|
||||
<th class="min-w-20">延迟</th>
|
||||
<th class="min-w-25">首Token</th>
|
||||
<th class="min-w-30">审计级别</th>
|
||||
<th class="min-w-25">任务</th>
|
||||
<th class="min-w-30">时间</th>
|
||||
<th class="min-w-20"></th>
|
||||
</tr>
|
||||
@@ -275,6 +307,10 @@ onMounted(() => {
|
||||
<td>
|
||||
<span class="text-xs text-gray-500">{{ auditLevelLabel[log.audit_level] || log.audit_level }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="log.task_id" class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="taskStatusBadge[log.task_status] || 'bg-gray-100 text-gray-800'">{{ taskStatusLabel[log.task_status] || log.task_status || '-' }}</span>
|
||||
<span v-else class="text-xs text-gray-300">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-xs text-gray-500">{{ log.created_at ? new Date(log.created_at).toLocaleString() : '-' }}</span>
|
||||
</td>
|
||||
@@ -374,6 +410,32 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Async Task Info -->
|
||||
<div v-if="detailRecord.task_id" class="mb-6">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-3 flex items-center gap-1.5">
|
||||
<svg class="h-4 w-4 text-primary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
异步任务
|
||||
</h4>
|
||||
<div class="bg-gray-50 rounded-xl p-4 space-y-2 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-500">任务 ID</span>
|
||||
<span class="font-mono text-xs">{{ detailRecord.task_id }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-500">任务状态</span>
|
||||
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="taskStatusBadge[detailRecord.task_status] || 'bg-gray-100 text-gray-800'">{{ taskStatusLabel[detailRecord.task_status] || detailRecord.task_status || '-' }}</span>
|
||||
</div>
|
||||
<div v-if="detailRecord.task_completed_at" class="flex items-center justify-between">
|
||||
<span class="text-gray-500">完成时间</span>
|
||||
<span class="text-xs text-gray-700">{{ detailRecord.task_completed_at }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="detailRecord.task_result" class="mt-3">
|
||||
<h5 class="text-xs font-medium text-gray-500 mb-1">上游响应</h5>
|
||||
<pre class="code-block text-xs max-h-[300px] overflow-auto whitespace-pre-wrap break-all">{{ formatJson(detailRecord.task_result) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Request Body -->
|
||||
<div v-if="detailRecord.request_body" class="mb-4">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">请求体(输入)</h4>
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import BaseModal from '@/components/common/BaseModal.vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
interface TaskItem {
|
||||
id: number
|
||||
public_task_id: string
|
||||
platform: string
|
||||
action: string
|
||||
status: string
|
||||
progress: string
|
||||
model_name: string
|
||||
fail_reason?: string
|
||||
pre_deduct_amount: number
|
||||
actual_cost: number
|
||||
billing_settled: boolean
|
||||
result_url?: string
|
||||
username?: string
|
||||
submit_time?: string
|
||||
finish_time?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const tasks = ref<TaskItem[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
const totalPages = computed(() => Math.ceil(total.value / pageSize))
|
||||
|
||||
const filterStatus = ref('')
|
||||
const filterPlatform = ref('')
|
||||
const filterTaskId = ref('')
|
||||
|
||||
const showDetail = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailTask = ref<TaskItem | null>(null)
|
||||
|
||||
const statusBadge: Record<string, string> = {
|
||||
NOT_START: 'bg-gray-100 text-gray-800',
|
||||
SUBMITTED: 'bg-blue-100 text-blue-800',
|
||||
IN_PROGRESS: 'bg-amber-100 text-amber-800',
|
||||
SUCCESS: 'bg-emerald-100 text-emerald-800',
|
||||
FAILURE: 'bg-red-100 text-red-800',
|
||||
TIMEOUT: 'bg-orange-100 text-orange-800',
|
||||
}
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
NOT_START: '未开始',
|
||||
SUBMITTED: '已提交',
|
||||
IN_PROGRESS: '进行中',
|
||||
SUCCESS: '成功',
|
||||
FAILURE: '失败',
|
||||
TIMEOUT: '超时',
|
||||
}
|
||||
|
||||
const platformLabel: Record<string, string> = {
|
||||
sora: 'Sora',
|
||||
kling: 'Kling',
|
||||
midjourney: 'Midjourney',
|
||||
suno: 'Suno',
|
||||
}
|
||||
|
||||
function formatCost(n: number | undefined): string {
|
||||
if (!n) return '$0.0000'
|
||||
return '$' + n.toFixed(4)
|
||||
}
|
||||
|
||||
function formatTime(s: string | undefined): string {
|
||||
if (!s) return '-'
|
||||
return s.replace('T', ' ').substring(0, 19)
|
||||
}
|
||||
|
||||
async function fetchTasks() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
page: page.value,
|
||||
page_size: pageSize,
|
||||
}
|
||||
if (filterStatus.value) params.status = filterStatus.value
|
||||
if (filterPlatform.value) params.platform = filterPlatform.value
|
||||
if (filterTaskId.value) params.public_task_id = filterTaskId.value
|
||||
|
||||
const res: any = await request.get('/tenant/tasks', { params })
|
||||
const raw = res.data?.data
|
||||
tasks.value = Array.isArray(raw?.list) ? raw.list : []
|
||||
total.value = raw?.total || 0
|
||||
} catch {
|
||||
tasks.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(task: TaskItem) {
|
||||
detailTask.value = task
|
||||
showDetail.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get(`/tenant/tasks/${task.id}`)
|
||||
const raw = res.data?.data
|
||||
if (raw?.task) {
|
||||
detailTask.value = raw.task
|
||||
}
|
||||
} catch {
|
||||
// keep the list-level data
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
page.value = 1
|
||||
fetchTasks()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filterStatus.value = ''
|
||||
filterPlatform.value = ''
|
||||
filterTaskId.value = ''
|
||||
page.value = 1
|
||||
fetchTasks()
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (page.value > 1) { page.value--; fetchTasks() }
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (page.value * pageSize < total.value) { page.value++; fetchTasks() }
|
||||
}
|
||||
|
||||
function isImageResult(url: string | undefined): boolean {
|
||||
if (!url) return false
|
||||
return /\.(jpg|jpeg|png|gif|webp)(\?|$)/i.test(url)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const route = useRoute()
|
||||
if (route.query.public_task_id) {
|
||||
filterTaskId.value = String(route.query.public_task_id)
|
||||
}
|
||||
fetchTasks()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">任务日志</h1>
|
||||
<p class="page-description">查看异步生成任务(视频/图片/音乐)的执行记录</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-500 whitespace-nowrap">任务 ID</label>
|
||||
<input v-model="filterTaskId" class="input" placeholder="搜索任务 ID" style="width:200px" @keydown.enter="applyFilters" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-500 whitespace-nowrap">状态</label>
|
||||
<select v-model="filterStatus" class="input bg-white" style="width:120px">
|
||||
<option value="">全部</option>
|
||||
<option value="NOT_START">未开始</option>
|
||||
<option value="SUBMITTED">已提交</option>
|
||||
<option value="IN_PROGRESS">进行中</option>
|
||||
<option value="SUCCESS">成功</option>
|
||||
<option value="FAILURE">失败</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-500 whitespace-nowrap">平台</label>
|
||||
<select v-model="filterPlatform" class="input bg-white" style="width:120px">
|
||||
<option value="">全部</option>
|
||||
<option value="sora">Sora</option>
|
||||
<option value="kling">Kling</option>
|
||||
<option value="midjourney">Midjourney</option>
|
||||
<option value="suno">Suno</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button class="btn btn-primary btn-sm" @click="applyFilters">搜索</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="resetFilters">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card p-0 overflow-hidden">
|
||||
<div v-if="loading" class="p-8 text-center">
|
||||
<div class="spinner mx-auto mb-3"></div>
|
||||
<p class="text-sm text-gray-500">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="tasks.length === 0" class="empty-state">
|
||||
<Icon name="clipboard" size="xl" class="empty-state-icon text-gray-300" />
|
||||
<p class="empty-state-title">暂无任务记录</p>
|
||||
<p class="empty-state-description">异步生成任务的执行记录将显示在这里</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>任务 ID</th>
|
||||
<th>平台</th>
|
||||
<th>状态</th>
|
||||
<th>模型</th>
|
||||
<th>费用</th>
|
||||
<th>提交时间</th>
|
||||
<th>完成时间</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="task in tasks" :key="task.id">
|
||||
<td>
|
||||
<span class="font-mono text-xs text-gray-600">{{ task.public_task_id }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-sm font-medium text-gray-700">{{ platformLabel[task.platform] || task.platform }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium" :class="statusBadge[task.status] || 'bg-gray-100 text-gray-800'">
|
||||
{{ statusLabel[task.status] || task.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-sm text-gray-700">{{ task.model_name || '-' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="task.billing_settled && task.actual_cost > 0" class="text-sm font-medium text-emerald-600">{{ formatCost(task.actual_cost) }}</span>
|
||||
<span v-else-if="task.pre_deduct_amount > 0" class="text-sm text-gray-500">{{ formatCost(task.pre_deduct_amount) }} <span class="text-xs text-gray-400">(预扣)</span></span>
|
||||
<span v-else class="text-sm text-gray-400">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-xs text-gray-500 whitespace-nowrap">{{ formatTime(task.submit_time) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-xs text-gray-500 whitespace-nowrap">{{ formatTime(task.finish_time) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-ghost btn-sm p-1.5" title="查看详情" @click="openDetail(task)">
|
||||
<Icon name="eye" size="sm" class="text-gray-400 hover:text-primary-500" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-between px-6 py-3 border-t border-gray-100">
|
||||
<span class="text-xs text-gray-500">共 {{ total }} 条记录</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-ghost btn-sm" :disabled="page <= 1" @click="prevPage">上一页</button>
|
||||
<span class="text-sm text-gray-600">{{ page }} / {{ totalPages }}</span>
|
||||
<button class="btn btn-ghost btn-sm" :disabled="page >= totalPages" @click="nextPage">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Modal -->
|
||||
<BaseModal :show="showDetail" title="任务详情" width="extra-wide" @close="showDetail = false">
|
||||
<div v-if="detailLoading" class="p-8 text-center">
|
||||
<div class="spinner mx-auto mb-3"></div>
|
||||
<p class="text-sm text-gray-500">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="detailTask" class="space-y-5">
|
||||
<!-- Basic Info -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
|
||||
<Icon name="document" size="sm" class="text-primary-500" />
|
||||
基本信息
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 gap-x-6 gap-y-2.5 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">任务 ID</span>
|
||||
<span class="font-mono text-xs">{{ detailTask.public_task_id }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">状态</span>
|
||||
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium" :class="statusBadge[detailTask.status] || 'bg-gray-100 text-gray-800'">
|
||||
{{ statusLabel[detailTask.status] || detailTask.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">平台</span>
|
||||
<span class="text-sm">{{ platformLabel[detailTask.platform] || detailTask.platform }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">动作</span>
|
||||
<span class="text-sm">{{ detailTask.action || '-' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">模型</span>
|
||||
<span class="text-sm font-mono">{{ detailTask.model_name }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">进度</span>
|
||||
<span class="text-sm">{{ detailTask.progress || '-' }}</span>
|
||||
</div>
|
||||
<div v-if="detailTask.username" class="flex justify-between">
|
||||
<span class="text-gray-500">用户</span>
|
||||
<span class="text-sm">{{ detailTask.username }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">提交时间</span>
|
||||
<span class="text-xs">{{ formatTime(detailTask.submit_time) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">完成时间</span>
|
||||
<span class="text-xs">{{ formatTime(detailTask.finish_time) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">创建时间</span>
|
||||
<span class="text-xs">{{ formatTime(detailTask.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cost -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
|
||||
<Icon name="creditCard" size="sm" class="text-primary-500" />
|
||||
费用信息
|
||||
</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-500">预扣金额</span>
|
||||
<span>{{ formatCost(detailTask.pre_deduct_amount) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between border-t border-gray-200 pt-2 font-semibold">
|
||||
<span class="text-gray-700">实际费用</span>
|
||||
<span v-if="detailTask.billing_settled" class="text-emerald-600">{{ formatCost(detailTask.actual_cost) }}</span>
|
||||
<span v-else class="text-gray-400">未结算</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result -->
|
||||
<div v-if="detailTask.result_url">
|
||||
<h4 class="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
|
||||
<Icon name="image" size="sm" class="text-primary-500" />
|
||||
生成结果
|
||||
</h4>
|
||||
<div class="bg-gray-50 rounded-xl p-4">
|
||||
<img
|
||||
v-if="isImageResult(detailTask.result_url)"
|
||||
:src="detailTask.result_url"
|
||||
alt="任务结果"
|
||||
class="max-w-full rounded-lg"
|
||||
/>
|
||||
<a v-else :href="detailTask.result_url" target="_blank" class="text-primary-600 hover:text-primary-700 text-sm break-all">
|
||||
{{ detailTask.result_url }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fail Reason -->
|
||||
<div v-if="detailTask.fail_reason">
|
||||
<h4 class="text-sm font-semibold text-red-600 mb-3 flex items-center gap-2">
|
||||
<Icon name="xCircle" size="sm" />
|
||||
失败原因
|
||||
</h4>
|
||||
<div class="rounded-lg bg-red-50 border border-red-100 px-4 py-3 text-sm text-red-700">
|
||||
{{ detailTask.fail_reason }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<button class="btn btn-secondary" @click="showDetail = false">关闭</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -66,13 +66,15 @@ const statusLabel: Record<string, string> = {
|
||||
const requestTypeBadge: Record<number, string> = {
|
||||
1: 'bg-gray-100 text-gray-800',
|
||||
2: 'bg-blue-100 text-blue-800',
|
||||
3: 'bg-violet-100 text-violet-800',
|
||||
3: 'bg-orange-100 text-orange-800',
|
||||
4: 'bg-violet-100 text-violet-800',
|
||||
}
|
||||
|
||||
const requestTypeLabel: Record<number, string> = {
|
||||
1: '同步',
|
||||
2: '流式',
|
||||
3: 'WebSocket',
|
||||
3: '异步',
|
||||
4: 'WebSocket',
|
||||
}
|
||||
|
||||
const billingModeBadge: Record<string, string> = {
|
||||
@@ -93,6 +95,7 @@ const billingSourceLabel: Record<string, string> = {
|
||||
tenant: '租户定价',
|
||||
custom: '自定义',
|
||||
plan: '套餐价',
|
||||
task: '异步任务',
|
||||
}
|
||||
|
||||
async function fetchLogs() {
|
||||
@@ -167,8 +170,11 @@ function copyText(text: string) {
|
||||
navigator.clipboard.writeText(text).then(() => {}).catch(() => {})
|
||||
}
|
||||
|
||||
function viewAuditLog(requestId: string) {
|
||||
router.push({ name: 'TenantRequestAuditLogs', query: { request_id: requestId } })
|
||||
function viewAuditLog(requestId: string, taskId?: string) {
|
||||
const query: Record<string, string> = {}
|
||||
if (taskId) query.task_id = taskId
|
||||
else query.request_id = requestId
|
||||
router.push({ name: 'TenantRequestAuditLogs', query })
|
||||
}
|
||||
|
||||
function hasCacheTokens(log: any): boolean {
|
||||
@@ -282,6 +288,7 @@ onMounted(() => {
|
||||
<option value="">全部</option>
|
||||
<option value="1">同步</option>
|
||||
<option value="2">流式</option>
|
||||
<option value="3">异步</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
@@ -592,11 +599,18 @@ onMounted(() => {
|
||||
<button class="text-gray-400 hover:text-primary-500" @click.stop="copyText(detailLog.request_id)">
|
||||
<Icon name="copy" size="xs" />
|
||||
</button>
|
||||
<button class="text-xs text-primary-500 hover:text-primary-700 ml-1" @click.stop="viewAuditLog(detailLog.request_id)">
|
||||
<button class="text-xs text-primary-500 hover:text-primary-700 ml-1" @click.stop="viewAuditLog(detailLog.request_id, detailLog.task_id || undefined)">
|
||||
查看审计日志
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="detailLog.task_id" class="flex justify-between">
|
||||
<span class="text-gray-500">关联任务</span>
|
||||
<span class="font-mono text-xs flex items-center gap-1">
|
||||
{{ detailLog.task_id }}
|
||||
<button class="text-xs text-primary-500 hover:text-primary-700 ml-1" @click.stop="$router.push({ path: '/tenant/task-logs', query: { public_task_id: detailLog.task_id } })">查看任务</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">渠道</span>
|
||||
<span>{{ detailLog.channel_name || '-' }} <span v-if="detailLog.channel_type" class="text-xs text-gray-400">(类型: {{ detailLog.channel_type }})</span></span>
|
||||
|
||||
Reference in new issue
Block a user