From e4f31eae5cbe29cdeff00f27f30486a37992dda7 Mon Sep 17 00:00:00 2001 From: qian Date: Tue, 22 Sep 2026 21:46:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(response):=E4=BC=98=E5=8C=96=E4=B8=8B?= =?UTF-8?q?=E6=8B=89=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/admin/admin.go | 1 + api/admin/v1/channel.go | 18 + internal/controller/admin/admin_v1_channel.go | 3 + internal/logic/admin/channel.go | 58 +++ internal/logic/admin/channel_oauth.go | 3 + internal/logic/admin/model.go | 89 +++-- internal/logic/admin/model_import_export.go | 8 +- internal/logic/common/audit_enrich.go | 334 +++++++++++++----- internal/logic/common/audit_enrich_test.go | 108 ++++++ internal/logic/common/cache.go | 89 +++++ internal/service/admin.go | 13 +- internal/service/tenant.go | 2 + .../views/monitor/ModelPerformancePage.vue | 7 +- web/admin/src/views/tenant/UsageLogsPage.vue | 47 +-- .../src/views/tenant/detail/ModelsTab.vue | 6 +- .../src/views/marketplace/marketplaceMeta.ts | 1 + web/tenant/src/views/service/ModelsPage.vue | 3 + .../src/views/team/MemberDetailPage.vue | 2 + 18 files changed, 649 insertions(+), 143 deletions(-) create mode 100644 internal/logic/common/audit_enrich_test.go diff --git a/api/admin/admin.go b/api/admin/admin.go index 90ac2c0..4fea9c4 100644 --- a/api/admin/admin.go +++ b/api/admin/admin.go @@ -52,6 +52,7 @@ type IAdminV1 interface { ChangelogDelete(ctx context.Context, req *v1.ChangelogDeleteReq) (res *v1.ChangelogDeleteRes, err error) ChangelogPublish(ctx context.Context, req *v1.ChangelogPublishReq) (res *v1.ChangelogPublishRes, err error) ChannelList(ctx context.Context, req *v1.ChannelListReq) (res *v1.ChannelListRes, err error) + ChannelOptions(ctx context.Context, req *v1.ChannelOptionsReq) (res *v1.ChannelOptionsRes, err error) ChannelCreate(ctx context.Context, req *v1.ChannelCreateReq) (res *v1.ChannelCreateRes, err error) ChannelUpdate(ctx context.Context, req *v1.ChannelUpdateReq) (res *v1.ChannelUpdateRes, err error) ChannelDelete(ctx context.Context, req *v1.ChannelDeleteReq) (res *v1.ChannelDeleteRes, err error) diff --git a/api/admin/v1/channel.go b/api/admin/v1/channel.go index 3b9f9f6..c8d677d 100644 --- a/api/admin/v1/channel.go +++ b/api/admin/v1/channel.go @@ -51,6 +51,24 @@ type ChannelItem struct { WorstModelScore float64 `json:"worst_model_score"` } +// ChannelOptionsReq 渠道选项列表请求(下拉选择专用,不分页) +type ChannelOptionsReq struct { + g.Meta `path:"/channels/options" method:"get" mime:"json" tags:"管理后台-渠道" summary:"渠道选项列表(不分页)"` +} + +// ChannelOptionsRes 渠道选项列表响应 +type ChannelOptionsRes struct { + List []ChannelOptionItem `json:"list"` +} + +// ChannelOptionItem 渠道选项项(精简字段) +type ChannelOptionItem struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type int `json:"type"` + Status string `json:"status"` +} + // ChannelCreateReq 创建渠道请求 type ChannelCreateReq struct { g.Meta `path:"/channels" method:"post" mime:"json" tags:"管理后台-渠道" summary:"创建渠道"` diff --git a/internal/controller/admin/admin_v1_channel.go b/internal/controller/admin/admin_v1_channel.go index b6539e8..c60afcc 100644 --- a/internal/controller/admin/admin_v1_channel.go +++ b/internal/controller/admin/admin_v1_channel.go @@ -52,3 +52,6 @@ func (c *ControllerV1) ChannelClone(ctx context.Context, req *v1.ChannelCloneReq func (c *ControllerV1) ChannelResetHealth(ctx context.Context, req *v1.ChannelResetHealthReq) (res *v1.ChannelResetHealthRes, err error) { return service.Admin().ResetChannelHealth(ctx, req) } +func (c *ControllerV1) ChannelOptions(ctx context.Context, req *v1.ChannelOptionsReq) (res *v1.ChannelOptionsRes, err error) { + return service.Admin().ListChannelOptions(ctx, req) +} diff --git a/internal/logic/admin/channel.go b/internal/logic/admin/channel.go index 7366efd..6d97b4b 100644 --- a/internal/logic/admin/channel.go +++ b/internal/logic/admin/channel.go @@ -55,6 +55,58 @@ func channelRuntimeStates() map[int64]dispatchadapter.ChannelRuntimeState { return cat.ChannelRuntimeStates() } +// channelOptionsCache 渠道下拉选项缓存(全量渠道,单个条目)。 +// 日志 / 统计页的渠道筛选每次打开都要拉一次全量渠道,而渠道量级为几十且极少变动, +// 反复对 chn_channels 全表扫描纯属浪费;整表结果缓存为一个条目,写侧主动失效。 +var channelOptionsCache = common.NewCache("channel_options", 300*time.Second) + +// channelOptionsCacheKey 渠道下拉是全量结果,只有一个缓存条目。 +const channelOptionsCacheKey = "all" + +// InvalidateChannelOptionsCache 失效渠道下拉选项缓存(渠道增 / 删 / 改 / 克隆后调用)。 +func InvalidateChannelOptionsCache(ctx context.Context) { + channelOptionsCache.Delete(ctx, channelOptionsCacheKey) +} + +// ListChannelOptions 渠道下拉选项(不分页,返回全部渠道)。 +// 刻意不做状态过滤:日志 / 统计页的渠道筛选项要覆盖历史数据里出现过的全部渠道, +// 已停用渠道同样需要能作为筛选条件;status 原样返回,由前端决定是否标记。 +// 注意与 /admin/channels 的区别:后者是渠道管理列表(分页 + 健康度联表 + 运行态), +// 下拉场景不要用它 —— 那是本接口存在的意义。 +func (s *sAdmin) ListChannelOptions(ctx context.Context, _ *v1.ChannelOptionsReq) (*v1.ChannelOptionsRes, error) { + list := make([]v1.ChannelOptionItem, 0) + if channelOptionsCache.GetJSON(ctx, channelOptionsCacheKey, &list) { + return &v1.ChannelOptionsRes{List: list}, nil + } + + var rows []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type int `json:"type"` + Status string `json:"status"` + } + err := dao.ChnChannels.Ctx(ctx). + Fields("id, name, type, status"). + OrderAsc("id"). + Scan(&rows) + if err = common.IgnoreScanNoRows(err); err != nil { + return nil, err + } + + list = make([]v1.ChannelOptionItem, 0, len(rows)) + for _, r := range rows { + list = append(list, v1.ChannelOptionItem{ + ID: r.ID, + Name: r.Name, + Type: r.Type, + Status: r.Status, + }) + } + + channelOptionsCache.Set(ctx, channelOptionsCacheKey, list) + return &v1.ChannelOptionsRes{List: list}, nil +} + // ListChannels 获取渠道列表 func (s *sAdmin) ListChannels(ctx context.Context, req *v1.ChannelListReq) (*v1.ChannelListRes, error) { query := dao.ChnChannels.Ctx(ctx). @@ -277,6 +329,8 @@ func (s *sAdmin) CloneChannel(ctx context.Context, req *v1.ChannelCloneReq) (*v1 g.Log().Warningf(ctx, "init health score for channel %d failed: %v", newID, err) } + InvalidateChannelOptionsCache(ctx) + return &v1.ChannelCloneRes{ID: newID}, nil } @@ -350,6 +404,8 @@ func (s *sAdmin) CreateChannel(ctx context.Context, req *v1.ChannelCreateReq) (* g.Log().Warningf(ctx, "init health score for channel %d failed: %v", id, err) } + InvalidateChannelOptionsCache(ctx) + return &v1.ChannelCreateRes{ID: id}, nil } @@ -490,6 +546,7 @@ func (s *sAdmin) UpdateChannel(ctx context.Context, req *v1.ChannelUpdateReq) (* } } dispatchadapter.InvalidateChannel(ctx, req.ID) + InvalidateChannelOptionsCache(ctx) if req.Status == "active" { // 手动启用/恢复:复位熔断并开启爬坡窗口,恢复初期小流量验证(rampFactor) dispatchadapter.MarkChannelRecovered(ctx, req.ID) @@ -519,6 +576,7 @@ func (s *sAdmin) DeleteChannel(ctx context.Context, req *v1.ChannelDeleteReq) (* return nil, err } dispatchadapter.InvalidateChannel(ctx, req.ID) + InvalidateChannelOptionsCache(ctx) return nil, nil } diff --git a/internal/logic/admin/channel_oauth.go b/internal/logic/admin/channel_oauth.go index 78bbcc7..d92e341 100644 --- a/internal/logic/admin/channel_oauth.go +++ b/internal/logic/admin/channel_oauth.go @@ -314,6 +314,9 @@ func createOAuthChannelAndKey(ctx context.Context, oauthData *oauth.OAuthKeyData return 0, 0, err } + // OAuth 建渠道同样要让渠道下拉缓存失效(新建渠道不出现/不失效会滞后一个 TTL) + InvalidateChannelOptionsCache(ctx) + g.Log().Infof(ctx, "[OAuth] 创建 OAuth 渠道: platform=%s, channel_id=%d, key_id=%d, email=%s", platform, channelID, keyID, oauthData.EmailAddress) diff --git a/internal/logic/admin/model.go b/internal/logic/admin/model.go index 21d0819..e0ea182 100644 --- a/internal/logic/admin/model.go +++ b/internal/logic/admin/model.go @@ -6,6 +6,7 @@ import ( "fmt" "math" "strings" + "time" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gcode" @@ -24,40 +25,67 @@ import ( "github.com/qianfree/team-api/internal/utility/export" ) +// modelOptionsCache 模型下拉选项缓存(全量模型,单个条目)。 +// 下拉接口是若干管理页每次打开都要调的,而模型表极少变动; +// 整表结果缓存为一个条目、按 status/category 在内存里过滤,写侧主动失效。 +var modelOptionsCache = common.NewCache("model_options", 300*time.Second) + +// modelOptionsCacheKey 模型下拉是全量结果,只有一个缓存条目。 +const modelOptionsCacheKey = "all" + +// InvalidateModelOptionsCache 失效模型下拉选项缓存(模型增 / 删 / 改后调用)。 +// 注:task 包的模型下线定时任务改 status 时不经此处,下拉最多滞后一个 TTL(300s); +// 对「按状态筛模型」的下拉而言可接受,故不为此在 task 包反向依赖 admin。 +func InvalidateModelOptionsCache(ctx context.Context) { + modelOptionsCache.Delete(ctx, modelOptionsCacheKey) +} + +// modelOptionRow 下拉缓存行:比出参多一个 status,供内存过滤。 +// 缓存存全量(不过滤),避免 status / category 的每个组合各占一个缓存条目。 +type modelOptionRow struct { + ID int64 `json:"id"` + ModelId string `json:"model_id"` + ModelName string `json:"model_name"` + Category string `json:"category"` + Status string `json:"status"` +} + // ListModelOptions 获取模型选项列表(不分页,用于下拉选择) func (s *sAdmin) ListModelOptions(ctx context.Context, req *v1.ModelOptionsReq) (*v1.ModelOptionsRes, error) { - query := dao.MdlModels.Ctx(ctx) - - if req.Status != "" { - query = query.Where("status", req.Status) - } else { - query = query.Where("status", "active") - } - if req.Category != "" { - query = query.Where("category", req.Category) + rows := make([]modelOptionRow, 0) + if !modelOptionsCache.GetJSON(ctx, modelOptionsCacheKey, &rows) { + err := dao.MdlModels.Ctx(ctx). + Fields("id, model_id, model_name, category, status"). + OrderAsc("category").OrderAsc("model_id"). + Scan(&rows) + if err = common.IgnoreScanNoRows(err); err != nil { + return nil, err + } + if rows == nil { + rows = make([]modelOptionRow, 0) + } + modelOptionsCache.Set(ctx, modelOptionsCacheKey, rows) } - var models []struct { - ID int64 `json:"id"` - ModelId string `json:"model_id"` - ModelName string `json:"model_name"` - Category string `json:"category"` + // 沿用原接口语义:不传 status 即只要可用模型 + status := req.Status + if status == "" { + status = "active" } - err := query.Fields("id, model_id, model_name, category"). - OrderAsc("category").OrderAsc("model_id"). - Scan(&models) - if err = common.IgnoreScanNoRows(err); err != nil { - return nil, err - } - - list := make([]v1.ModelOptionItem, 0, len(models)) - for _, m := range models { + list := make([]v1.ModelOptionItem, 0, len(rows)) + for _, r := range rows { + if r.Status != status { + continue + } + if req.Category != "" && r.Category != req.Category { + continue + } list = append(list, v1.ModelOptionItem{ - ID: m.ID, - ModelId: m.ModelId, - ModelName: m.ModelName, - Category: m.Category, + ID: r.ID, + ModelId: r.ModelId, + ModelName: r.ModelName, + Category: r.Category, }) } @@ -291,6 +319,8 @@ func (s *sAdmin) CreateModel(ctx context.Context, req *v1.ModelCreateReq) (*v1.M return nil, err } + InvalidateModelOptionsCache(ctx) + return &v1.ModelCreateRes{ID: id}, nil } @@ -397,6 +427,10 @@ func (s *sAdmin) UpdateModel(ctx context.Context, req *v1.ModelUpdateReq) (*v1.M return nil, err } + // 下拉缓存按全量存、内存过滤 status/category,故改名 / 改分类同样要失效, + // 不能只在 statusChanged 时清。 + InvalidateModelOptionsCache(ctx) + if statusChanged { // 状态变更时清除模型缓存和租户分组缓存 relay.NewDataProvider().InvalidateModelCache(oldModel.ModelId) @@ -473,6 +507,7 @@ func (s *sAdmin) DeleteModel(ctx context.Context, req *v1.ModelDeleteReq) (*v1.M if model.ModelId != "" { billing.ClearModelPriceCache(ctx, model.ModelId) } + InvalidateModelOptionsCache(ctx) return nil, nil } diff --git a/internal/logic/admin/model_import_export.go b/internal/logic/admin/model_import_export.go index 452bcdb..3c10d03 100644 --- a/internal/logic/admin/model_import_export.go +++ b/internal/logic/admin/model_import_export.go @@ -401,6 +401,12 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1. } return nil }) + if err != nil { + return res, err + } - return res, err + // 导入含新建 / 更新模型,逐条失效太碎,事务提交后整体失效一次下拉缓存 + InvalidateModelOptionsCache(ctx) + + return res, nil } diff --git a/internal/logic/common/audit_enrich.go b/internal/logic/common/audit_enrich.go index 2717fd3..5817293 100644 --- a/internal/logic/common/audit_enrich.go +++ b/internal/logic/common/audit_enrich.go @@ -2,14 +2,153 @@ package common import ( "context" - "fmt" + "strconv" + "strings" + "time" "github.com/gogf/gf/v2/frame/g" ) +// 关联名称字典缓存:审计 / 日志列表回填 tenant_name / username / api_key_name / +// project_name 用。 +// +// 这四类都是「改一次、读十万次」的字典数据,而列表页每翻一页都要回填一次, +// 命中缓存后名称回填不再产生任何 DB 查询(原先每页固定 3 次 + 每租户 1 次查询)。 +// +// TTL 300s 与项目既定的「用户信息 300s」缓存策略一致:改名最多滞后一个 TTL。 +// 列表展示的是历史日志,短暂滞后可接受,因此不做写侧的主动失效。 +var nameDictCache = NewCache("namedict", 300*time.Second) + +// 字典种类前缀,与各自 key 拼装函数一起构成缓存 key。 +const ( + nameDictTenant = "tenant" + nameDictUser = "user" + nameDictApiKey = "apikey" + nameDictProject = "project" +) + +// resolveNameDict 批量解析「字典标识 → 名称」:先按标识查缓存(一次 MGET 批量取回), +// 仅对未命中的标识调用 load 查库,查回的名称回填缓存。 +// +// keyOf 把标识编码成缓存 key;load 只接收未命中的标识,且返回的 map 必须只以入参标识为键 +// (不能自行规范化),否则回填的 key 与调用方后续的查询 key 对不上。 +// +// 库里查不到的标识不回填缓存 —— 否则「刚创建、事务尚未提交」的读穿会被固化成 +// 一个长期空值,之后建好也读到空。 +// load 出错时降级为「只返回缓存命中部分」,调用方拿到零值名称,不阻断列表查询。 +// ids 需调用方先去重。 +func resolveNameDict[K comparable]( + ctx context.Context, + label string, + ids []K, + keyOf func(K) string, + load func(ctx context.Context, missing []K) (map[K]string, error), +) map[K]string { + if len(ids) == 0 { + return nil + } + + cacheKeys := make([]string, len(ids)) + for i, id := range ids { + cacheKeys[i] = keyOf(id) + } + + cached := nameDictCache.GetMany(ctx, cacheKeys) + result := make(map[K]string, len(ids)) + missing := make([]K, 0, len(ids)) + for i, id := range ids { + if name, ok := cached[cacheKeys[i]].(string); ok && name != "" { + result[id] = name + continue + } + missing = append(missing, id) + } + if len(missing) == 0 { + return result + } + + loaded, err := load(ctx, missing) + if err != nil { + g.Log().Warningf(ctx, "[NameDict] 查询 %s 名称失败: %v", label, err) + return result + } + if len(loaded) == 0 { + return result + } + + backfill := make(map[string]any, len(loaded)) + for id, name := range loaded { + result[id] = name + backfill[keyOf(id)] = name + } + nameDictCache.SetMany(ctx, backfill) + return result +} + +// idNameDictKey int64 主键类的字典缓存 key,形如 "tenant:12"。 +func idNameDictKey(kind string, id int64) string { + return kind + ":" + strconv.FormatInt(id, 10) +} + +// resolveInt64NameDict 租户 / API Key / 项目三类同构字典的解析入口(主键均为 int64)。 +func resolveInt64NameDict( + ctx context.Context, + kind string, + ids []int64, + load func(ctx context.Context, missing []int64) (map[int64]string, error), +) map[int64]string { + return resolveNameDict(ctx, kind, ids, func(id int64) string { + return idNameDictKey(kind, id) + }, load) +} + +// nameDictUserKey 用户字典缓存 key,形如 "user:3:45"。 +// 租户维度必须编入 key:只按 user_id 缓存的话,A 租户的用户名会在 B 租户的记录上命中, +// 等于绕过了「按 ID 取对象用双键校验」的隔离约定(租户控制台也会回填这些名称)。 +func nameDictUserKey(tenantID, userID int64) string { + return nameDictUser + ":" + pairKey(tenantID, userID) +} + +// pairKey 拼装 "tenantID:userID"。 +func pairKey(tenantID, userID int64) string { + return strconv.FormatInt(tenantID, 10) + ":" + strconv.FormatInt(userID, 10) +} + +// uniqueInt64s 去重并保持首次出现的顺序(丢弃非正值)。 +func uniqueInt64s(ids []int64) []int64 { + seen := make(map[int64]bool, len(ids)) + result := make([]int64, 0, len(ids)) + for _, id := range ids { + if id <= 0 || seen[id] { + continue + } + seen[id] = true + result = append(result, id) + } + return result +} + +// parseTenantUserKey 拆解 "tenantID:userID" 复合 key。 +func parseTenantUserKey(key string) (tenantID, userID int64, ok bool) { + head, tail, found := strings.Cut(key, ":") + if !found { + return 0, 0, false + } + tenantID, err := strconv.ParseInt(head, 10, 64) + if err != nil || tenantID <= 0 { + return 0, 0, false + } + userID, err = strconv.ParseInt(tail, 10, 64) + if err != nil || userID <= 0 { + return 0, 0, false + } + return tenantID, userID, true +} + // EnrichAuditRecords 为审计记录批量补充关联信息(用户名、租户名、API Key 名、项目名)。 // records 中的每条记录应包含 tenant_id、user_id、api_key_id、project_id 字段, // 函数会从主库批量查询关联信息并回填 tenant_name、username、api_key_name、project_name 字段。 +// 四类名称各自最多一次查库(且仅在缓存未命中时),与记录条数无关。 func EnrichAuditRecords(ctx context.Context, records []map[string]any) { if len(records) == 0 { return @@ -45,158 +184,179 @@ func EnrichAuditRecords(ctx context.Context, records []map[string]any) { } // BatchQueryUserNames 批量查询用户名。 -// key 格式为 "tenantID:userID",返回 map[key]username。 +// 入参 key 格式为 "tenantID:userID";返回 map[key]username。 func BatchQueryUserNames(ctx context.Context, userKeys []string) map[string]string { - if len(userKeys) == 0 { - return nil - } - result := make(map[string]string, len(userKeys)) - // 去重 + // 先滤掉非法键:它们既不该进缓存也不该查库 + keys := make([]string, 0, len(userKeys)) seen := make(map[string]bool, len(userKeys)) - unique := make([]string, 0, len(userKeys)) - for _, k := range userKeys { - if !seen[k] { - seen[k] = true - unique = append(unique, k) - } - } - - // 拆分 tenantID 和 userID,按 tenantID 分组批量查询 - tenantUsers := make(map[int64][]int64) // tenantID -> []userID - userKeyToID := make(map[string]int64) // "tenantID:userID" -> userID - for _, key := range unique { - var tenantID, userID int64 - fmt.Sscanf(key, "%d:%d", &tenantID, &userID) - if tenantID > 0 && userID > 0 { - tenantUsers[tenantID] = append(tenantUsers[tenantID], userID) - userKeyToID[key] = userID - } - } - - // 按 tenantID 分组查询 - for tenantID, userIDs := range tenantUsers { - type row struct { - Id int64 `json:"id"` - Username string `json:"username"` - } - var rows []row - err := g.DB().Ctx(ctx).Model("tnt_users"). - Where("tenant_id", tenantID). - WhereIn("id", userIDs). - Fields("id, username"). - Scan(&rows) - if err != nil { + for _, key := range userKeys { + if _, _, ok := parseTenantUserKey(key); !ok { continue } - for _, r := range rows { - mapKey := fmt.Sprintf("%d:%d", tenantID, r.Id) - result[mapKey] = r.Username + if seen[key] { + continue } + seen[key] = true + keys = append(keys, key) + } + if len(keys) == 0 { + return nil } - return result + return resolveNameDict(ctx, nameDictUser, keys, + func(key string) string { + // keys 已过滤,必定可解析(忽略 ok) + tenantID, userID, _ := parseTenantUserKey(key) + return nameDictUserKey(tenantID, userID) + }, + loadUserNames) +} + +// loadUserNames 按 (tenant_id, user_id) 精确配对批量查询用户名。 +// 先用 tenant_id IN (...) AND id IN (...) 缩小范围,再在应用层按配对过滤 —— +// 不用「只按 id 查」,否则 user_id 与 tenant_id 不匹配的脏记录会把其他租户的用户名带出来, +// 与「按 ID 取对象用双键校验」的隔离约定相悖。 +func loadUserNames(ctx context.Context, keys []string) (map[string]string, error) { + type pair struct{ tenantID, userID int64 } + + pairs := make([]pair, 0, len(keys)) + tenantIDs := make([]int64, 0, len(keys)) + userIDs := make([]int64, 0, len(keys)) + for _, key := range keys { + tenantID, userID, ok := parseTenantUserKey(key) + if !ok { + continue + } + pairs = append(pairs, pair{tenantID, userID}) + tenantIDs = append(tenantIDs, tenantID) + userIDs = append(userIDs, userID) + } + if len(pairs) == 0 { + return nil, nil + } + + type row struct { + Id int64 `json:"id"` + TenantId int64 `json:"tenant_id"` + Username string `json:"username"` + } + var rows []row + err := g.DB().Ctx(ctx).Model("tnt_users"). + WhereIn("tenant_id", uniqueInt64s(tenantIDs)). + WhereIn("id", uniqueInt64s(userIDs)). + Fields("id, tenant_id, username"). + Scan(&rows) + if err != nil { + return nil, err + } + + nameByPair := make(map[string]string, len(rows)) + for _, r := range rows { + nameByPair[pairKey(r.TenantId, r.Id)] = r.Username + } + + // 以入参 key 原样为返回键(回填缓存时 keyOf 也据此重算),不做规范化改写 + result := make(map[string]string, len(keys)) + for _, key := range keys { + tenantID, userID, ok := parseTenantUserKey(key) + if !ok { + continue + } + if name, found := nameByPair[pairKey(tenantID, userID)]; found { + result[key] = name + } + } + return result, nil } // BatchQueryTenantNames 批量查询租户名。 func BatchQueryTenantNames(ctx context.Context, tenantIDs []int64) map[int64]string { - if len(tenantIDs) == 0 { + ids := uniqueInt64s(tenantIDs) + if len(ids) == 0 { return nil } - result := make(map[int64]string, len(tenantIDs)) - // 去重 - seen := make(map[int64]bool, len(tenantIDs)) - unique := make([]int64, 0, len(tenantIDs)) - for _, id := range tenantIDs { - if !seen[id] { - seen[id] = true - unique = append(unique, id) - } - } + return resolveInt64NameDict(ctx, nameDictTenant, ids, loadTenantNames) +} +// loadTenantNames 从主库按主键批量查询租户名。 +func loadTenantNames(ctx context.Context, tenantIDs []int64) (map[int64]string, error) { type row struct { Id int64 `json:"id"` Name string `json:"name"` } var rows []row err := g.DB().Ctx(ctx).Model("tnt_tenants"). - WhereIn("id", unique). + WhereIn("id", tenantIDs). Fields("id, name"). Scan(&rows) if err != nil { - return result + return nil, err } + result := make(map[int64]string, len(rows)) for _, r := range rows { result[r.Id] = r.Name } - return result + return result, nil } // BatchQueryApiKeyNames 批量查询 API Key 名。 func BatchQueryApiKeyNames(ctx context.Context, apiKeyIDs []int64) map[int64]string { - if len(apiKeyIDs) == 0 { + ids := uniqueInt64s(apiKeyIDs) + if len(ids) == 0 { return nil } - result := make(map[int64]string, len(apiKeyIDs)) - // 去重 - seen := make(map[int64]bool, len(apiKeyIDs)) - unique := make([]int64, 0, len(apiKeyIDs)) - for _, id := range apiKeyIDs { - if !seen[id] { - seen[id] = true - unique = append(unique, id) - } - } + return resolveInt64NameDict(ctx, nameDictApiKey, ids, loadApiKeyNames) +} +// loadApiKeyNames 从主库按主键批量查询 API Key 名。 +func loadApiKeyNames(ctx context.Context, apiKeyIDs []int64) (map[int64]string, error) { type row struct { Id int64 `json:"id"` Name string `json:"name"` } var rows []row err := g.DB().Ctx(ctx).Model("api_keys"). - WhereIn("id", unique). + WhereIn("id", apiKeyIDs). Fields("id, name"). Scan(&rows) if err != nil { - return result + return nil, err } + result := make(map[int64]string, len(rows)) for _, r := range rows { result[r.Id] = r.Name } - return result + return result, nil } // BatchQueryProjectNames 批量查询项目名。 func BatchQueryProjectNames(ctx context.Context, projectIDs []int64) map[int64]string { - if len(projectIDs) == 0 { + ids := uniqueInt64s(projectIDs) + if len(ids) == 0 { return nil } - result := make(map[int64]string, len(projectIDs)) - // 去重 - seen := make(map[int64]bool, len(projectIDs)) - unique := make([]int64, 0, len(projectIDs)) - for _, id := range projectIDs { - if !seen[id] { - seen[id] = true - unique = append(unique, id) - } - } + return resolveInt64NameDict(ctx, nameDictProject, ids, loadProjectNames) +} +// loadProjectNames 从主库按主键批量查询项目名。 +func loadProjectNames(ctx context.Context, projectIDs []int64) (map[int64]string, error) { type row struct { Id int64 `json:"id"` Name string `json:"name"` } var rows []row err := g.DB().Ctx(ctx).Model("tnt_projects"). - WhereIn("id", unique). + WhereIn("id", projectIDs). Fields("id, name"). Scan(&rows) if err != nil { - return result + return nil, err } + result := make(map[int64]string, len(rows)) for _, r := range rows { result[r.Id] = r.Name } - return result + return result, nil } // collectInt64Fields 从记录集合中收集指定字段的所有非零 int64 值(去重)。 @@ -250,5 +410,5 @@ func getUserKey(record map[string]any) (string, bool) { if !ok1 || !ok2 || tenantID == 0 || userID == 0 { return "", false } - return fmt.Sprintf("%d:%d", tenantID, userID), true + return pairKey(tenantID, userID), true } diff --git a/internal/logic/common/audit_enrich_test.go b/internal/logic/common/audit_enrich_test.go new file mode 100644 index 0000000..5d0e781 --- /dev/null +++ b/internal/logic/common/audit_enrich_test.go @@ -0,0 +1,108 @@ +package common + +import ( + "reflect" + "testing" +) + +// 说明:resolveNameDict / BatchQuery* 的缓存路径依赖 Redis(g.Redis() 在未配置时 panic, +// 单测环境无 Redis 配置),故此处只覆盖不触达缓存的纯逻辑: +// key 拼装、复合 key 解析、ID 去重。缓存命中/回填行为由集成环境验证。 + +func TestIdNameDictKey(t *testing.T) { + cases := []struct { + kind string + id int64 + want string + }{ + {nameDictTenant, 12, "tenant:12"}, + {nameDictApiKey, 7, "apikey:7"}, + {nameDictProject, 0, "project:0"}, + {nameDictTenant, -3, "tenant:-3"}, + } + for _, c := range cases { + if got := idNameDictKey(c.kind, c.id); got != c.want { + t.Errorf("idNameDictKey(%q, %d) = %q, want %q", c.kind, c.id, got, c.want) + } + } +} + +// TestNameDictUserKeyIsTenantScoped 用户字典 key 必须带上租户维度: +// 只按 user_id 缓存会让 A 租户的用户名在 B 租户的记录上命中(跨租户名称泄漏)。 +func TestNameDictUserKeyIsTenantScoped(t *testing.T) { + if got := nameDictUserKey(3, 45); got != "user:3:45" { + t.Fatalf("nameDictUserKey(3, 45) = %q, want %q", got, "user:3:45") + } + sameUserDifferentTenant := nameDictUserKey(5, 45) + if nameDictUserKey(3, 45) == sameUserDifferentTenant { + t.Fatalf("同一 user_id 在不同租户下必须得到不同缓存 key,均得到 %q", sameUserDifferentTenant) + } +} + +// TestPairKeyMatchesUserKeySuffix loadUserNames 用 pairKey 建索引、nameDictUserKey 拼缓存 key, +// 两者必须共用同一套 "tenantID:userID" 编码,否则回填的 key 与查询 key 对不上。 +func TestPairKeyMatchesUserKeySuffix(t *testing.T) { + if got, want := nameDictUserKey(3, 45), nameDictUser+":"+pairKey(3, 45); got != want { + t.Fatalf("nameDictUserKey(3, 45) = %q, want %q", got, want) + } +} + +// TestParseTenantUserKey 合法键正常拆解;非法键一律拒绝(BatchQueryUserNames 据此静默丢弃, +// 不能把错键当成 tenant 0 / user 0 去查库)。 +func TestParseTenantUserKey(t *testing.T) { + valid := []struct { + key string + tenantID int64 + userID int64 + }{ + {"3:45", 3, 45}, + {"100:1", 100, 1}, + } + for _, c := range valid { + tenantID, userID, ok := parseTenantUserKey(c.key) + if !ok || tenantID != c.tenantID || userID != c.userID { + t.Errorf("parseTenantUserKey(%q) = (%d, %d, %v), want (%d, %d, true)", + c.key, tenantID, userID, ok, c.tenantID, c.userID) + } + } + + invalid := []string{ + "", // 空串 + "45", // 无分隔符 + ":45", // 缺 tenantID + "3:", // 缺 userID + "0:45", // tenantID 非正 + "3:0", // userID 非正 + "-3:45", // tenantID 非正 + "3:-45", // userID 非正 + "a:45", // 非数字 + "3:b", // 非数字 + "3:45:6", // 多余段(tail 非数字) + " 3:45", // 前导空格 + "2147483648:99999999999999999999", // 溢出 + } + for _, key := range invalid { + if tenantID, userID, ok := parseTenantUserKey(key); ok { + t.Errorf("parseTenantUserKey(%q) 应被拒绝,却返回 (%d, %d, true)", key, tenantID, userID) + } + } +} + +// TestUniqueInt64s 去重保持首次出现顺序,并丢弃非正值(0 / 负数不是合法主键)。 +func TestUniqueInt64s(t *testing.T) { + cases := []struct { + name string + in []int64 + want []int64 + }{ + {"去重保序", []int64{5, 3, 5, 1, 3}, []int64{5, 3, 1}}, + {"丢弃非正值", []int64{0, -1, 2, 0}, []int64{2}}, + {"全非正", []int64{0, -1}, []int64{}}, + {"空入参", nil, []int64{}}, + } + for _, c := range cases { + if got := uniqueInt64s(c.in); !reflect.DeepEqual(got, c.want) { + t.Errorf("%s: uniqueInt64s(%v) = %v, want %v", c.name, c.in, got, c.want) + } + } +} diff --git a/internal/logic/common/cache.go b/internal/logic/common/cache.go index 699e0ff..6130dc6 100644 --- a/internal/logic/common/cache.go +++ b/internal/logic/common/cache.go @@ -83,6 +83,95 @@ func (c *Cache) Set(ctx context.Context, key string, value any, ttl ...time.Dura } } +// SetMany 批量写入 L1(内存)+ L2(Redis),语义与逐 key 调用 Set 一致。 +// L1 逐 key 写本进程内存;L2 逐 key SETEX —— Redis 没有「批量且带过期」的原生命令 +// (MSET 不支持 TTL),故写侧仍是 N 次往返。该方法只服务于「缓存未命中后回填」, +// 调用频次受 TTL 约束,不在热路径上;热路径的批量读走 GetMany(单次 MGET)。 +func (c *Cache) SetMany(ctx context.Context, values map[string]any) { + if len(values) == 0 { + return + } + expire := jitterTTL(c.ttl) + ttlSeconds := int64(math.Ceil(expire.Seconds())) + if ttlSeconds <= 0 { + // 兜底:ttl ≤ 0 时使用 24h 兜底 TTL,避免产生永久 key(与 Set 一致) + g.Log().Warningf(ctx, "[Cache] TTL <= 0 for prefix=%s, using fallback 24h TTL", c.prefix) + ttlSeconds = 86400 + } + + // L1 先整体落地:即使 L2 写入失败,本进程后续读取也能立即命中 + for key, value := range values { + gcache.Set(ctx, c.fullKey(key), value, expire) + } + + for key, value := range values { + fullKey := c.fullKey(key) + jsonBytes, err := json.Marshal(value) + if err != nil { + g.Log().Warningf(ctx, "[Cache] JSON marshal failed key=%s: %v", fullKey, err) + continue + } + if _, err = g.Redis().Do(ctx, "SETEX", fullKey, ttlSeconds, string(jsonBytes)); err != nil { + g.Log().Warningf(ctx, "[Cache] SETEX failed key=%s: %v", fullKey, err) + } + } +} + +// GetMany 批量读取:先逐 key 查 L1(gcache,命中即零网络开销), +// 未命中的 key 用一次 MGET 走 L2(Redis),命中 L2 的值回填 L1。 +// 返回「命中的原始 key → 值」,未命中的 key 不出现在结果中。 +// key 为不含前缀的原始 key(与 Get/Set 一致)。 +// +// 为什么必须批量:审计/日志列表每页要回填几十个关联名称,逐 key Get 会让冷路径 +// 产生 N 次 Redis 往返,反而比直接查库更慢;MGET 把冷路径压成 1 次往返。 +// Redis 不可用或 MGET 失败时只返回 L1 命中部分,未命中部分由调用方回落查库。 +func (c *Cache) GetMany(ctx context.Context, keys []string) map[string]any { + result := make(map[string]any, len(keys)) + if len(keys) == 0 { + return result + } + + missKeys := make([]string, 0, len(keys)) + missFullKeys := make([]string, 0, len(keys)) + for _, key := range keys { + fullKey := c.fullKey(key) + if val, err := gcache.Get(ctx, fullKey); err == nil && val != nil { + result[key] = val.Interface() + continue + } + missKeys = append(missKeys, key) + missFullKeys = append(missFullKeys, fullKey) + } + if len(missFullKeys) == 0 { + return result + } + + // L2:一次 MGET 取回全部未命中 key(缺失 key 在返回 map 中为 nil 值) + l2Values, err := g.Redis().MGet(ctx, missFullKeys...) + if err != nil { + g.Log().Warningf(ctx, "[Cache] MGET failed prefix=%s: %v", c.prefix, err) + return result + } + for i, fullKey := range missFullKeys { + v, ok := l2Values[fullKey] + if !ok || v == nil || v.IsNil() { + continue + } + jsonStr := v.String() + if jsonStr == "" { + continue + } + // 与 Get 一致:优先按 JSON 反序列化还原原始类型,非 JSON 的历史值按纯字符串兜底 + var raw any + if unmarshalErr := json.Unmarshal([]byte(jsonStr), &raw); unmarshalErr != nil { + raw = jsonStr + } + gcache.Set(ctx, fullKey, raw, jitterTTL(c.ttl)) + result[missKeys[i]] = raw + } + return result +} + // Get retrieves a value: L1 → L2 → miss. // L1 returns native Go types. L2 deserializes JSON back to the original type. func (c *Cache) Get(ctx context.Context, key string) (any, bool) { diff --git a/internal/service/admin.go b/internal/service/admin.go index f73ae26..d7d6b29 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -106,7 +106,12 @@ type ( DeleteChangelog(ctx context.Context, req *v1.ChangelogDeleteReq) (*v1.ChangelogDeleteRes, error) // PublishChangelog 发布更新日志 PublishChangelog(ctx context.Context, req *v1.ChangelogPublishReq) (*v1.ChangelogPublishRes, error) - // ListChannels 获取渠道列表 + // ListChannelOptions 渠道下拉选项(不分页,返回全部渠道)。 + // 刻意不做状态过滤:日志 / 统计页的渠道筛选项要覆盖历史数据里出现过的全部渠道, + // 已停用渠道同样需要能作为筛选条件;status 原样返回,由前端决定是否标记。 + // 注意与 /admin/channels 的区别:后者是渠道管理列表(分页 + 健康度联表 + 运行态), + // 下拉场景不要用它 —— 那是本接口存在的意义。 + ListChannelOptions(ctx context.Context, _ *v1.ChannelOptionsReq) (*v1.ChannelOptionsRes, error) ListChannels(ctx context.Context, req *v1.ChannelListReq) (*v1.ChannelListRes, error) // CloneChannel 克隆渠道 CloneChannel(ctx context.Context, req *v1.ChannelCloneReq) (*v1.ChannelCloneRes, error) @@ -192,7 +197,11 @@ type ( // GetAllUsageLogs 获取所有租户的用量日志(管理后台) GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListReq) (*v1.AdminUsageLogListRes, error) // GetUsageLogDetail 获取单条用量日志详情(含 billing_snapshot / billing_summary / - // user_agent / error_message 等列表不返回的大字段) + // user_agent / error_message 等列表不返回的大字段)。 + // bil_usage_logs 按 created_at 月分区且 PK 为 (id, created_at):仅按 id 查询无法 + // 分区裁剪,计划为对各月分区的 Append 索引探测(每分区走本地 PK 前缀命中 id), + // 分区数随月份线性增长但单次探测为微秒级,与 upstream_request_id 反查(000023) + // 同一模式;若日后分区数显著增大,可让调用方携带 created_at 提示做裁剪。 GetUsageLogDetail(ctx context.Context, req *v1.AdminUsageLogDetailReq) (*v1.AdminUsageLogDetailRes, error) // GetUsageLogSummary 获取用量日志统计汇总(独立接口,与列表共用筛选口径) GetUsageLogSummary(ctx context.Context, req *v1.AdminUsageLogSummaryReq) (*v1.AdminUsageLogSummaryRes, error) diff --git a/internal/service/tenant.go b/internal/service/tenant.go index d84e248..f6b9e6c 100644 --- a/internal/service/tenant.go +++ b/internal/service/tenant.go @@ -72,6 +72,8 @@ type ( // (request_id / task_id / billing_summary / error_message / user_agent 等)。 // 平台侧敏感字段(upstream_model、account_cost、upstream_endpoint、billing_snapshot) // 与列表同样禁止返回。member 角色只能查看自己的记录。 + // 性能:bil_usage_logs 按 created_at 月分区,仅按 id 查询无法分区裁剪,为各分区 + // Append 索引探测(见管理后台 GetUsageLogDetail 注释),当前量级可接受。 UsageLogDetail(ctx context.Context, req *v1.TenantUsageLogDetailReq) (*v1.TenantUsageLogDetailRes, error) // UsageLogsSummary 用量日志统计汇总(与 UsageLogs 共用筛选口径:强制 tenant_id 隔离, // member 角色只能统计自己的日志;总费用与租户端列表费用列同口径:actual_cost 优先,0/NULL 回退 total_cost) diff --git a/web/admin/src/views/monitor/ModelPerformancePage.vue b/web/admin/src/views/monitor/ModelPerformancePage.vue index 13240d7..4109815 100644 --- a/web/admin/src/views/monitor/ModelPerformancePage.vue +++ b/web/admin/src/views/monitor/ModelPerformancePage.vue @@ -27,11 +27,12 @@ const modelOptions = ref<{ label: string; value: string }[]>([]) const expandedRows = ref>(new Set()) const expandLoading = ref>(new Set()) const channelData = ref>(new Map()) -// 渠道下拉数据:渠道量级为几十,一次性拉全量(后端分页限制最大 100) +// 渠道下拉数据:专用不分页接口 /admin/channels/options,返回全部渠道(含已停用)。 +// 不用 /admin/channels——那是渠道管理列表(分页 + 健康度联表 + 运行态 + 上限 100 条)。 async function fetchChannelOptions() { try { - const res: any = await request.get('/admin/channels', { params: { page: 1, page_size: 100 } }) - channelOptions.value = (res.data?.data?.list || res.data?.list || []).map((c: any) => ({ + const res: any = await request.get('/admin/channels/options') + channelOptions.value = (res.data?.data?.list || []).map((c: any) => ({ label: c.name ? `${c.name} (#${c.id})` : `#${c.id}`, value: c.id, })) diff --git a/web/admin/src/views/tenant/UsageLogsPage.vue b/web/admin/src/views/tenant/UsageLogsPage.vue index 1f846d1..27fde05 100644 --- a/web/admin/src/views/tenant/UsageLogsPage.vue +++ b/web/admin/src/views/tenant/UsageLogsPage.vue @@ -66,7 +66,6 @@ const requestTypeOptions = [ ] let tenantSearchTimer: ReturnType | null = null -let modelSearchTimer: ReturnType | null = null async function fetchTenantOptions(keyword = '') { try { @@ -88,11 +87,12 @@ function handleTenantSearch(value: string) { tenantSearchTimer = setTimeout(() => fetchTenantOptions(value), 300) } -async function fetchModelOptions(search = '') { +// 模型下拉数据:专用不分页接口 /admin/models/options,一次拉回全部 active 模型后本地过滤。 +// 不用 /admin/models 列表接口——那是模型管理页的重接口(分页 + 定价 JSONB 展开 + +// 渠道能力联表),仅取 model_id / model_name 做下拉纯属浪费。 +async function fetchModelOptions() { try { - const res: any = await request.get('/admin/models', { - params: { page: 1, page_size: 50, status: 'active', search } - }) + const res: any = await request.get('/admin/models/options') const list = res.data?.data?.list || [] modelOptions.value = list.map((m: any) => ({ label: m.model_name || m.model_id, @@ -103,18 +103,22 @@ async function fetchModelOptions(search = '') { } } -function handleModelSearch(value: string) { - if (modelSearchTimer) clearTimeout(modelSearchTimer) - modelSearchTimer = setTimeout(() => fetchModelOptions(value), 300) +// 模型下拉本地过滤:候选是全量模型,label 用的是 model_name、value 是 model_id, +// 只在 label 上匹配会让「显示名不含 model_id」的模型(如中文显示名)搜不到。 +// 这里对齐原先服务端 model_id LIKE ? OR model_name LIKE ? 的口径,name 与 id 任一命中即可。 +function filterModelOption(input: string, option: any): boolean { + const keyword = input.toLowerCase() + return String(option?.label ?? '').toLowerCase().includes(keyword) + || String(option?.value ?? '').toLowerCase().includes(keyword) } -// 渠道下拉数据:渠道量级为几十,一次性拉全量(后端分页限制最大 100),本地过滤即可 +// 渠道下拉数据:专用不分页接口 /admin/channels/options,返回全部渠道(含已停用, +// 历史日志要能按停用渠道筛选),一次拉全量后本地过滤。 +// 不用 /admin/channels——那是渠道管理列表(分页 + 健康度联表 + 运行态 + 上限 100 条)。 async function fetchChannelOptions() { try { - const res: any = await request.get('/admin/channels', { - params: { page: 1, page_size: 100 } - }) - const list = res.data?.data?.list || res.data?.list || [] + const res: any = await request.get('/admin/channels/options') + const list = res.data?.data?.list || [] channelOptions.value = list.map((c: any) => ({ label: c.name ? `${c.name} (#${c.id})` : `#${c.id}`, value: c.id, @@ -634,8 +638,9 @@ async function fetchSummary() { function handleFilter() { pagination.current = 1 - fetchData() - fetchSummary() // 筛选条件变化时重新加载统计数据 + // 表格数据加载完成后再刷新统计:summary 是对明细表的全量聚合, + // 与列表查询并发会争抽数据库资源、拖慢表格首屏 + fetchData().then(() => fetchSummary()) } function handleReset() { @@ -650,8 +655,8 @@ function handleReset() { filterUpstreamRequestId.value = undefined filterDateRange.value = defaultTodayRange() pagination.current = 1 - fetchData() - fetchSummary() // 重置后重新加载统计数据 + // 与 handleFilter 同口径:先加载表格数据,完成后再刷新统计 + fetchData().then(() => fetchSummary()) } // 刷新:清空所有筛选条件,仅按当天起始时间查询最新记录(截止留空 = 到现在) @@ -663,8 +668,9 @@ onMounted(() => { fetchTenantOptions() fetchModelOptions() fetchChannelOptions() - fetchData() - fetchSummary() // 初始加载时获取统计数据 + // 表格数据加载完成后再拉统计汇总,避免 summary 的全量聚合 + // 与列表首查并发争抽数据库资源(fetchData 内部已捕获异常,必 resolve) + fetchData().then(() => fetchSummary()) }) const { exporting, exportFile } = useExport({ @@ -728,9 +734,8 @@ const { exporting, exportFile } = useExport({ placeholder="模型" allow-search allow-clear - :filter-option="false" + :filter-option="filterModelOption" style="width: 220px" - @search="handleModelSearch" @change="handleFilter" @clear="handleFilter" /> diff --git a/web/admin/src/views/tenant/detail/ModelsTab.vue b/web/admin/src/views/tenant/detail/ModelsTab.vue index ecee30f..0b640fe 100644 --- a/web/admin/src/views/tenant/detail/ModelsTab.vue +++ b/web/admin/src/views/tenant/detail/ModelsTab.vue @@ -71,10 +71,12 @@ async function fetchTenantModels() { } } +// 候选模型走专用不分页接口 /admin/models/options:一次拉回全部 active 模型, +// 不受 /admin/models 的 page_size=100 上限约束(平台模型超 100 个时旧写法会漏模型) async function fetchAllModels() { try { - const res: any = await request.get('/admin/models', { params: { page: 1, page_size: 100, status: 'active' } }) - allModels.value = res.data?.data?.list || res.data?.list || [] + const res: any = await request.get('/admin/models/options') + allModels.value = res.data?.data?.list || [] } catch (err: any) { console.error('fetchAllModels failed:', err) } diff --git a/web/tenant/src/views/marketplace/marketplaceMeta.ts b/web/tenant/src/views/marketplace/marketplaceMeta.ts index b6c18c5..a33606c 100644 --- a/web/tenant/src/views/marketplace/marketplaceMeta.ts +++ b/web/tenant/src/views/marketplace/marketplaceMeta.ts @@ -28,6 +28,7 @@ export const categoryMetaList: CategoryMeta[] = [ { key: 'embedding', value: 'embedding', label: '向量嵌入', shortLabel: '向量', description: '语义检索与知识库构建', icon: 'cube', from: '#a78bfa', to: '#8b5cf6', glow: '139, 92, 246' }, { key: 'image', value: 'image', label: '图像生成', shortLabel: '图像', description: '文生图、图像理解与编辑', icon: 'eye', from: '#f472b6', to: '#ec4899', glow: '236, 72, 153' }, { key: 'audio', value: 'audio', label: '音频处理', shortLabel: '音频', description: '语音识别、合成与音乐生成', icon: 'bell', from: '#fbbf24', to: '#f59e0b', glow: '245, 158, 11' }, + { key: 'video', value: 'video', label: '视频生成', shortLabel: '视频', description: '文生视频与视频处理', icon: 'film', from: '#fb7185', to: '#f43f5e', glow: '244, 63, 94' }, { key: 'rerank', value: 'rerank', label: '重排序', shortLabel: '重排', description: '检索结果相关性排序', icon: 'trendingUp', from: '#22d3ee', to: '#06b6d4', glow: '6, 182, 212' }, ] diff --git a/web/tenant/src/views/service/ModelsPage.vue b/web/tenant/src/views/service/ModelsPage.vue index e2ed6e2..2d4e94e 100644 --- a/web/tenant/src/views/service/ModelsPage.vue +++ b/web/tenant/src/views/service/ModelsPage.vue @@ -149,6 +149,7 @@ const categories = [ { value: 'embedding', label: '嵌入' }, { value: 'image', label: '图像' }, { value: 'audio', label: '语音' }, + { value: 'video', label: '视频' }, { value: 'rerank', label: '重排' }, ] @@ -157,6 +158,7 @@ const categoryLabel: Record = { embedding: '嵌入', image: '图像', audio: '语音', + video: '视频', rerank: '重排', } @@ -165,6 +167,7 @@ const categoryBadgeStyle: Record = { embedding: 'background:rgba(139,92,246,0.1);color:#7c3aed', image: 'background:rgba(245,158,11,0.1);color:#d97706', audio: 'background:rgba(16,185,129,0.1);color:#059669', + video: 'background:rgba(244,63,94,0.1);color:#e11d48', rerank: 'background:rgba(107,114,128,0.1);color:#4b5563', } diff --git a/web/tenant/src/views/team/MemberDetailPage.vue b/web/tenant/src/views/team/MemberDetailPage.vue index 51bf773..e27cc7f 100644 --- a/web/tenant/src/views/team/MemberDetailPage.vue +++ b/web/tenant/src/views/team/MemberDetailPage.vue @@ -344,6 +344,7 @@ const categoryLabel: Record = { embedding: '嵌入', image: '图像', audio: '语音', + video: '视频', rerank: '重排', } @@ -352,6 +353,7 @@ const categoryBadgeClass: Record = { embedding: 'badge-purple', image: 'badge-warning', audio: 'badge-success', + video: 'badge-purple', rerank: 'badge-gray', }