feat(response):优化下拉选项

This commit is contained in:
qian committed 2026-09-22 21:46:26 +08:00
1 parent 4297739e23
commit e4f31eae5c
18 files changed
+649 -143

No files matched your search

+1
View File
@@ -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)
+18
View File
@@ -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)
}
+58
View File
@@ -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
}
+3
View File
@@ -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)
+62 -27
View File
@@ -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
}
+7 -1
View File
@@ -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
}
+247 -87
View File
@@ -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
}
+108
View File
@@ -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)
}
}
}
+89
View File
@@ -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) {
+11 -2
View File
@@ -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)
+2
View File
@@ -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,
}))
+26 -21
View File
@@ -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',
}