feat(response):优化下拉选项
This commit is contained in:
1 parent
4297739e23
commit
e4f31eae5c
18 files changed
+649
-143
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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:"创建渠道"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -27,11 +27,12 @@ const modelOptions = ref<{ label: string; value: string }[]>([])
|
||||
const expandedRows = ref<Set<string>>(new Set())
|
||||
const expandLoading = ref<Set<string>>(new Set())
|
||||
const channelData = ref<Map<string, any[]>>(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,
|
||||
}))
|
||||
|
||||
@@ -66,7 +66,6 @@ const requestTypeOptions = [
|
||||
]
|
||||
|
||||
let tenantSearchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let modelSearchTimer: ReturnType<typeof setTimeout> | 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"
|
||||
/>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
embedding: '嵌入',
|
||||
image: '图像',
|
||||
audio: '语音',
|
||||
video: '视频',
|
||||
rerank: '重排',
|
||||
}
|
||||
|
||||
@@ -165,6 +167,7 @@ const categoryBadgeStyle: Record<string, string> = {
|
||||
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',
|
||||
}
|
||||
|
||||
|
||||
@@ -344,6 +344,7 @@ const categoryLabel: Record<string, string> = {
|
||||
embedding: '嵌入',
|
||||
image: '图像',
|
||||
audio: '语音',
|
||||
video: '视频',
|
||||
rerank: '重排',
|
||||
}
|
||||
|
||||
@@ -352,6 +353,7 @@ const categoryBadgeClass: Record<string, string> = {
|
||||
embedding: 'badge-purple',
|
||||
image: 'badge-warning',
|
||||
audio: 'badge-success',
|
||||
video: 'badge-purple',
|
||||
rerank: 'badge-gray',
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user