Merge branch 'feat/video' into dev

This commit is contained in:
qian committed 2026-05-20 00:40:51 +08:00
commit b04e656667
55 files changed
+2231 -474

No files matched your search

+1
View File
@@ -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:"状态码"`
+3 -3
View File
@@ -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:"模型能力特性"`
+6 -5
View File
@@ -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 {
+2
View File
@@ -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)
+1
View File
@@ -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:"开始日期"`
+49
View File
@@ -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)
}
+22
View File
@@ -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)
}
+60 -50
View File
@@ -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.
+3 -1
View File
@@ -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.
+2
View File
@@ -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",
+91 -11
View File
@@ -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",
})
}
+4
View File
@@ -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)
+1 -1
View File
@@ -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,
)
+2 -1
View File
@@ -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
+3
View File
@@ -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"`
+19 -7
View File
@@ -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)
}
}
+67
View File
@@ -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
+88
View File
@@ -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
}
+54 -4
View File
@@ -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
}
+7 -1
View File
@@ -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
}
+2 -2
View File
@@ -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
+168
View File
@@ -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
}
+31 -26
View File
@@ -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 // 异步任务达到终态的时间
}
+2 -1
View File
@@ -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,普通请求为空
}
+1
View File
@@ -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
}
+30 -25
View File
@@ -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:"异步任务达到终态的时间"` // 异步任务达到终态的时间
}
+57 -56
View File
@@ -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,普通请求为空
}
+25 -24
View File
@@ -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:""` //
}
+1 -1
View File
@@ -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)
+6
View File
@@ -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 获取租户工单列表
+59
View File
@@ -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';
+16 -1
View File
@@ -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 端点)
+4 -3
View File
@@ -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 任务状态枚举
+5
View File
@@ -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 等敏感数据
+19
View File
@@ -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,
+276 -27
View File
@@ -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
}
+11 -2
View File
@@ -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",
-99
View File
@@ -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
}
-45
View File
@@ -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"`
}
+235 -55
View File
@@ -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
}
+15 -5
View File
@@ -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
+3
View File
@@ -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: '重排',
}
+15
View File
@@ -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>
+19 -6
View File
@@ -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',
}
+6
View File
@@ -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>
+19 -5
View File
@@ -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>