feat(tenant):新增模型厂商和租户消费总额
No files matched your search
@@ -269,8 +269,9 @@ cmd(路由注册)
|
||||
|
||||
钱包资金状态采用 **Redis 唯一实时权威 + DB 滞后物化视图** 架构(详见 [`docs/钱包Redis权威化设计.md`](docs/钱包Redis权威化设计.md)):
|
||||
|
||||
- **Redis 权威**:`wallet:v2:{tenant_id}` hash(balance / frozen_balance / ver,整数 micro-USD)是余额的唯一实时真相。所有资金变动(预扣冻结、结算扣款、解冻、充值/退款/调账)一律通过 `internal/logic/billing/wallet.go` 的 Lua 脚本原子完成,**任何 Go 代码不得直接读改写 Redis 钱包 hash 或从 DB 反向重建它**。
|
||||
- **DB 物化**:`bil_wallets.balance / frozen_balance` 只是展示/报表/灾难恢复用的滞后副本,由后台物化器每 `billing_wallet_materialize_interval_ms`(默认 5000ms)从 Redis 全量覆盖。**禁止**把 DB 钱包余额当作实时判断依据(如余额门槛、防超扣闸门)。
|
||||
- **Redis 权威**:`wallet:v2:{tenant_id}` hash(balance / frozen_balance / total_consumed / ver,整数 micro-USD)是余额的唯一实时真相。所有资金变动(预扣冻结、结算扣款、解冻、充值/退款/调账)一律通过 `internal/logic/billing/wallet.go` 的 Lua 脚本原子完成,**任何 Go 代码不得直接读改写 Redis 钱包 hash 或从 DB 反向重建它**。
|
||||
- **累计消费(total_consumed)只随结算事件变动**:`settleClaimLua` 与扣款同一原子 `HINCRBY` 递增;结算记流水失败的补偿逆转走专用 `ReverseConsumeRedis`(余额与计数同一原子回退)。充值/兑换/调账/退款/预扣/解冻一律**不得**触碰该计数(`CreditWalletRedis`/`DebitWalletRedis` 不感知计数)。字段基线由三层补种保证(boot 种子 `SeedWalletTotalConsumed` / 灾备重建 `rebuildWalletFromDB` / 物化器自愈 `materializeOneWallet`),存量历史从 `bil_transactions(type=consume)` 回填(幂等可重放)。
|
||||
- **DB 物化**:`bil_wallets.balance / frozen_balance / total_consumed` 只是展示/报表/灾难恢复用的滞后副本,由后台物化器每 `billing_wallet_materialize_interval_ms`(默认 5000ms)从 Redis 全量覆盖。**禁止**把 DB 钱包余额当作实时判断依据(如余额门槛、防超扣闸门)。
|
||||
- **预扣记录纯 Redis**:`bil_prededuct_tracks` 表已废弃(预扣明细 = `prededuct:v2:{request_id}` hash + `prededuct_active:{tenant}` 集合,TTL 2h),孤儿清扫由 cron `prededuct_sweep` 每 2 分钟按「重算 frozen = Σ 幸存预扣」自愈,不再依赖 DB 表。
|
||||
- **可用余额** = balance - frozen_balance,预扣/扣款/退款门槛都在 Redis Lua 内原子判断。
|
||||
- **幂等闸门**:结算幂等靠 `bil_records.request_id` 唯一约束(DB,先行于任何资金变动);预扣/解冻幂等靠 Redis hash 认领即删。
|
||||
|
||||
@@ -8,6 +8,7 @@ type ModelListReq struct {
|
||||
Page int `json:"page" d:"1" v:"min:1" dc:"页码"`
|
||||
PageSize int `json:"page_size" d:"20" v:"min:1|max:100" dc:"每页数量"`
|
||||
Category string `json:"category" dc:"模型分类筛选:chat/embedding/image/audio/rerank/video"`
|
||||
Vendor string `json:"vendor" dc:"研发厂商筛选:openai/anthropic/google/alibaba/deepseek等,空=不过滤"`
|
||||
Status string `json:"status" dc:"状态筛选:active/deprecated/offline"`
|
||||
Search string `json:"search" dc:"搜索关键词(模型名或显示名)"`
|
||||
PricingStatus string `json:"pricing_status" dc:"定价状态筛选:priced/unpriced"`
|
||||
@@ -34,6 +35,7 @@ type ModelItem struct {
|
||||
ModelId string `json:"model_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
Category string `json:"category"`
|
||||
Vendor string `json:"vendor"` // 研发厂商(空=未分类)
|
||||
Status string `json:"status"`
|
||||
MaxContext int `json:"max_context_tokens"`
|
||||
MaxOutput int `json:"max_output_tokens"`
|
||||
@@ -61,6 +63,7 @@ type ModelCreateReq struct {
|
||||
ModelId string `json:"model_id" v:"required|length:1,100#请输入模型标识|模型标识长度1-100" dc:"模型唯一标识"`
|
||||
ModelName string `json:"model_name" dc:"模型显示名称"`
|
||||
Category string `json:"category" v:"required|in:chat,embedding,image,audio,rerank,video#请选择分类|分类必须是 chat/embedding/image/audio/rerank/video" dc:"模型分类"`
|
||||
Vendor string `json:"vendor" v:"in:openai,anthropic,google,xai,mistral,cohere,meta,alibaba,bytedance,deepseek,zhipu,moonshot,minimax,baidu,tencent,xunfei,kuaishou,midjourney,suno#厂商值不合法" dc:"研发厂商(空=未分类)"`
|
||||
MaxContext int `json:"max_context_tokens" dc:"最大上下文 token 数"`
|
||||
MaxOutput int `json:"max_output_tokens" dc:"最大输出 token 数"`
|
||||
Capabilities map[string]bool `json:"capabilities" dc:"模型能力特性"`
|
||||
@@ -75,10 +78,12 @@ type ModelCreateRes struct {
|
||||
|
||||
// ModelUpdateReq 更新模型请求
|
||||
type ModelUpdateReq struct {
|
||||
g.Meta `path:"/models/{id}" method:"put" mime:"json" tags:"管理后台-模型" summary:"更新模型"`
|
||||
ID int64 `json:"id" in:"path" v:"required" dc:"模型ID"`
|
||||
ModelName string `json:"model_name" dc:"模型显示名称"`
|
||||
Category string `json:"category" v:"in:chat,embedding,image,audio,rerank,video" dc:"模型分类"`
|
||||
g.Meta `path:"/models/{id}" method:"put" mime:"json" tags:"管理后台-模型" summary:"更新模型"`
|
||||
ID int64 `json:"id" in:"path" v:"required" dc:"模型ID"`
|
||||
ModelName string `json:"model_name" dc:"模型显示名称"`
|
||||
Category string `json:"category" v:"in:chat,embedding,image,audio,rerank,video" dc:"模型分类"`
|
||||
// Vendor 研发厂商;指针语义:nil=不更新,空串=清空回未分类,非空=设置(枚举校验在 logic 层做)
|
||||
Vendor *string `json:"vendor" dc:"研发厂商"`
|
||||
MaxContext int `json:"max_context_tokens" dc:"最大上下文 token 数"`
|
||||
MaxOutput int `json:"max_output_tokens" dc:"最大输出 token 数"`
|
||||
Capabilities map[string]bool `json:"capabilities" dc:"模型能力特性"`
|
||||
@@ -209,6 +214,7 @@ type ModelExportReq struct {
|
||||
g.Meta `path:"/models/export" method:"get" mime:"json" tags:"管理后台-模型" summary:"导出模型列表"`
|
||||
Format string `json:"format" in:"query" d:"csv" v:"in:csv,xlsx" dc:"导出格式:csv / xlsx"`
|
||||
Category string `json:"category" in:"query" dc:"模型分类筛选:chat/embedding/image/audio/rerank"`
|
||||
Vendor string `json:"vendor" in:"query" dc:"研发厂商筛选,空=不过滤"`
|
||||
Status string `json:"status" in:"query" dc:"状态筛选:active/deprecated/offline"`
|
||||
Search string `json:"search" in:"query" dc:"搜索关键词(模型名或显示名)"`
|
||||
}
|
||||
@@ -288,6 +294,7 @@ type ModelImportPreviewItem struct {
|
||||
ModelId string `json:"model_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
Category string `json:"category"`
|
||||
Vendor string `json:"vendor"` // 研发厂商(空=未分类)
|
||||
Status string `json:"status"`
|
||||
MaxContextTokens int `json:"max_context_tokens"`
|
||||
MaxOutputTokens int `json:"max_output_tokens"`
|
||||
@@ -312,6 +319,7 @@ type ModelImportItem struct {
|
||||
ModelId string `json:"model_id" v:"required" dc:"模型唯一标识"`
|
||||
ModelName string `json:"model_name" dc:"模型显示名称"`
|
||||
Category string `json:"category" v:"required|in:chat,embedding,image,audio,rerank,video#请选择分类|分类无效" dc:"模型分类"`
|
||||
Vendor string `json:"vendor" v:"in:openai,anthropic,google,xai,mistral,cohere,meta,alibaba,bytedance,deepseek,zhipu,moonshot,minimax,baidu,tencent,xunfei,kuaishou,midjourney,suno#厂商值不合法" dc:"研发厂商(空=未分类)"`
|
||||
Status string `json:"status" dc:"状态"`
|
||||
MaxContextTokens int `json:"max_context_tokens" dc:"最大上下文 token 数"`
|
||||
MaxOutputTokens int `json:"max_output_tokens" dc:"最大输出 token 数"`
|
||||
|
||||
@@ -25,6 +25,8 @@ type TenantListReq struct {
|
||||
PageSize int `json:"page_size" d:"20" dc:"每页数量"`
|
||||
Keyword string `json:"keyword" dc:"搜索关键词(名称/代码)"`
|
||||
Status string `json:"status" dc:"状态筛选:active/suspended/closed"`
|
||||
Tag string `json:"tag" dc:"标签精确筛选"`
|
||||
Level *int `json:"level" dc:"等级筛选(对应 tnt_tenant_level_configs.level)"`
|
||||
}
|
||||
|
||||
type TenantListRes struct {
|
||||
@@ -35,24 +37,27 @@ type TenantListRes struct {
|
||||
}
|
||||
|
||||
type TenantItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
OwnerUserID int64 `json:"owner_user_id"`
|
||||
OwnerName string `json:"owner_name"`
|
||||
Status string `json:"status"`
|
||||
MaxMembers *int `json:"max_members" dc:"最大成员数上限,NULL表示跟随等级配置"`
|
||||
MaxConcurrency *int `json:"max_concurrency" dc:"并发上限,NULL表示跟随等级配置"`
|
||||
EffectiveMaxMembers int `json:"effective_max_members" dc:"实际生效的成员数上限"`
|
||||
EffectiveMaxConcurrency int `json:"effective_max_concurrency" dc:"实际生效的并发上限"`
|
||||
DefaultChannelScope string `json:"default_channel_scope"`
|
||||
MemberCount int `json:"member_count"`
|
||||
WalletBalance string `json:"wallet_balance"`
|
||||
Level int `json:"level"`
|
||||
LevelName string `json:"level_name"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
OwnerUserID int64 `json:"owner_user_id"`
|
||||
OwnerName string `json:"owner_name"`
|
||||
Status string `json:"status"`
|
||||
MaxMembers *int `json:"max_members" dc:"最大成员数上限,NULL表示跟随等级配置"`
|
||||
MaxConcurrency *int `json:"max_concurrency" dc:"并发上限,NULL表示跟随等级配置"`
|
||||
EffectiveMaxMembers int `json:"effective_max_members" dc:"实际生效的成员数上限"`
|
||||
EffectiveMaxConcurrency int `json:"effective_max_concurrency" dc:"实际生效的并发上限"`
|
||||
DefaultChannelScope string `json:"default_channel_scope"`
|
||||
MemberCount int `json:"member_count"`
|
||||
WalletBalance string `json:"wallet_balance"`
|
||||
Tags []string `json:"tags" dc:"租户标签(管理后台运营标注)"`
|
||||
Remark string `json:"remark" dc:"管理员备注"`
|
||||
TotalConsumed string `json:"total_consumed" dc:"累计消费(本位币,decimal 字符串;Redis 权威计数的物化副本,≤5s 滞后)"`
|
||||
Level int `json:"level"`
|
||||
LevelName string `json:"level_name"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TenantGetReq 获取租户详情
|
||||
@@ -85,11 +90,13 @@ type TenantUpdateStatusRes struct{}
|
||||
// TenantUpdateReq 更新租户信息
|
||||
type TenantUpdateReq struct {
|
||||
g.Meta `path:"/tenants/{id}" method:"put" mime:"json" tags:"管理后台-租户管理" summary:"更新租户"`
|
||||
Id int64 `json:"id" in:"path" v:"required" dc:"租户ID"`
|
||||
Name string `json:"name" dc:"租户名称"`
|
||||
MaxMembers *int `json:"max_members" dc:"最大成员数"`
|
||||
MaxConcurrency *int `json:"max_concurrency" dc:"租户总并发上限(0表示不限制)"`
|
||||
Level *int `json:"level" dc:"租户等级(调整等级会同步更新成员数和并发数为该等级的配置值)"`
|
||||
Id int64 `json:"id" in:"path" v:"required" dc:"租户ID"`
|
||||
Name string `json:"name" dc:"租户名称"`
|
||||
MaxMembers *int `json:"max_members" dc:"最大成员数"`
|
||||
MaxConcurrency *int `json:"max_concurrency" dc:"租户总并发上限(0表示不限制)"`
|
||||
Level *int `json:"level" dc:"租户等级(调整等级会同步更新成员数和并发数为该等级的配置值)"`
|
||||
Tags []string `json:"tags" dc:"租户标签(≤10 个,每项 ≤30 字符,传空数组清空标签)"`
|
||||
Remark *string `json:"remark" dc:"管理员备注(≤1000 字符)"`
|
||||
}
|
||||
|
||||
type TenantUpdateRes struct{}
|
||||
@@ -138,6 +145,8 @@ type TenantExportReq struct {
|
||||
Format string `json:"format" in:"query" d:"csv" v:"in:csv,xlsx" dc:"导出格式:csv / xlsx"`
|
||||
Keyword string `json:"keyword" in:"query" dc:"搜索关键词(名称/代码)"`
|
||||
Status string `json:"status" in:"query" dc:"状态筛选:active/suspended/closed"`
|
||||
Tag string `json:"tag" in:"query" dc:"标签筛选"`
|
||||
Level *int `json:"level" in:"query" dc:"等级筛选"`
|
||||
}
|
||||
|
||||
type TenantExportRes struct{}
|
||||
|
||||
@@ -8,6 +8,7 @@ type MarketplaceListReq struct {
|
||||
g.Meta `path:"/marketplace/models" method:"get" tags:"公开-模型广场" summary:"获取模型广场列表" group:"public" middleware:"-"`
|
||||
Keyword string `json:"keyword" dc:"搜索关键词"`
|
||||
Category string `json:"category" dc:"模型类别筛选(chat/embedding/image/audio等)"`
|
||||
Vendor string `json:"vendor" dc:"研发厂商筛选(openai/anthropic/alibaba等),空=不过滤"`
|
||||
Page int `json:"page" d:"1" dc:"页码"`
|
||||
PageSize int `json:"page_size" d:"20" v:"between:1,100" dc:"每页数量"`
|
||||
}
|
||||
@@ -17,12 +18,16 @@ type MarketplaceListRes struct {
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
// Vendors 当前筛选条件(关键词/分类,忽略厂商筛选本身)下有模型的厂商去重列表,
|
||||
// 供前端只渲染实际有模型的厂商筛选项;顺序不保证,前端按本地字典序展示
|
||||
Vendors []string `json:"vendors"`
|
||||
}
|
||||
|
||||
type MarketplaceModelItem struct {
|
||||
ModelId string `json:"model_id"` // 模型标识
|
||||
ModelName string `json:"model_name"` // 模型显示名称
|
||||
Category string `json:"category"` // 分类(chat/embedding/image等)
|
||||
Vendor string `json:"vendor"` // 研发厂商(openai/anthropic/alibaba等,空=未分类)
|
||||
Description string `json:"description"` // 描述
|
||||
MaxContextTokens int `json:"max_context_tokens"` // 最大上下文 tokens
|
||||
MaxOutputTokens int `json:"max_output_tokens"` // 最大输出 tokens
|
||||
|
||||
@@ -6,6 +6,7 @@ import "github.com/gogf/gf/v2/frame/g"
|
||||
type TenantAvailableModelsReq struct {
|
||||
g.Meta `path:"/models" method:"get" mime:"json" tags:"租户控制台-模型" summary:"租户可用模型列表"`
|
||||
Category string `json:"category" dc:"模型分类筛选:chat/embedding/image/audio/rerank"`
|
||||
Vendor string `json:"vendor" dc:"研发厂商筛选(openai/anthropic/alibaba等),空=不过滤"`
|
||||
Search string `json:"search" dc:"搜索关键词(模型名或显示名)"`
|
||||
// ApiKeyID 按指定 API Key 的模型范围过滤(可选;不传或为 0 时返回租户全部可用模型)
|
||||
ApiKeyID int64 `json:"api_key_id" dc:"API Key ID(可选,传入时按该 Key 的模型范围过滤)"`
|
||||
@@ -46,6 +47,7 @@ type TenantAvailableModelItem struct {
|
||||
ModelId string `json:"model_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
Category string `json:"category"`
|
||||
Vendor string `json:"vendor"` // 研发厂商(空=未分类)
|
||||
MaxContext int `json:"max_context_tokens"`
|
||||
MaxOutput int `json:"max_output_tokens"`
|
||||
Description string `json:"description"`
|
||||
|
||||
@@ -157,6 +157,10 @@ var (
|
||||
// relay 层运行时配置注入(全局超时兜底/系统代理;渠道级 settings 仍优先)
|
||||
syncGlobalRelaySettings(ctx)
|
||||
|
||||
// 累计消费基线种子:为存量钱包 hash 补种 total_consumed 历史基线(幂等),
|
||||
// 必须在 relay 流量进入前完成,防止结算 HINCRBY 从 0 起算丢历史
|
||||
billing.SeedWalletTotalConsumed(ctx)
|
||||
|
||||
// 启动钱包物化器(boot goroutine,秒级刷新 Redis 权威钱包状态到 DB;不走 cron)
|
||||
billing.StartWalletMaterializer(ctx)
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ type BilWalletsColumns struct {
|
||||
UpdatedAt string // 更新时间
|
||||
CumulativeRecharge string // 累计充值总额(USD)
|
||||
LowBalanceNotified string // 低余额预警是否已推送(充值恢复后重置为 false)
|
||||
TotalConsumed string // 累计消费总额(本位币;Redis 钱包 hash total_consumed 的物化副本,随结算事件递增/补偿递减)
|
||||
}
|
||||
|
||||
// bilWalletsColumns holds the columns for the table bil_wallets.
|
||||
@@ -45,6 +46,7 @@ var bilWalletsColumns = BilWalletsColumns{
|
||||
UpdatedAt: "updated_at",
|
||||
CumulativeRecharge: "cumulative_recharge",
|
||||
LowBalanceNotified: "low_balance_notified",
|
||||
TotalConsumed: "total_consumed",
|
||||
}
|
||||
|
||||
// NewBilWalletsDao creates and returns a new DAO object for table data access.
|
||||
|
||||
@@ -36,6 +36,7 @@ type MdlModelsColumns struct {
|
||||
DeprecatedAt string // 标记弃用的时间(NULL表示未弃用)
|
||||
SunsetDate string // 计划下线日期(到达后返回410 Gone,NULL表示未设置)
|
||||
ReplacementModel string // 推荐替代模型名(NULL表示无替代)
|
||||
Vendor string // 研发厂商枚举:openai/anthropic/google/xai/mistral/cohere/meta/alibaba/bytedance/deepseek/zhipu/moonshot/minimax/baidu/tencent/xunfei/kuaishou/midjourney/suno,空串=未分类
|
||||
}
|
||||
|
||||
// mdlModelsColumns holds the columns for the table mdl_models.
|
||||
@@ -55,6 +56,7 @@ var mdlModelsColumns = MdlModelsColumns{
|
||||
DeprecatedAt: "deprecated_at",
|
||||
SunsetDate: "sunset_date",
|
||||
ReplacementModel: "replacement_model",
|
||||
Vendor: "vendor",
|
||||
}
|
||||
|
||||
// NewMdlModelsDao creates and returns a new DAO object for table data access.
|
||||
|
||||
@@ -30,6 +30,7 @@ type MdlPricingColumns struct {
|
||||
DiscountLabel string // 折扣标签(对外展示,如"7折起"、"限时5折"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
|
||||
PriceChangeNote string // 价格调整说明(对外展示,提示用户价格有变动,如"9月1日起输入价下调"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
|
||||
Pricing string // 计费详情(JSONB,按 billing_mode 单模式存储):token={input_price,output_price,cache_read_price,cache_creation_price};tiered={tiers:[{min_tokens,max_tokens,input_price,output_price}],cache_read_price,cache_creation_price};per_request={price};per_second={unit:"second",prices:{分辨率规格:每秒单价,"*":兜底价}};顶层可含 time_segments(时段定价)
|
||||
OfficialPricing string // 官方参考定价 JSONB(结构与 pricing 一致 + billing_mode 键,本位币;仅作定价折扣计算基准与几折展示,非计费依据)
|
||||
}
|
||||
|
||||
// mdlPricingColumns holds the columns for the table mdl_pricing.
|
||||
@@ -43,6 +44,7 @@ var mdlPricingColumns = MdlPricingColumns{
|
||||
DiscountLabel: "discount_label",
|
||||
PriceChangeNote: "price_change_note",
|
||||
Pricing: "pricing",
|
||||
OfficialPricing: "official_pricing",
|
||||
}
|
||||
|
||||
// NewMdlPricingDao creates and returns a new DAO object for table data access.
|
||||
|
||||
@@ -40,6 +40,8 @@ type TntTenantsColumns struct {
|
||||
DefaultChannelScope string // 默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组)
|
||||
Level string // 当前等级(对应 tnt_tenant_level_configs.level)
|
||||
TeamEnabled string // 团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度)
|
||||
Tags string // 租户标签(JSONB 字符串数组,管理后台运营标注,不暴露给租户控制台)
|
||||
Remark string // 管理员备注(运营备注,不暴露给租户控制台)
|
||||
}
|
||||
|
||||
// tntTenantsColumns holds the columns for the table tnt_tenants.
|
||||
@@ -63,6 +65,8 @@ var tntTenantsColumns = TntTenantsColumns{
|
||||
DefaultChannelScope: "default_channel_scope",
|
||||
Level: "level",
|
||||
TeamEnabled: "team_enabled",
|
||||
Tags: "tags",
|
||||
Remark: "remark",
|
||||
}
|
||||
|
||||
// NewTntTenantsDao creates and returns a new DAO object for table data access.
|
||||
|
||||
@@ -71,6 +71,9 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode
|
||||
if req.Category != "" {
|
||||
query = query.Where("category", req.Category)
|
||||
}
|
||||
if req.Vendor != "" {
|
||||
query = query.Where("vendor", req.Vendor)
|
||||
}
|
||||
if req.Status != "" {
|
||||
query = query.Where("status", req.Status)
|
||||
}
|
||||
@@ -98,6 +101,7 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode
|
||||
ModelId string `json:"model_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
Category string `json:"category"`
|
||||
Vendor string `json:"vendor"`
|
||||
Status string `json:"status"`
|
||||
MaxContextTokens int `json:"max_context_tokens"`
|
||||
MaxOutputTokens int `json:"max_output_tokens"`
|
||||
@@ -111,7 +115,7 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode
|
||||
ReplacementModel string `json:"replacement_model"`
|
||||
}
|
||||
|
||||
err := query.Fields("id, model_id, model_name, category, status, max_context_tokens, max_output_tokens, capabilities, description, tags, created_at, updated_at, deprecated_at, sunset_date, replacement_model").
|
||||
err := query.Fields("id, model_id, model_name, category, vendor, status, max_context_tokens, max_output_tokens, capabilities, description, tags, created_at, updated_at, deprecated_at, sunset_date, replacement_model").
|
||||
OrderDesc("id").
|
||||
Page(req.Page, req.PageSize).
|
||||
ScanAndCount(&models, &total, false)
|
||||
@@ -184,6 +188,7 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode
|
||||
ModelId: m.ModelId,
|
||||
ModelName: m.ModelName,
|
||||
Category: m.Category,
|
||||
Vendor: m.Vendor,
|
||||
Status: m.Status,
|
||||
MaxContext: m.MaxContextTokens,
|
||||
MaxOutput: m.MaxOutputTokens,
|
||||
@@ -255,6 +260,7 @@ func (s *sAdmin) CreateModel(ctx context.Context, req *v1.ModelCreateReq) (*v1.M
|
||||
ModelId: req.ModelId,
|
||||
ModelName: req.ModelName,
|
||||
Category: req.Category,
|
||||
Vendor: req.Vendor, // 空串=未分类
|
||||
Status: "active",
|
||||
MaxContextTokens: req.MaxContext,
|
||||
MaxOutputTokens: req.MaxOutput,
|
||||
@@ -288,6 +294,22 @@ func (s *sAdmin) CreateModel(ctx context.Context, req *v1.ModelCreateReq) (*v1.M
|
||||
return &v1.ModelCreateRes{ID: id}, nil
|
||||
}
|
||||
|
||||
// validModelVendors 模型研发厂商合法值集合(与 mdl_models.vendor 枚举、API v:"in:" 标签、前端字典对齐)。
|
||||
// 注意与 chn_channels.type 的"渠道供应商"是两个维度:研发厂商指模型的开发公司,
|
||||
// 例如 Bedrock 上的 Claude 厂商仍是 anthropic。
|
||||
var validModelVendors = map[string]struct{}{
|
||||
"openai": {}, "anthropic": {}, "google": {}, "xai": {}, "mistral": {}, "cohere": {},
|
||||
"meta": {}, "alibaba": {}, "bytedance": {}, "deepseek": {}, "zhipu": {}, "moonshot": {},
|
||||
"minimax": {}, "baidu": {}, "tencent": {}, "xunfei": {}, "kuaishou": {}, "midjourney": {},
|
||||
"suno": {},
|
||||
}
|
||||
|
||||
// isValidModelVendor 校验研发厂商枚举值
|
||||
func isValidModelVendor(v string) bool {
|
||||
_, ok := validModelVendors[v]
|
||||
return ok
|
||||
}
|
||||
|
||||
// UpdateModel 更新模型(含弃用状态管理)
|
||||
func (s *sAdmin) UpdateModel(ctx context.Context, req *v1.ModelUpdateReq) (*v1.ModelUpdateRes, error) {
|
||||
var oldModel *struct {
|
||||
@@ -311,6 +333,14 @@ func (s *sAdmin) UpdateModel(ctx context.Context, req *v1.ModelUpdateReq) (*v1.M
|
||||
data.Category = req.Category
|
||||
hasUpdate = true
|
||||
}
|
||||
// 厂商指针语义:nil=不更新;空串=清空回未分类(do 字段为 any,空串不会被 ORM 忽略);非空=校验枚举后设置
|
||||
if req.Vendor != nil {
|
||||
if *req.Vendor != "" && !isValidModelVendor(*req.Vendor) {
|
||||
return nil, common.NewBusinessError(422, "厂商值不合法")
|
||||
}
|
||||
data.Vendor = *req.Vendor
|
||||
hasUpdate = true
|
||||
}
|
||||
data.MaxContextTokens = req.MaxContext
|
||||
data.MaxOutputTokens = req.MaxOutput
|
||||
hasUpdate = true
|
||||
|
||||
@@ -28,6 +28,7 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe
|
||||
ModelId string `orm:"model_id" json:"model_id"`
|
||||
ModelName string `orm:"model_name" json:"model_name"`
|
||||
Category string `orm:"category" json:"category"`
|
||||
Vendor string `orm:"vendor" json:"vendor"`
|
||||
Status string `orm:"status" json:"status"`
|
||||
MaxContextTokens int `orm:"max_context_tokens" json:"max_context_tokens"`
|
||||
MaxOutputTokens int `orm:"max_output_tokens" json:"max_output_tokens"`
|
||||
@@ -64,6 +65,7 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe
|
||||
ModelId string `json:"model_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
Category string `json:"category"`
|
||||
Vendor string `json:"vendor,omitempty"` // 研发厂商(空=未分类,旧文件导入兼容)
|
||||
Status string `json:"status"`
|
||||
MaxContextTokens int `json:"max_context_tokens"`
|
||||
MaxOutputTokens int `json:"max_output_tokens"`
|
||||
@@ -148,6 +150,7 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe
|
||||
ModelId: m.ModelId,
|
||||
ModelName: m.ModelName,
|
||||
Category: m.Category,
|
||||
Vendor: m.Vendor,
|
||||
Status: m.Status,
|
||||
MaxContextTokens: m.MaxContextTokens,
|
||||
MaxOutputTokens: m.MaxOutputTokens,
|
||||
@@ -280,6 +283,10 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1.
|
||||
MaxOutputTokens: item.MaxOutputTokens,
|
||||
Description: item.Description,
|
||||
}
|
||||
// 旧导出文件无 vendor 字段(空串),不覆盖已有厂商配置
|
||||
if item.Vendor != "" {
|
||||
updateData.Vendor = item.Vendor
|
||||
}
|
||||
if item.Tags != nil {
|
||||
updateData.Tags = item.Tags
|
||||
}
|
||||
@@ -324,6 +331,7 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1.
|
||||
ModelId: item.ModelId,
|
||||
ModelName: item.ModelName,
|
||||
Category: item.Category,
|
||||
Vendor: item.Vendor,
|
||||
Status: "active",
|
||||
MaxContextTokens: item.MaxContextTokens,
|
||||
MaxOutputTokens: item.MaxOutputTokens,
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
@@ -22,6 +23,64 @@ import (
|
||||
"github.com/qianfree/team-api/internal/utility/export"
|
||||
)
|
||||
|
||||
// 租户标签约束:数量与单项长度上限(字符数,非字节数)
|
||||
const (
|
||||
tenantTagsMaxCount = 10
|
||||
tenantTagsMaxRunes = 30
|
||||
tenantRemarkMaxRunes = 1000
|
||||
)
|
||||
|
||||
// marshalTagFilter 把单个标签序列化为 JSONB 包含查询参数(tags @> '["vip"]')。
|
||||
// 序列化不会失败(输入为字符串),错误时返回空数组条件(匹配不到任何行,安全降级)。
|
||||
func marshalTagFilter(tag string) string {
|
||||
b, err := json.Marshal([]string{strings.TrimSpace(tag)})
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// parseTenantTags 解析租户标签 JSONB(空/非法输入返回空切片,不报错——展示路径容错)
|
||||
func parseTenantTags(raw string) []string {
|
||||
if raw == "" {
|
||||
return []string{}
|
||||
}
|
||||
var tags []string
|
||||
if err := json.Unmarshal([]byte(raw), &tags); err != nil {
|
||||
return []string{}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
// marshalTenantTags 校验并序列化租户标签为 JSONB 字符串:
|
||||
// 去首尾空白、跳过空项、去重,≤ tenantTagsMaxCount 个、每项 ≤ tenantTagsMaxRunes 字符。
|
||||
func marshalTenantTags(tags []string) (string, error) {
|
||||
cleaned := make([]string, 0, len(tags))
|
||||
seen := make(map[string]struct{}, len(tags))
|
||||
for _, tag := range tags {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
if utf8.RuneCountInString(tag) > tenantTagsMaxRunes {
|
||||
return "", common.NewBadRequestError("单个标签最长 30 字符")
|
||||
}
|
||||
if _, ok := seen[tag]; ok {
|
||||
continue
|
||||
}
|
||||
seen[tag] = struct{}{}
|
||||
cleaned = append(cleaned, tag)
|
||||
}
|
||||
if len(cleaned) > tenantTagsMaxCount {
|
||||
return "", common.NewBadRequestError("标签数量最多 10 个")
|
||||
}
|
||||
b, err := json.Marshal(cleaned)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// TenantSelect returns a lightweight paginated tenant list for dropdown selectors.
|
||||
func (s *sAdmin) TenantSelect(ctx context.Context, req *v1.TenantSelectReq) (*v1.TenantSelectRes, error) {
|
||||
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
||||
@@ -217,10 +276,12 @@ func batchTenantAggregates(ctx context.Context, tenantIDs, ownerIDs []int64) (
|
||||
ownerNames map[int64]string,
|
||||
memberCounts map[int64]int,
|
||||
walletBalances map[int64]string,
|
||||
walletTotalConsumed map[int64]string,
|
||||
) {
|
||||
ownerNames = make(map[int64]string, len(ownerIDs))
|
||||
memberCounts = make(map[int64]int, len(tenantIDs))
|
||||
walletBalances = make(map[int64]string, len(tenantIDs))
|
||||
walletTotalConsumed = make(map[int64]string, len(tenantIDs))
|
||||
|
||||
// owner 名称:按 owner_user_id 批量取
|
||||
if len(ownerIDs) > 0 {
|
||||
@@ -246,15 +307,17 @@ func batchTenantAggregates(ctx context.Context, tenantIDs, ownerIDs []int64) (
|
||||
memberCounts[c.TenantID] = c.Count
|
||||
}
|
||||
|
||||
// 钱包余额:按 tenant_id 批量取
|
||||
// 钱包余额 / 累计消费:按 tenant_id 批量取(DB 物化副本,累计消费随物化器 ≤5s 滞后)
|
||||
var wallets []struct {
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
Balance string `json:"balance"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
Balance string `json:"balance"`
|
||||
TotalConsumed string `json:"total_consumed"`
|
||||
}
|
||||
_ = dao.BilWallets.Ctx(ctx).Fields("tenant_id, balance").
|
||||
_ = dao.BilWallets.Ctx(ctx).Fields("tenant_id, balance, total_consumed").
|
||||
WhereIn("tenant_id", tenantIDs).Scan(&wallets)
|
||||
for _, w := range wallets {
|
||||
walletBalances[w.TenantID] = w.Balance
|
||||
walletTotalConsumed[w.TenantID] = w.TotalConsumed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +337,12 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te
|
||||
if req.Status != "" {
|
||||
m = m.Where("status", req.Status)
|
||||
}
|
||||
if req.Tag != "" {
|
||||
m = m.Where("tags @> ?::jsonb", marshalTagFilter(req.Tag))
|
||||
}
|
||||
if req.Level != nil {
|
||||
m = m.Where("level", *req.Level)
|
||||
}
|
||||
|
||||
total, err := m.Count()
|
||||
if err != nil {
|
||||
@@ -289,6 +358,12 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te
|
||||
if req.Status != "" {
|
||||
m = m.Where("status", req.Status)
|
||||
}
|
||||
if req.Tag != "" {
|
||||
m = m.Where("tags @> ?::jsonb", marshalTagFilter(req.Tag))
|
||||
}
|
||||
if req.Level != nil {
|
||||
m = m.Where("level", *req.Level)
|
||||
}
|
||||
|
||||
// 扫描结构体必须带 orm 标签:gf 的 Scan 会用「结构体字段名」自动生成 SELECT 字段列表,
|
||||
// 无 orm 标签时靠运行时查表结构(TableFields)做 Id→id 映射;该元数据查询失败会被框架静默吞掉,
|
||||
@@ -305,6 +380,8 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te
|
||||
DefaultChannelScope string `json:"default_channel_scope" orm:"default_channel_scope"`
|
||||
Settings string `json:"settings" orm:"settings"`
|
||||
Level int `json:"level" orm:"level"`
|
||||
Tags string `json:"tags" orm:"tags"`
|
||||
Remark string `json:"remark" orm:"remark"`
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at"`
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at"`
|
||||
}
|
||||
@@ -336,7 +413,7 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te
|
||||
tenantIDs[i] = t.Id
|
||||
ownerIDs[i] = t.OwnerUserID
|
||||
}
|
||||
ownerNameMap, memberCountMap, walletBalanceMap := batchTenantAggregates(ctx, tenantIDs, ownerIDs)
|
||||
ownerNameMap, memberCountMap, walletBalanceMap, walletConsumedMap := batchTenantAggregates(ctx, tenantIDs, ownerIDs)
|
||||
|
||||
for i, t := range tenants {
|
||||
item := v1.TenantItem{
|
||||
@@ -351,6 +428,8 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te
|
||||
DefaultChannelScope: t.DefaultChannelScope,
|
||||
Level: t.Level,
|
||||
LevelName: levelNameMap[t.Level],
|
||||
Tags: parseTenantTags(t.Tags),
|
||||
Remark: t.Remark,
|
||||
CreatedAt: t.CreatedAt.String(),
|
||||
UpdatedAt: t.UpdatedAt.String(),
|
||||
}
|
||||
@@ -371,7 +450,7 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te
|
||||
item.EffectiveMaxConcurrency = 0
|
||||
}
|
||||
|
||||
// 从批量结果中取 owner 名称 / 成员数 / 钱包余额
|
||||
// 从批量结果中取 owner 名称 / 成员数 / 钱包余额 / 累计消费
|
||||
item.OwnerName = ownerNameMap[t.OwnerUserID]
|
||||
item.MemberCount = memberCountMap[t.Id]
|
||||
if bal, ok := walletBalanceMap[t.Id]; ok && bal != "" {
|
||||
@@ -379,6 +458,11 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te
|
||||
} else {
|
||||
item.WalletBalance = "0"
|
||||
}
|
||||
if tc, ok := walletConsumedMap[t.Id]; ok && tc != "" {
|
||||
item.TotalConsumed = tc
|
||||
} else {
|
||||
item.TotalConsumed = "0"
|
||||
}
|
||||
|
||||
items[i] = item
|
||||
}
|
||||
@@ -405,6 +489,8 @@ func (s *sAdmin) GetTenant(ctx context.Context, req *v1.TenantGetReq) (*v1.Tenan
|
||||
DefaultChannelScope string `json:"default_channel_scope"`
|
||||
Settings string `json:"settings"`
|
||||
Level int `json:"level"`
|
||||
Tags string `json:"tags"`
|
||||
Remark string `json:"remark"`
|
||||
CreatedAt *gtime.Time `json:"created_at"`
|
||||
UpdatedAt *gtime.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -428,19 +514,27 @@ func (s *sAdmin) GetTenant(ctx context.Context, req *v1.TenantGetReq) (*v1.Tenan
|
||||
memberCount, _ := dao.TntUsers.Ctx(ctx).
|
||||
Where("tenant_id", req.Id).Count()
|
||||
|
||||
// Get wallet balance:优先读 Redis 权威值(调整余额/入账后详情页立即可见),DB 物化值兜底
|
||||
// Get wallet balance / 累计消费:优先读 Redis 权威值(调整余额/入账/结算后详情页立即可见),DB 物化值兜底
|
||||
walletBalance := "0"
|
||||
totalConsumed := "0"
|
||||
if w, err := billing.GetWallet(ctx, req.Id); err == nil {
|
||||
walletBalance = strconv.FormatFloat(w.Balance, 'f', -1, 64)
|
||||
totalConsumed = strconv.FormatFloat(w.TotalConsumed, 'f', -1, 64)
|
||||
} else {
|
||||
var wallet *struct {
|
||||
Balance string `json:"balance"`
|
||||
Balance string `json:"balance"`
|
||||
TotalConsumed string `json:"total_consumed"`
|
||||
}
|
||||
_ = dao.BilWallets.Ctx(ctx).
|
||||
Where("tenant_id", req.Id).Scan(&wallet)
|
||||
|
||||
if wallet != nil && wallet.Balance != "" {
|
||||
walletBalance = wallet.Balance
|
||||
if wallet != nil {
|
||||
if wallet.Balance != "" {
|
||||
walletBalance = wallet.Balance
|
||||
}
|
||||
if wallet.TotalConsumed != "" {
|
||||
totalConsumed = wallet.TotalConsumed
|
||||
}
|
||||
}
|
||||
}
|
||||
ownerName := ""
|
||||
@@ -486,6 +580,9 @@ func (s *sAdmin) GetTenant(ctx context.Context, req *v1.TenantGetReq) (*v1.Tenan
|
||||
DefaultChannelScope: tenant.DefaultChannelScope,
|
||||
MemberCount: memberCount,
|
||||
WalletBalance: walletBalance,
|
||||
Tags: parseTenantTags(tenant.Tags),
|
||||
Remark: tenant.Remark,
|
||||
TotalConsumed: totalConsumed,
|
||||
Level: tenant.Level,
|
||||
LevelName: levelName,
|
||||
CreatedAt: tenant.CreatedAt.String(),
|
||||
@@ -556,6 +653,22 @@ func (s *sAdmin) UpdateTenant(ctx context.Context, req *v1.TenantUpdateReq) (*v1
|
||||
data.Level = *req.Level
|
||||
}
|
||||
|
||||
// 标签:nil 表示未传(不更新);空数组表示清空(序列化为 [])
|
||||
if req.Tags != nil {
|
||||
tagsJSON, err := marshalTenantTags(req.Tags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data.Tags = tagsJSON
|
||||
}
|
||||
if req.Remark != nil {
|
||||
remark := strings.TrimSpace(*req.Remark)
|
||||
if utf8.RuneCountInString(remark) > tenantRemarkMaxRunes {
|
||||
return nil, common.NewBadRequestError("备注最长 1000 字符")
|
||||
}
|
||||
data.Remark = remark
|
||||
}
|
||||
|
||||
_, err := dao.TntTenants.Ctx(ctx).Where("id", req.Id).Update(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -593,8 +706,11 @@ func (s *sAdmin) ExportTenants(ctx context.Context, req *v1.TenantExportReq) (*v
|
||||
{Field: "code", Header: "代码"},
|
||||
{Field: "owner_name", Header: "所有者"},
|
||||
{Field: "status", Header: "状态"},
|
||||
{Field: "tags", Header: "标签"},
|
||||
{Field: "remark", Header: "备注"},
|
||||
{Field: "member_count", Header: "成员数"},
|
||||
{Field: "wallet_balance", Header: "钱包余额"},
|
||||
{Field: "total_consumed", Header: "累计消费"},
|
||||
{Field: "created_at", Header: "创建时间"},
|
||||
}
|
||||
|
||||
@@ -615,40 +731,55 @@ func (s *sAdmin) ExportTenants(ctx context.Context, req *v1.TenantExportReq) (*v
|
||||
if req.Status != "" {
|
||||
m = m.Where("status", req.Status)
|
||||
}
|
||||
if req.Tag != "" {
|
||||
m = m.Where("tags @> ?::jsonb", marshalTagFilter(req.Tag))
|
||||
}
|
||||
if req.Level != nil {
|
||||
m = m.Where("level", *req.Level)
|
||||
}
|
||||
var batch []struct {
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
OwnerUserID int64 `json:"owner_user_id"`
|
||||
Status string `json:"status"`
|
||||
Tags string `json:"tags"`
|
||||
Remark string `json:"remark"`
|
||||
CreatedAt *gtime.Time `json:"created_at"`
|
||||
}
|
||||
if err := m.Fields("id, name, code, owner_user_id, status, created_at").OrderDesc("id").Limit(1000).Offset(offset).Scan(&batch); err != nil {
|
||||
if err := m.Fields("id, name, code, owner_user_id, status, tags, remark, created_at").OrderDesc("id").Limit(1000).Offset(offset).Scan(&batch); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 批量查询本页的 owner 名称 / 成员数 / 钱包余额(避免逐行 N+1)
|
||||
// 批量查询本页的 owner 名称 / 成员数 / 钱包余额 / 累计消费(避免逐行 N+1)
|
||||
tenantIDs := make([]int64, len(batch))
|
||||
ownerIDs := make([]int64, len(batch))
|
||||
for i, t := range batch {
|
||||
tenantIDs[i] = t.Id
|
||||
ownerIDs[i] = t.OwnerUserID
|
||||
}
|
||||
ownerNameMap, memberCountMap, walletBalanceMap := batchTenantAggregates(ctx, tenantIDs, ownerIDs)
|
||||
ownerNameMap, memberCountMap, walletBalanceMap, walletConsumedMap := batchTenantAggregates(ctx, tenantIDs, ownerIDs)
|
||||
|
||||
for _, t := range batch {
|
||||
walletBalance := "0"
|
||||
if bal, ok := walletBalanceMap[t.Id]; ok && bal != "" {
|
||||
walletBalance = bal
|
||||
}
|
||||
totalConsumed := "0"
|
||||
if tc, ok := walletConsumedMap[t.Id]; ok && tc != "" {
|
||||
totalConsumed = tc
|
||||
}
|
||||
row := map[string]any{
|
||||
"id": t.Id,
|
||||
"name": t.Name,
|
||||
"code": t.Code,
|
||||
"owner_name": ownerNameMap[t.OwnerUserID],
|
||||
"status": t.Status,
|
||||
"tags": strings.Join(parseTenantTags(t.Tags), ","),
|
||||
"remark": t.Remark,
|
||||
"member_count": memberCountMap[t.Id],
|
||||
"wallet_balance": walletBalance,
|
||||
"total_consumed": totalConsumed,
|
||||
"created_at": t.CreatedAt.String(),
|
||||
}
|
||||
if !yield(row) {
|
||||
|
||||
@@ -269,7 +269,7 @@ func (s *sAdmin) DeleteTenantModel(ctx context.Context, req *v1.TenantModelDelet
|
||||
|
||||
// ListTenantAvailableModels 预览租户实际可用的所有模型(显式分配 + 分组来源,去重)
|
||||
func (s *sAdmin) ListTenantAvailableModels(ctx context.Context, req *v1.TenantAvailableModelsPreviewReq) (*v1.TenantAvailableModelsPreviewRes, error) {
|
||||
models, err := tenantLogic.GetTenantAvailableModels(ctx, req.TenantID, "", "")
|
||||
models, err := tenantLogic.GetTenantAvailableModels(ctx, req.TenantID, "", "", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMarshalTenantTags 标签校验与序列化:去空白、去重、跳过空项、数量与长度上限
|
||||
func TestMarshalTenantTags(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input []string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"普通标签", []string{"vip", "重点客户"}, `["vip","重点客户"]`, false},
|
||||
{"去首尾空白", []string{" vip ", "dev"}, `["vip","dev"]`, false},
|
||||
{"跳过空项", []string{"", " ", "vip"}, `["vip"]`, false},
|
||||
{"去重", []string{"vip", "vip", "dev"}, `["vip","dev"]`, false},
|
||||
{"空数组清空", []string{}, `[]`, false},
|
||||
{"nil 清空", nil, `[]`, false},
|
||||
{"超长单标签", []string{"一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一"}, "", true},
|
||||
{"数量超限", []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"}, "", true},
|
||||
{"英文30字符边界", []string{"abcdefghijklmnopqrstuvwxyz0123"}, `["abcdefghijklmnopqrstuvwxyz0123"]`, false},
|
||||
{"英文31字符超限", []string{"abcdefghijklmnopqrstuvwxyz0123" + "4"}, "", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, err := marshalTenantTags(c.input)
|
||||
if c.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("want error, got %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Fatalf("got=%s want=%s", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseTenantTags 标签解析容错:空/非法输入返回空切片而非报错(展示路径)
|
||||
func TestParseTenantTags(t *testing.T) {
|
||||
if got := parseTenantTags(""); got == nil || len(got) != 0 {
|
||||
t.Fatalf("empty input: want empty slice, got %v", got)
|
||||
}
|
||||
if got := parseTenantTags("not-json"); got == nil || len(got) != 0 {
|
||||
t.Fatalf("invalid json: want empty slice, got %v", got)
|
||||
}
|
||||
if got := parseTenantTags(`["vip","dev"]`); len(got) != 2 || got[0] != "vip" || got[1] != "dev" {
|
||||
t.Fatalf("valid json: got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarshalTagFilter 标签筛选参数序列化为 JSONB 包含查询条件
|
||||
func TestMarshalTagFilter(t *testing.T) {
|
||||
if got := marshalTagFilter("vip"); got != `["vip"]` {
|
||||
t.Fatalf("got=%s want=[\"vip\"]", got)
|
||||
}
|
||||
if got := marshalTagFilter(" vip "); got != `["vip"]` {
|
||||
t.Fatalf("trimmed: got=%s", got)
|
||||
}
|
||||
}
|
||||
@@ -185,7 +185,7 @@ func reconcileFrozenBalance(ctx context.Context) {
|
||||
|
||||
// checkOneWalletFrozenDrift 比对单个租户的 Redis/DB 冻结值,超窗漂移告警
|
||||
func checkOneWalletFrozenDrift(ctx context.Context, tenantID int64, materializeLag time.Duration) {
|
||||
_, frozenMicro, exists, err := readWalletHash(ctx, tenantID)
|
||||
_, frozenMicro, _, _, exists, err := readWalletHash(ctx, tenantID)
|
||||
if err != nil || !exists {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -123,10 +123,11 @@ func executeSettlement(ctx context.Context, p settlementTxParams) (int64, error)
|
||||
_, err = dao.BilTransactions.Ctx(ctx).Data(p.buildTransaction(billingID,
|
||||
InexactFloat64(balanceAfterD), InexactFloat64(frozenAfterD))).Insert()
|
||||
if err != nil {
|
||||
// 逆转扣款 + 删除计费记录,回到「未结算」状态(预扣已随认领释放,本单免费)
|
||||
if _, _, creditErr := CreditWalletRedis(ctx, p.tenantID, actualCostD); creditErr != nil {
|
||||
// 逆转扣款 + 删除计费记录,回到「未结算」状态(预扣已随认领释放,本单免费)。
|
||||
// 用 ReverseConsumeRedis 而非 CreditWalletRedis:余额与累计消费必须同一原子回退
|
||||
if reverseErr := ReverseConsumeRedis(ctx, p.tenantID, actualCostD); reverseErr != nil {
|
||||
g.Log().Errorf(ctx, "%s: compensate reverse charge failed: tenant=%d request=%v cost=%.6f: %v",
|
||||
p.logPrefix, p.tenantID, p.predeductRequestIDs, p.actualCost, creditErr)
|
||||
p.logPrefix, p.tenantID, p.predeductRequestIDs, p.actualCost, reverseErr)
|
||||
}
|
||||
if delErr := deleteBillingRecord(ctx, billingID); delErr != nil {
|
||||
g.Log().Errorf(ctx, "%s: compensate delete billing record %d failed: %v", p.logPrefix, billingID, delErr)
|
||||
|
||||
@@ -80,7 +80,7 @@ func materializeDirtyWallets(ctx context.Context) {
|
||||
// materializeOneWallet 物化单个租户钱包;成功或 hash 缺失时清除脏标记
|
||||
func materializeOneWallet(ctx context.Context, tenantID int64) {
|
||||
walletRedisKey := walletHashKey(tenantID)
|
||||
balanceMicro, frozenMicro, exists, err := readWalletHash(ctx, tenantID)
|
||||
balanceMicro, frozenMicro, consumedMicro, hasConsumed, exists, err := readWalletHash(ctx, tenantID)
|
||||
if err != nil {
|
||||
// Redis 故障:保留脏标记,下 tick 重试
|
||||
g.Log().Warningf(ctx, "[WALLET MATERIALIZER] read wallet hash failed: tenant=%d: %v", tenantID, err)
|
||||
@@ -92,10 +92,27 @@ func materializeOneWallet(ctx context.Context, tenantID int64) {
|
||||
return
|
||||
}
|
||||
|
||||
// decimal 直传 NUMERIC(driver.Valuer 精确字符串),balance/frozen 全量覆盖
|
||||
// 自愈兜底(三层补种之③层):hash 存在但缺 total_consumed 字段(boot 种子后新出现的
|
||||
// 字段缺失 hash,如灾后经充值路径重建),从 DB 物化副本补种历史基线——否则下方全量
|
||||
// 覆盖会把回填好的历史值写回 0
|
||||
if !hasConsumed {
|
||||
dbConsumed, qErr := queryTotalConsumed(ctx, tenantID)
|
||||
if qErr != nil {
|
||||
// DB 故障:保留脏标记,下 tick 重试
|
||||
g.Log().Warningf(ctx, "[WALLET MATERIALIZER] query total_consumed for seed failed: tenant=%d: %v", tenantID, qErr)
|
||||
return
|
||||
}
|
||||
if sErr := seedTotalConsumedIfMissing(ctx, tenantID, ToMicro(dbConsumed)); sErr != nil {
|
||||
g.Log().Warningf(ctx, "[WALLET MATERIALIZER] seed total_consumed failed: tenant=%d: %v", tenantID, sErr)
|
||||
return
|
||||
}
|
||||
consumedMicro = ToMicro(dbConsumed)
|
||||
}
|
||||
|
||||
// decimal 直传 NUMERIC(driver.Valuer 精确字符串),balance/frozen/total_consumed 全量覆盖
|
||||
_, err = g.DB().Ctx(ctx).Exec(ctx,
|
||||
"UPDATE bil_wallets SET balance = ?, frozen_balance = ?, updated_at = NOW() WHERE tenant_id = ?",
|
||||
FromMicro(balanceMicro), FromMicro(frozenMicro), tenantID)
|
||||
"UPDATE bil_wallets SET balance = ?, frozen_balance = ?, total_consumed = ?, updated_at = NOW() WHERE tenant_id = ?",
|
||||
FromMicro(balanceMicro), FromMicro(frozenMicro), FromMicro(consumedMicro), tenantID)
|
||||
if err != nil {
|
||||
// DB 故障:保留脏标记,下 tick 重试
|
||||
g.Log().Warningf(ctx, "[WALLET MATERIALIZER] update db wallet failed: tenant=%d: %v", tenantID, err)
|
||||
|
||||
@@ -31,7 +31,7 @@ const (
|
||||
)
|
||||
|
||||
// walletHashKey 钱包 Redis hash key。
|
||||
// v2:balance / frozen_balance 以整数 micro-USD 存储;旧版 float key 随 TTL 过期。
|
||||
// v2:balance / frozen_balance / total_consumed 以整数 micro-USD 存储;旧版 float key 随 TTL 过期。
|
||||
//
|
||||
// 钱包 hash 是资金状态的【唯一实时权威】(Redis 权威化架构):
|
||||
// - 所有资金变动(预扣冻结、结算扣款、解冻、充值/退款/调账)全部通过 Lua 原子作用于它;
|
||||
@@ -56,6 +56,7 @@ type WalletInfo struct {
|
||||
TenantID int64
|
||||
Balance float64
|
||||
FrozenBalance float64
|
||||
TotalConsumed float64
|
||||
WarningThreshold float64
|
||||
Currency string
|
||||
LowBalanceNotified bool
|
||||
@@ -101,7 +102,7 @@ func GetWallet(ctx context.Context, tenantID int64) (*WalletInfo, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
balance, frozen, err := getWalletRedisState(ctx, tenantID)
|
||||
balance, frozen, totalConsumed, err := getWalletRedisState(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -111,44 +112,51 @@ func GetWallet(ctx context.Context, tenantID int64) (*WalletInfo, error) {
|
||||
TenantID: tenantID,
|
||||
Balance: balance,
|
||||
FrozenBalance: frozen,
|
||||
TotalConsumed: totalConsumed,
|
||||
WarningThreshold: static.WarningThreshold,
|
||||
Currency: static.Currency,
|
||||
LowBalanceNotified: static.LowBalanceNotified,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getWalletRedisState 读取 Redis 权威余额/冻结(USD float)。hash 缺失时先灾难恢复重建。
|
||||
func getWalletRedisState(ctx context.Context, tenantID int64) (balance, frozen float64, err error) {
|
||||
balanceMicro, frozenMicro, exists, err := readWalletHash(ctx, tenantID)
|
||||
// getWalletRedisState 读取 Redis 权威余额/冻结/累计消费(USD float)。hash 缺失时先灾难恢复重建。
|
||||
func getWalletRedisState(ctx context.Context, tenantID int64) (balance, frozen, totalConsumed float64, err error) {
|
||||
balanceMicro, frozenMicro, consumedMicro, _, exists, err := readWalletHash(ctx, tenantID)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
if !exists {
|
||||
if err = rebuildWalletFromDB(ctx, tenantID); err != nil {
|
||||
return 0, 0, err
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
balanceMicro, frozenMicro, _, err = readWalletHash(ctx, tenantID)
|
||||
balanceMicro, frozenMicro, consumedMicro, _, _, err = readWalletHash(ctx, tenantID)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
}
|
||||
return InexactFloat64(FromMicro(balanceMicro)), InexactFloat64(FromMicro(frozenMicro)), nil
|
||||
return InexactFloat64(FromMicro(balanceMicro)), InexactFloat64(FromMicro(frozenMicro)), InexactFloat64(FromMicro(consumedMicro)), nil
|
||||
}
|
||||
|
||||
// readWalletHash 读取钱包 hash 的 balance/frozen_balance(micro-USD)。
|
||||
// readWalletHash 读取钱包 hash 的 balance/frozen_balance/total_consumed(micro-USD)。
|
||||
// exists=false 表示 hash 不存在(灾难恢复场景)。
|
||||
func readWalletHash(ctx context.Context, tenantID int64) (balanceMicro, frozenMicro int64, exists bool, err error) {
|
||||
// hasConsumed=false 表示 hash 存在但缺 total_consumed 字段(部署前的老 hash / 灾后仅含
|
||||
// balance 的重建 hash),调用方(物化器)据此从 DB 补种历史基线,避免计数从 0 起算丢历史。
|
||||
func readWalletHash(ctx context.Context, tenantID int64) (balanceMicro, frozenMicro, consumedMicro int64, hasConsumed, exists bool, err error) {
|
||||
res, err := g.Redis().Do(ctx, "HGETALL", walletHashKey(tenantID))
|
||||
if err != nil {
|
||||
return 0, 0, false, gerror.Wrapf(err, "read wallet hash")
|
||||
return 0, 0, 0, false, false, gerror.Wrapf(err, "read wallet hash")
|
||||
}
|
||||
if res.IsNil() || res.IsEmpty() {
|
||||
return 0, 0, false, nil
|
||||
return 0, 0, 0, false, false, nil
|
||||
}
|
||||
m := res.Map()
|
||||
balanceMicro = gconv.Int64(m["balance"])
|
||||
frozenMicro = gconv.Int64(m["frozen_balance"])
|
||||
return balanceMicro, frozenMicro, true, nil
|
||||
if raw, ok := m["total_consumed"]; ok {
|
||||
consumedMicro = gconv.Int64(raw)
|
||||
hasConsumed = true
|
||||
}
|
||||
return balanceMicro, frozenMicro, consumedMicro, hasConsumed, true, nil
|
||||
}
|
||||
|
||||
// rebuildWalletFromDB 灾难恢复:钱包 hash 缺失时从 DB 物化值重建。
|
||||
@@ -174,11 +182,12 @@ func doRebuildWalletFromDB(ctx context.Context, tenantID int64) error {
|
||||
}
|
||||
|
||||
var w *struct {
|
||||
Balance decimal.Decimal `json:"balance"`
|
||||
Balance decimal.Decimal `json:"balance"`
|
||||
TotalConsumed decimal.Decimal `json:"total_consumed"`
|
||||
}
|
||||
err = dao.BilWallets.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Fields("balance").
|
||||
Fields("balance, total_consumed").
|
||||
Scan(&w)
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "rebuild wallet: query db")
|
||||
@@ -190,18 +199,19 @@ func doRebuildWalletFromDB(ctx context.Context, tenantID int64) error {
|
||||
// 幸存预扣明细重算 frozen(不取 DB frozen_balance:其中可能含已丢失预扣的残留)
|
||||
frozenMicro := sumSurvivingPrededucts(ctx, tenantID)
|
||||
|
||||
// 「检查不存在 + 写入」原子完成,避免并发重建互相覆盖
|
||||
// 「检查不存在 + 写入」原子完成,避免并发重建互相覆盖。
|
||||
// total_consumed 从 DB 物化副本恢复历史基线(丢失的只是物化间隔内的增量,与余额同类风险)
|
||||
rebuildLua := `
|
||||
local key = KEYS[1]
|
||||
if redis.call("EXISTS", key) == 1 then
|
||||
return 0
|
||||
end
|
||||
redis.call("HSET", key, "balance", ARGV[1], "frozen_balance", ARGV[2], "ver", 0)
|
||||
redis.call("SADD", "wallet_dirty_tenants", ARGV[3])
|
||||
redis.call("HSET", key, "balance", ARGV[1], "frozen_balance", ARGV[2], "total_consumed", ARGV[3], "ver", 0)
|
||||
redis.call("SADD", "wallet_dirty_tenants", ARGV[4])
|
||||
return 1
|
||||
`
|
||||
ret, err := g.Redis().Do(ctx, "EVAL", rebuildLua, 1, walletRedisKey,
|
||||
ToMicro(w.Balance), frozenMicro, tenantID)
|
||||
ToMicro(w.Balance), frozenMicro, ToMicro(w.TotalConsumed), tenantID)
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "rebuild wallet: redis")
|
||||
}
|
||||
@@ -421,6 +431,8 @@ func UnfreezePreDeduct(ctx context.Context, tenantID int64, requestID string) (d
|
||||
// 返回值:{claimed_micro, balance_after_micro, frozen_after_micro}
|
||||
// 预扣 hash 不存在(已释放/过期/丢失)时按 claimed=0 处理:只扣 balance 不动 frozen,
|
||||
// 与既有「track 丢失只扣 balance」语义一致(调用方据此打告警)。
|
||||
// total_consumed 与扣款同一原子递增(累计消费唯一正向变动点;字段缺失按 0 起算——
|
||||
// 正常路径由 boot 种子/灾备重建/物化器自愈三层机制保证字段已含历史基线)。
|
||||
const settleClaimLua = `
|
||||
local wallet_key = KEYS[1]
|
||||
local cost = tonumber(ARGV[1])
|
||||
@@ -444,6 +456,7 @@ if frozen < 0 then
|
||||
frozen = 0
|
||||
end
|
||||
local balance = tonumber(redis.call("HINCRBY", wallet_key, "balance", -cost))
|
||||
redis.call("HINCRBY", wallet_key, "total_consumed", cost)
|
||||
redis.call("HINCRBY", wallet_key, "ver", 1)
|
||||
redis.call("SADD", "wallet_dirty_tenants", tenant_id)
|
||||
return {claimed, balance, frozen}
|
||||
@@ -502,7 +515,8 @@ redis.call("SADD", "wallet_dirty_tenants", ARGV[2])
|
||||
return {balance, frozen}
|
||||
`
|
||||
|
||||
// CreditWalletRedis 钱包加款(Redis 提交点):充值/兑换/人工充值/结算补偿逆转等场景。
|
||||
// CreditWalletRedis 钱包加款(Redis 提交点):充值/兑换/人工充值/调账等场景。
|
||||
// 注意:结算补偿逆转走 ReverseConsumeRedis(需同步回退累计消费),不得用本函数。
|
||||
// 返回加款后余额与当前冻结快照(供流水 balance_after/frozen_after)。
|
||||
// hash 缺失时 HINCRBY 会新建仅含 balance 的 hash——仅应发生在「新租户首充」等合法场景;
|
||||
// 其他场景说明发生了灾难丢失,由后续 rebuild/物化流程收敛。
|
||||
@@ -537,6 +551,32 @@ func DebitWalletRedis(ctx context.Context, tenantID int64, amount decimal.Decima
|
||||
return FromMicro(vals[0]), FromMicro(vals[1]), true, nil
|
||||
}
|
||||
|
||||
// 结算补偿逆转 Lua:balance 与 total_consumed 同一原子回退。
|
||||
// 专用场景:executeSettlement 第 c 步(记流水)失败时逆转扣款——本单最终免费,不构成消费,
|
||||
// 累计消费必须与余额同步回退,防止只逆转余额导致计数虚增。
|
||||
// 不复用 creditWalletLua:那是充值/调账通用入口,不感知累计消费计数(充值不得动计数)。
|
||||
// KEYS[1] = wallet:v2:{tenant_id}
|
||||
// ARGV[1] = cost_micro(正数)
|
||||
// ARGV[2] = tenant_id
|
||||
const reverseConsumeLua = `
|
||||
redis.call("HINCRBY", KEYS[1], "balance", ARGV[1])
|
||||
redis.call("HINCRBY", KEYS[1], "total_consumed", -tonumber(ARGV[1]))
|
||||
redis.call("HINCRBY", KEYS[1], "ver", 1)
|
||||
redis.call("SADD", "wallet_dirty_tenants", ARGV[2])
|
||||
return 1
|
||||
`
|
||||
|
||||
// ReverseConsumeRedis 结算补偿逆转(executeSettlement 记流水失败时调用):
|
||||
// 余额与累计消费在同一 Lua 内原子回退。失败由调用方记 Error 日志告警人工对账。
|
||||
func ReverseConsumeRedis(ctx context.Context, tenantID int64, cost decimal.Decimal) error {
|
||||
_, err := g.Redis().Do(ctx, "EVAL", reverseConsumeLua, 1,
|
||||
walletHashKey(tenantID), ToMicro(cost), tenantID)
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "reverse consume: redis")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InvalidateWalletStaticCache 清除钱包静态字段缓存(预警阈值、低余额标记等低频字段变更后调用)。
|
||||
// 注意:只清进程内静态缓存,【绝不】清 Redis 钱包 hash——hash 是权威余额(非可失效缓存),
|
||||
// 删除会导致下次从滞后的 DB 重建而回滚未物化增量。
|
||||
|
||||
@@ -42,7 +42,7 @@ func testCtx() context.Context { return context.Background() }
|
||||
// readWallet 读取租户钱包余额/冻结(micro → decimal)
|
||||
func readWallet(t *testing.T, tenantID int64) (balance, frozen decimal.Decimal) {
|
||||
t.Helper()
|
||||
b, f, exists, err := readWalletHash(testCtx(), tenantID)
|
||||
b, f, _, _, exists, err := readWalletHash(testCtx(), tenantID)
|
||||
if err != nil {
|
||||
t.Fatalf("read wallet hash: %v", err)
|
||||
}
|
||||
@@ -52,6 +52,22 @@ func readWallet(t *testing.T, tenantID int64) (balance, frozen decimal.Decimal)
|
||||
return FromMicro(b), FromMicro(f)
|
||||
}
|
||||
|
||||
// readWalletConsumed 读取租户累计消费计数(micro → decimal;hash 缺字段按 0)
|
||||
func readWalletConsumed(t *testing.T, tenantID int64) decimal.Decimal {
|
||||
t.Helper()
|
||||
_, _, c, hasConsumed, exists, err := readWalletHash(testCtx(), tenantID)
|
||||
if err != nil {
|
||||
t.Fatalf("read wallet hash: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Fatalf("wallet hash missing for tenant %d", tenantID)
|
||||
}
|
||||
if !hasConsumed {
|
||||
return decimal.Zero
|
||||
}
|
||||
return FromMicro(c)
|
||||
}
|
||||
|
||||
// seedWallet 用加款创建指定余额的钱包 hash(顺带校验 CreditWalletRedis 自身)
|
||||
func seedWallet(t *testing.T, tenantID int64, balance decimal.Decimal) {
|
||||
t.Helper()
|
||||
@@ -249,6 +265,7 @@ func TestWalletConcurrency(t *testing.T) {
|
||||
bal, frozen := readWallet(t, tenant)
|
||||
assertDecimalEqual(t, bal, NewFromFloat(100).Sub(NewFromFloat(0.5).Mul(NewFromFloat(n))), "balance conserved (100 - n*0.5)")
|
||||
assertDecimalEqual(t, frozen, decimal.Zero, "frozen converges to zero")
|
||||
assertDecimalEqual(t, readWalletConsumed(t, tenant), NewFromFloat(0.5).Mul(NewFromFloat(n)), "total_consumed conserved (n*0.5)")
|
||||
}
|
||||
|
||||
// TestPredeductSweep_RecomputesFrozen 孤儿清扫重算:某预扣 hash 丢失(TTL 过期/崩溃残留)后,
|
||||
@@ -297,3 +314,103 @@ func TestCleanupPreDeduct_RemovesHash(t *testing.T) {
|
||||
t.Fatal("prededuct hash should be cleaned")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettleClaim_IncrementsTotalConsumed 累计消费随结算扣款同一原子递增;
|
||||
// 重复结算(预扣已认领、claimed=0 只补扣)时计数仍按实际扣款额累计。
|
||||
func TestSettleClaim_IncrementsTotalConsumed(t *testing.T) {
|
||||
tenant := int64(11)
|
||||
seedWallet(t, tenant, NewFromFloat(10))
|
||||
if ok, _ := PreDeduct(testCtx(), tenant, 4, "req-tc1", "gpt-4o"); !ok {
|
||||
t.Fatal("prededuct failed")
|
||||
}
|
||||
|
||||
// 首次结算:新 hash 无 total_consumed 字段,HINCRBY 从 0 起算(正常路径由三层补种保证含基线)
|
||||
if _, _, _, err := SettleClaim(testCtx(), tenant, NewFromFloat(1.5), []string{"req-tc1"}); err != nil {
|
||||
t.Fatalf("settle claim: %v", err)
|
||||
}
|
||||
assertDecimalEqual(t, readWalletConsumed(t, tenant), NewFromFloat(1.5), "total_consumed after first settle")
|
||||
|
||||
// 重复结算走补扣路径:计数继续累计实际扣款
|
||||
if _, _, _, err := SettleClaim(testCtx(), tenant, NewFromFloat(0.5), []string{"req-tc1"}); err != nil {
|
||||
t.Fatalf("second settle claim: %v", err)
|
||||
}
|
||||
assertDecimalEqual(t, readWalletConsumed(t, tenant), NewFromFloat(2), "total_consumed accumulates supplement")
|
||||
}
|
||||
|
||||
// TestReverseConsumeRedis_ReversesBalanceAndCount 结算补偿逆转:余额与累计消费同一原子回退
|
||||
func TestReverseConsumeRedis_ReversesBalanceAndCount(t *testing.T) {
|
||||
tenant := int64(12)
|
||||
seedWallet(t, tenant, NewFromFloat(10))
|
||||
if ok, _ := PreDeduct(testCtx(), tenant, 4, "req-tc2", "gpt-4o"); !ok {
|
||||
t.Fatal("prededuct failed")
|
||||
}
|
||||
if _, _, _, err := SettleClaim(testCtx(), tenant, NewFromFloat(3), []string{"req-tc2"}); err != nil {
|
||||
t.Fatalf("settle claim: %v", err)
|
||||
}
|
||||
assertDecimalEqual(t, readWalletConsumed(t, tenant), NewFromFloat(3), "total_consumed after settle")
|
||||
|
||||
// 模拟 executeSettlement 第 c 步失败补偿:本单免费,计数与余额同步回退
|
||||
if err := ReverseConsumeRedis(testCtx(), tenant, NewFromFloat(3)); err != nil {
|
||||
t.Fatalf("reverse consume: %v", err)
|
||||
}
|
||||
bal, _ := readWallet(t, tenant)
|
||||
assertDecimalEqual(t, bal, NewFromFloat(10), "balance restored after reverse")
|
||||
assertDecimalEqual(t, readWalletConsumed(t, tenant), decimal.Zero, "total_consumed reversed with balance")
|
||||
}
|
||||
|
||||
// TestNonConsumeOps_DoNotTouchTotalConsumed 回归保护:
|
||||
// 预扣/解冻/扣款(退款调账)/加款(充值)均不得触碰累计消费计数。
|
||||
func TestNonConsumeOps_DoNotTouchTotalConsumed(t *testing.T) {
|
||||
tenant := int64(13)
|
||||
seedWallet(t, tenant, NewFromFloat(10))
|
||||
|
||||
if ok, _ := PreDeduct(testCtx(), tenant, 2, "req-nc1", "gpt-4o"); !ok {
|
||||
t.Fatal("prededuct failed")
|
||||
}
|
||||
assertDecimalEqual(t, readWalletConsumed(t, tenant), decimal.Zero, "prededuct must not touch total_consumed")
|
||||
|
||||
if _, err := UnfreezePreDeduct(testCtx(), tenant, "req-nc1"); err != nil {
|
||||
t.Fatalf("unfreeze: %v", err)
|
||||
}
|
||||
assertDecimalEqual(t, readWalletConsumed(t, tenant), decimal.Zero, "unfreeze must not touch total_consumed")
|
||||
|
||||
if _, _, ok, err := DebitWalletRedis(testCtx(), tenant, NewFromFloat(3)); err != nil || !ok {
|
||||
t.Fatalf("debit failed: ok=%v err=%v", ok, err)
|
||||
}
|
||||
assertDecimalEqual(t, readWalletConsumed(t, tenant), decimal.Zero, "debit (refund/adjust) must not touch total_consumed")
|
||||
|
||||
if _, _, err := CreditWalletRedis(testCtx(), tenant, NewFromFloat(5)); err != nil {
|
||||
t.Fatalf("credit: %v", err)
|
||||
}
|
||||
assertDecimalEqual(t, readWalletConsumed(t, tenant), decimal.Zero, "credit (recharge) must not touch total_consumed")
|
||||
}
|
||||
|
||||
// TestSeedTotalConsumedIfMissing 三层补种之①③层共用 Lua 的行为:
|
||||
// hash 存在且缺字段 → 写入基线;字段已存在 → 不覆盖(幂等);hash 不存在 → no-op。
|
||||
func TestSeedTotalConsumedIfMissing(t *testing.T) {
|
||||
tenant := int64(14)
|
||||
seedWallet(t, tenant, NewFromFloat(10)) // creditLua 创建的 hash 不含 total_consumed 字段
|
||||
|
||||
if err := seedTotalConsumedIfMissing(testCtx(), tenant, ToMicro(NewFromFloat(7.25))); err != nil {
|
||||
t.Fatalf("seed total_consumed: %v", err)
|
||||
}
|
||||
assertDecimalEqual(t, readWalletConsumed(t, tenant), NewFromFloat(7.25), "seeded from db baseline")
|
||||
|
||||
// 字段已存在后发生结算 → 重复种子不得回退结算增量
|
||||
if _, _, _, err := SettleClaim(testCtx(), tenant, NewFromFloat(1), nil); err != nil {
|
||||
t.Fatalf("settle claim: %v", err)
|
||||
}
|
||||
if err := seedTotalConsumedIfMissing(testCtx(), tenant, ToMicro(NewFromFloat(7.25))); err != nil {
|
||||
t.Fatalf("re-seed: %v", err)
|
||||
}
|
||||
assertDecimalEqual(t, readWalletConsumed(t, tenant), NewFromFloat(8.25), "re-seed must not overwrite post-settle value")
|
||||
|
||||
// hash 不存在的租户 → no-op(不得凭空创建 hash,由 rebuild 流程负责)
|
||||
missing := int64(15)
|
||||
if err := seedTotalConsumedIfMissing(testCtx(), missing, ToMicro(NewFromFloat(9))); err != nil {
|
||||
t.Fatalf("seed on missing hash: %v", err)
|
||||
}
|
||||
if _, _, _, hasConsumed, exists, err := readWalletHash(testCtx(), missing); err != nil || exists || hasConsumed {
|
||||
t.Fatalf("seed must not create hash: exists=%v hasConsumed=%v err=%v", exists, hasConsumed, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/shopspring/decimal"
|
||||
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
)
|
||||
|
||||
// seedTotalConsumedLua 补种 Lua:仅当 hash 存在且缺 total_consumed 字段时写入历史基线
|
||||
// (HSET-if-missing 语义,幂等)。
|
||||
// KEYS[1] = wallet:v2:{tenant_id}
|
||||
// ARGV[1] = total_consumed_micro
|
||||
const seedTotalConsumedLua = `
|
||||
if redis.call("EXISTS", KEYS[1]) == 1 and redis.call("HEXISTS", KEYS[1], "total_consumed") == 0 then
|
||||
redis.call("HSET", KEYS[1], "total_consumed", ARGV[1])
|
||||
return 1
|
||||
end
|
||||
return 0
|
||||
`
|
||||
|
||||
// seedTotalConsumedIfMissing 为钱包 hash 补种累计消费基线(hash 不存在或字段已存在均为 no-op)。
|
||||
// 供 boot 种子与物化器自愈共用(三层补种机制的①③层)。
|
||||
func seedTotalConsumedIfMissing(ctx context.Context, tenantID int64, consumedMicro int64) error {
|
||||
_, err := g.Redis().Do(ctx, "EVAL", seedTotalConsumedLua, 1,
|
||||
walletHashKey(tenantID), consumedMicro)
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "seed total_consumed: redis")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// queryTotalConsumed 读 DB 物化副本中的累计消费(钱包行不存在时返回 0——物化 UPDATE
|
||||
// 同样影响 0 行,无需区分)。
|
||||
func queryTotalConsumed(ctx context.Context, tenantID int64) (decimal.Decimal, error) {
|
||||
var w *struct {
|
||||
TotalConsumed decimal.Decimal `json:"total_consumed"`
|
||||
}
|
||||
err := dao.BilWallets.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Fields("total_consumed").
|
||||
Scan(&w)
|
||||
if err != nil {
|
||||
return Zero, gerror.Wrapf(err, "query total_consumed")
|
||||
}
|
||||
if w == nil {
|
||||
return Zero, nil
|
||||
}
|
||||
return w.TotalConsumed, nil
|
||||
}
|
||||
|
||||
// SeedWalletTotalConsumed 启动种子(三层补种之①层):把 DB 物化副本中的累计消费历史基线
|
||||
// 种进已存在的钱包 hash。
|
||||
//
|
||||
// 背景:total_consumed 字段上线时,存量钱包 hash(wallet:v2:*)不含该字段,结算 Lua 的
|
||||
// HINCRBY 会从 0 起算,丢失迁移回填的历史值。本函数在启动时(relay 流量进入前)为所有
|
||||
// total_consumed > 0 的钱包补种,幂等可重跑:
|
||||
// - hash 已含字段(已种过/已发生结算)→ no-op;
|
||||
// - hash 不存在(该租户尚无 Redis 钱包)→ 跳过,由 rebuildWalletFromDB 在首次资金操作时补种(②层);
|
||||
// - 部署后新出现的字段缺失 hash 由物化器自愈兜底(③层)。
|
||||
//
|
||||
// 不 SADD 脏集合——种子值与 DB 相同,无需再物化。
|
||||
// DB/Redis 故障时记 Warning 放行(Redis 故障随后由预扣/结算 fail-closed 机制接管,不阻塞启动)。
|
||||
// 租户量级为千级以内时逐条 EVAL 足够(秒级完成);量级显著增长时可改 pipeline。
|
||||
func SeedWalletTotalConsumed(ctx context.Context) {
|
||||
var rows []struct {
|
||||
TenantId int64 `json:"tenant_id"`
|
||||
TotalConsumed decimal.Decimal `json:"total_consumed"`
|
||||
}
|
||||
err := dao.BilWallets.Ctx(ctx).
|
||||
Fields("tenant_id, total_consumed").
|
||||
Where("total_consumed > 0").
|
||||
Scan(&rows)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[WALLET SEED] query wallets for total_consumed seed failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
seeded := 0
|
||||
for _, row := range rows {
|
||||
if err := seedTotalConsumedIfMissing(ctx, row.TenantId, ToMicro(row.TotalConsumed)); err != nil {
|
||||
g.Log().Warningf(ctx, "[WALLET SEED] seed total_consumed failed: tenant=%d: %v", row.TenantId, err)
|
||||
continue
|
||||
}
|
||||
seeded++
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
g.Log().Infof(ctx, "[WALLET SEED] total_consumed baseline seeded: %d/%d wallets", seeded, len(rows))
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ type AvailableModelItem struct {
|
||||
ModelId string `json:"model_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
Category string `json:"category"`
|
||||
Vendor string `json:"vendor"`
|
||||
MaxContextTokens int `json:"max_context_tokens"`
|
||||
MaxOutputTokens int `json:"max_output_tokens"`
|
||||
Description string `json:"description"`
|
||||
@@ -24,13 +25,14 @@ type AvailableModelItem struct {
|
||||
|
||||
// GetTenantAvailableModels 计算租户实际可用的所有模型(显式分配 + 分组来源,去重)
|
||||
// 返回模型基本信息列表,不含价格。可被管理后台和租户控制台共用。
|
||||
func GetTenantAvailableModels(ctx context.Context, tenantID int64, category, search string) ([]AvailableModelItem, error) {
|
||||
func GetTenantAvailableModels(ctx context.Context, tenantID int64, category, vendor, search string) ([]AvailableModelItem, error) {
|
||||
// 阶段1:获取显式分配的模型
|
||||
var explicitModels []struct {
|
||||
ModelDBID int64 `json:"model_db_id"`
|
||||
ModelId string `json:"model_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
Category string `json:"category"`
|
||||
Vendor string `json:"vendor"`
|
||||
MaxContextTokens int `json:"max_context_tokens"`
|
||||
MaxOutputTokens int `json:"max_output_tokens"`
|
||||
Description string `json:"description"`
|
||||
@@ -47,12 +49,15 @@ func GetTenantAvailableModels(ctx context.Context, tenantID int64, category, sea
|
||||
if category != "" {
|
||||
explicitQuery = explicitQuery.Where("m.category", category)
|
||||
}
|
||||
if vendor != "" {
|
||||
explicitQuery = explicitQuery.Where("m.vendor", vendor)
|
||||
}
|
||||
if search != "" {
|
||||
explicitQuery = explicitQuery.Where("m.model_id LIKE ? OR m.model_name LIKE ?", "%"+search+"%", "%"+search+"%")
|
||||
}
|
||||
|
||||
err := explicitQuery.
|
||||
Fields("mdl_tenant_models.model_id AS model_db_id, m.model_id, m.model_name, m.category, m.max_context_tokens, m.max_output_tokens, m.description, m.tags, m.capabilities").
|
||||
Fields("mdl_tenant_models.model_id AS model_db_id, m.model_id, m.model_name, m.category, m.vendor, m.max_context_tokens, m.max_output_tokens, m.description, m.tags, m.capabilities").
|
||||
OrderAsc("m.category").
|
||||
OrderAsc("m.model_id").
|
||||
Scan(&explicitModels)
|
||||
@@ -69,6 +74,7 @@ func GetTenantAvailableModels(ctx context.Context, tenantID int64, category, sea
|
||||
ModelId: r.ModelId,
|
||||
ModelName: r.ModelName,
|
||||
Category: r.Category,
|
||||
Vendor: r.Vendor,
|
||||
MaxContextTokens: r.MaxContextTokens,
|
||||
MaxOutputTokens: r.MaxOutputTokens,
|
||||
Description: r.Description,
|
||||
@@ -84,6 +90,7 @@ func GetTenantAvailableModels(ctx context.Context, tenantID int64, category, sea
|
||||
ModelId string `json:"model_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
Category string `json:"category"`
|
||||
Vendor string `json:"vendor"`
|
||||
MaxContextTokens int `json:"max_context_tokens"`
|
||||
MaxOutputTokens int `json:"max_output_tokens"`
|
||||
Description string `json:"description"`
|
||||
@@ -110,12 +117,15 @@ func GetTenantAvailableModels(ctx context.Context, tenantID int64, category, sea
|
||||
if category != "" {
|
||||
groupQuery = groupQuery.Where("m.category", category)
|
||||
}
|
||||
if vendor != "" {
|
||||
groupQuery = groupQuery.Where("m.vendor", vendor)
|
||||
}
|
||||
if search != "" {
|
||||
groupQuery = groupQuery.Where("m.model_id LIKE ? OR m.model_name LIKE ?", "%"+search+"%", "%"+search+"%")
|
||||
}
|
||||
|
||||
_ = groupQuery.
|
||||
Fields("DISTINCT m.id AS model_db_id, m.model_id, m.model_name, m.category, m.max_context_tokens, m.max_output_tokens, m.description, m.tags, m.capabilities").
|
||||
Fields("DISTINCT m.id AS model_db_id, m.model_id, m.model_name, m.category, m.vendor, m.max_context_tokens, m.max_output_tokens, m.description, m.tags, m.capabilities").
|
||||
OrderAsc("m.category").
|
||||
OrderAsc("m.model_id").
|
||||
Scan(&groupModels)
|
||||
@@ -126,6 +136,7 @@ func GetTenantAvailableModels(ctx context.Context, tenantID int64, category, sea
|
||||
ModelId: gm.ModelId,
|
||||
ModelName: gm.ModelName,
|
||||
Category: gm.Category,
|
||||
Vendor: gm.Vendor,
|
||||
MaxContextTokens: gm.MaxContextTokens,
|
||||
MaxOutputTokens: gm.MaxOutputTokens,
|
||||
Description: gm.Description,
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
@@ -41,24 +42,32 @@ func (s *sTenant) GetModelList(ctx context.Context, req *v1.MarketplaceListReq)
|
||||
Total: 0,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
Vendors: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 2. 构建查询:通过分组关联查询模型
|
||||
m := dao.MdlModels.Ctx(ctx).
|
||||
LeftJoin("mdl_group_models", "mdl_group_models.model_id = mdl_models.id").
|
||||
Where("mdl_group_models.group_id", defaultGroup.Id).
|
||||
Where("mdl_models.status", "active")
|
||||
|
||||
// 搜索关键词
|
||||
if req.Keyword != "" {
|
||||
keyword := "%" + req.Keyword + "%"
|
||||
m = m.Where("(mdl_models.model_id LIKE ? OR mdl_models.model_name LIKE ? OR mdl_models.description LIKE ?)", keyword, keyword, keyword)
|
||||
// 2. 构建查询:通过分组关联查询模型(厂商 facet 与主列表共用同一套基础条件,
|
||||
// 用闭包各自构建链路,避免 gf Model 链式分支共享底层状态的歧义)
|
||||
newQuery := func() *gdb.Model {
|
||||
q := dao.MdlModels.Ctx(ctx).
|
||||
LeftJoin("mdl_group_models", "mdl_group_models.model_id = mdl_models.id").
|
||||
Where("mdl_group_models.group_id", defaultGroup.Id).
|
||||
Where("mdl_models.status", "active")
|
||||
if req.Keyword != "" {
|
||||
keyword := "%" + req.Keyword + "%"
|
||||
q = q.Where("(mdl_models.model_id LIKE ? OR mdl_models.model_name LIKE ? OR mdl_models.description LIKE ?)", keyword, keyword, keyword)
|
||||
}
|
||||
if req.Category != "" {
|
||||
q = q.Where("mdl_models.category", req.Category)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// 分类筛选
|
||||
if req.Category != "" {
|
||||
m = m.Where("mdl_models.category", req.Category)
|
||||
m := newQuery()
|
||||
|
||||
// 厂商筛选
|
||||
if req.Vendor != "" {
|
||||
m = m.Where("mdl_models.vendor", req.Vendor)
|
||||
}
|
||||
|
||||
// 排序:按类别、模型名称
|
||||
@@ -70,6 +79,23 @@ func (s *sTenant) GetModelList(ctx context.Context, req *v1.MarketplaceListReq)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3.1 厂商 facet:在当前关键词/分类条件下(忽略厂商筛选本身)有模型的厂商去重列表,
|
||||
// 前端据此只渲染实际有模型的厂商筛选项,保证点任一厂商都有结果
|
||||
var vendorRows []struct {
|
||||
Vendor string `json:"vendor"`
|
||||
}
|
||||
err = newQuery().
|
||||
Where("mdl_models.vendor <> ''").
|
||||
Fields("DISTINCT mdl_models.vendor").
|
||||
Scan(&vendorRows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vendors := make([]string, 0, len(vendorRows))
|
||||
for _, r := range vendorRows {
|
||||
vendors = append(vendors, r.Vendor)
|
||||
}
|
||||
|
||||
// 4. 分页查询模型
|
||||
var models []*entity.MdlModels
|
||||
err = m.Fields("mdl_models.*").
|
||||
@@ -86,6 +112,7 @@ func (s *sTenant) GetModelList(ctx context.Context, req *v1.MarketplaceListReq)
|
||||
Total: 0,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
Vendors: vendors,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -120,6 +147,7 @@ func (s *sTenant) GetModelList(ctx context.Context, req *v1.MarketplaceListReq)
|
||||
Total: total,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
Vendors: vendors,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -177,6 +205,7 @@ func (s *sTenant) convertToMarketplaceItem(ctx context.Context, model *entity.Md
|
||||
ModelId: model.ModelId,
|
||||
ModelName: model.ModelName,
|
||||
Category: model.Category,
|
||||
Vendor: model.Vendor,
|
||||
Description: model.Description,
|
||||
MaxContextTokens: model.MaxContextTokens,
|
||||
MaxOutputTokens: model.MaxOutputTokens,
|
||||
|
||||
@@ -139,7 +139,7 @@ func fillFromBlob(blob *billing.PricingBlob) (input, output, cacheRead, cacheCre
|
||||
func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailableModelsReq) (*v1.TenantAvailableModelsRes, error) {
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
|
||||
models, err := lcommon.GetTenantAvailableModels(ctx, tenantID, req.Category, req.Search)
|
||||
models, err := lcommon.GetTenantAvailableModels(ctx, tenantID, req.Category, req.Vendor, req.Search)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -282,6 +282,7 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
|
||||
ModelId: m.ModelId,
|
||||
ModelName: m.ModelName,
|
||||
Category: m.Category,
|
||||
Vendor: m.Vendor,
|
||||
MaxContext: m.MaxContextTokens,
|
||||
MaxOutput: m.MaxOutputTokens,
|
||||
Description: m.Description,
|
||||
@@ -339,6 +340,7 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
|
||||
ModelId: m.ModelId,
|
||||
ModelName: m.ModelName,
|
||||
Category: m.Category,
|
||||
Vendor: m.Vendor,
|
||||
MaxContext: m.MaxContextTokens,
|
||||
MaxOutput: m.MaxOutputTokens,
|
||||
Description: m.Description,
|
||||
|
||||
@@ -23,4 +23,5 @@ type BilWallets struct {
|
||||
UpdatedAt *gtime.Time // 更新时间
|
||||
CumulativeRecharge any // 累计充值总额(USD)
|
||||
LowBalanceNotified any // 低余额预警是否已推送(充值恢复后重置为 false)
|
||||
TotalConsumed any // 累计消费总额(本位币;Redis 钱包 hash total_consumed 的物化副本,随结算事件递增/补偿递减)
|
||||
}
|
||||
@@ -27,4 +27,5 @@ type MdlModels struct {
|
||||
DeprecatedAt *gtime.Time // 标记弃用的时间(NULL表示未弃用)
|
||||
SunsetDate *gtime.Time // 计划下线日期(到达后返回410 Gone,NULL表示未设置)
|
||||
ReplacementModel any // 推荐替代模型名(NULL表示无替代)
|
||||
Vendor any // 研发厂商枚举:openai/anthropic/google/xai/mistral/cohere/meta/alibaba/bytedance/deepseek/zhipu/moonshot/minimax/baidu/tencent/xunfei/kuaishou/midjourney/suno,空串=未分类
|
||||
}
|
||||
@@ -21,4 +21,5 @@ type MdlPricing struct {
|
||||
DiscountLabel any // 折扣标签(对外展示,如"7折起"、"限时5折"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
|
||||
PriceChangeNote any // 价格调整说明(对外展示,提示用户价格有变动,如"9月1日起输入价下调"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
|
||||
Pricing any // 计费详情(JSONB,按 billing_mode 单模式存储):token={input_price,output_price,cache_read_price,cache_creation_price};tiered={tiers:[{min_tokens,max_tokens,input_price,output_price}],cache_read_price,cache_creation_price};per_request={price};per_second={unit:"second",prices:{分辨率规格:每秒单价,"*":兜底价}};顶层可含 time_segments(时段定价)
|
||||
OfficialPricing any // 官方参考定价 JSONB(结构与 pricing 一致 + billing_mode 键,本位币;仅作定价折扣计算基准与几折展示,非计费依据)
|
||||
}
|
||||
@@ -31,4 +31,6 @@ type TntTenants struct {
|
||||
DefaultChannelScope any // 默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组)
|
||||
Level any // 当前等级(对应 tnt_tenant_level_configs.level)
|
||||
TeamEnabled any // 团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度)
|
||||
Tags any // 租户标签(JSONB 字符串数组,管理后台运营标注,不暴露给租户控制台)
|
||||
Remark any // 管理员备注(运营备注,不暴露给租户控制台)
|
||||
}
|
||||
@@ -11,14 +11,15 @@ import (
|
||||
|
||||
// BilWallets is the golang structure for table bil_wallets.
|
||||
type BilWallets struct {
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID(每个租户一个钱包)"` // 租户ID(每个租户一个钱包)
|
||||
Balance decimal.Decimal `json:"balance" orm:"balance" description:"总余额"` // 总余额
|
||||
FrozenBalance decimal.Decimal `json:"frozen_balance" orm:"frozen_balance" description:"冻结余额(支付中/退款中,可用余额 = balance - frozen_balance)"` // 冻结余额(支付中/退款中,可用余额 = balance - frozen_balance)
|
||||
WarningThreshold *decimal.Decimal `json:"warning_threshold" orm:"warning_threshold" description:"余额预警线(低于此值触发通知)"` // 余额预警线(低于此值触发通知)
|
||||
Currency string `json:"currency" orm:"currency" description:"货币(USD)"` // 货币(USD)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
CumulativeRecharge decimal.Decimal `json:"cumulative_recharge" orm:"cumulative_recharge" description:"累计充值总额(USD)"` // 累计充值总额(USD)
|
||||
LowBalanceNotified bool `json:"low_balance_notified" orm:"low_balance_notified" description:"低余额预警是否已推送(充值恢复后重置为 false)"` // 低余额预警是否已推送(充值恢复后重置为 false)
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID(每个租户一个钱包)"` // 租户ID(每个租户一个钱包)
|
||||
Balance decimal.Decimal `json:"balance" orm:"balance" description:"总余额"` // 总余额
|
||||
FrozenBalance decimal.Decimal `json:"frozen_balance" orm:"frozen_balance" description:"冻结余额(支付中/退款中,可用余额 = balance - frozen_balance)"` // 冻结余额(支付中/退款中,可用余额 = balance - frozen_balance)
|
||||
WarningThreshold *decimal.Decimal `json:"warning_threshold" orm:"warning_threshold" description:"余额预警线(低于此值触发通知)"` // 余额预警线(低于此值触发通知)
|
||||
Currency string `json:"currency" orm:"currency" description:"货币(USD)"` // 货币(USD)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
CumulativeRecharge decimal.Decimal `json:"cumulative_recharge" orm:"cumulative_recharge" description:"累计充值总额(USD)"` // 累计充值总额(USD)
|
||||
LowBalanceNotified bool `json:"low_balance_notified" orm:"low_balance_notified" description:"低余额预警是否已推送(充值恢复后重置为 false)"` // 低余额预警是否已推送(充值恢复后重置为 false)
|
||||
TotalConsumed decimal.Decimal `json:"total_consumed" orm:"total_consumed" description:"累计消费总额(本位币;Redis 钱包 hash total_consumed 的物化副本,随结算事件递增/补偿递减)"` // 累计消费总额(本位币;Redis 钱包 hash total_consumed 的物化副本,随结算事件递增/补偿递减)
|
||||
}
|
||||
@@ -10,19 +10,20 @@ import (
|
||||
|
||||
// MdlModels is the golang structure for table mdl_models.
|
||||
type MdlModels struct {
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
ModelId string `json:"model_id" orm:"model_id" description:"模型唯一标识(如 gpt-4o、claude-3-5-sonnet)"` // 模型唯一标识(如 gpt-4o、claude-3-5-sonnet)
|
||||
ModelName string `json:"model_name" orm:"model_name" description:"模型显示名称(如 GPT-4o、Claude 3.5 Sonnet)"` // 模型显示名称(如 GPT-4o、Claude 3.5 Sonnet)
|
||||
Category string `json:"category" orm:"category" description:"模型分类:chat(对话)/ embedding(嵌入)/ image(图像)/ audio(音频)/ rerank(重排序)"` // 模型分类:chat(对话)/ embedding(嵌入)/ image(图像)/ audio(音频)/ rerank(重排序)
|
||||
Status string `json:"status" orm:"status" description:"状态:active(可用)/ deprecated(已废弃)/ offline(已下线)"` // 状态:active(可用)/ deprecated(已废弃)/ offline(已下线)
|
||||
MaxContextTokens int `json:"max_context_tokens" orm:"max_context_tokens" description:"最大上下文 token 数"` // 最大上下文 token 数
|
||||
MaxOutputTokens int `json:"max_output_tokens" orm:"max_output_tokens" description:"最大输出 token 数"` // 最大输出 token 数
|
||||
Description string `json:"description" orm:"description" description:"模型描述"` // 模型描述
|
||||
Tags []string `json:"tags" orm:"tags" description:"标签(如 reasoning、vision、function_calling)"` // 标签(如 reasoning、vision、function_calling)
|
||||
Capabilities string `json:"capabilities" orm:"capabilities" description:"模型能力特性(如 vision、function_calling、reasoning 等)"` // 模型能力特性(如 vision、function_calling、reasoning 等)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
DeprecatedAt *gtime.Time `json:"deprecated_at" orm:"deprecated_at" description:"标记弃用的时间(NULL表示未弃用)"` // 标记弃用的时间(NULL表示未弃用)
|
||||
SunsetDate *gtime.Time `json:"sunset_date" orm:"sunset_date" description:"计划下线日期(到达后返回410 Gone,NULL表示未设置)"` // 计划下线日期(到达后返回410 Gone,NULL表示未设置)
|
||||
ReplacementModel string `json:"replacement_model" orm:"replacement_model" description:"推荐替代模型名(NULL表示无替代)"` // 推荐替代模型名(NULL表示无替代)
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
ModelId string `json:"model_id" orm:"model_id" description:"模型唯一标识(如 gpt-4o、claude-3-5-sonnet)"` // 模型唯一标识(如 gpt-4o、claude-3-5-sonnet)
|
||||
ModelName string `json:"model_name" orm:"model_name" description:"模型显示名称(如 GPT-4o、Claude 3.5 Sonnet)"` // 模型显示名称(如 GPT-4o、Claude 3.5 Sonnet)
|
||||
Category string `json:"category" orm:"category" description:"模型分类:chat(对话)/ embedding(嵌入)/ image(图像)/ audio(音频)/ rerank(重排序)"` // 模型分类:chat(对话)/ embedding(嵌入)/ image(图像)/ audio(音频)/ rerank(重排序)
|
||||
Status string `json:"status" orm:"status" description:"状态:active(可用)/ deprecated(已废弃)/ offline(已下线)"` // 状态:active(可用)/ deprecated(已废弃)/ offline(已下线)
|
||||
MaxContextTokens int `json:"max_context_tokens" orm:"max_context_tokens" description:"最大上下文 token 数"` // 最大上下文 token 数
|
||||
MaxOutputTokens int `json:"max_output_tokens" orm:"max_output_tokens" description:"最大输出 token 数"` // 最大输出 token 数
|
||||
Description string `json:"description" orm:"description" description:"模型描述"` // 模型描述
|
||||
Tags []string `json:"tags" orm:"tags" description:"标签(如 reasoning、vision、function_calling)"` // 标签(如 reasoning、vision、function_calling)
|
||||
Capabilities string `json:"capabilities" orm:"capabilities" description:"模型能力特性(如 vision、function_calling、reasoning 等)"` // 模型能力特性(如 vision、function_calling、reasoning 等)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
DeprecatedAt *gtime.Time `json:"deprecated_at" orm:"deprecated_at" description:"标记弃用的时间(NULL表示未弃用)"` // 标记弃用的时间(NULL表示未弃用)
|
||||
SunsetDate *gtime.Time `json:"sunset_date" orm:"sunset_date" description:"计划下线日期(到达后返回410 Gone,NULL表示未设置)"` // 计划下线日期(到达后返回410 Gone,NULL表示未设置)
|
||||
ReplacementModel string `json:"replacement_model" orm:"replacement_model" description:"推荐替代模型名(NULL表示无替代)"` // 推荐替代模型名(NULL表示无替代)
|
||||
Vendor string `json:"vendor" orm:"vendor" description:"研发厂商枚举:openai/anthropic/google/xai/mistral/cohere/meta/alibaba/bytedance/deepseek/zhipu/moonshot/minimax/baidu/tencent/xunfei/kuaishou/midjourney/suno,空串=未分类"` // 研发厂商枚举:openai/anthropic/google/xai/mistral/cohere/meta/alibaba/bytedance/deepseek/zhipu/moonshot/minimax/baidu/tencent/xunfei/kuaishou/midjourney/suno,空串=未分类
|
||||
}
|
||||
@@ -19,4 +19,5 @@ type MdlPricing struct {
|
||||
DiscountLabel string `json:"discount_label" orm:"discount_label" description:"折扣标签(对外展示,如\"7折起\"、\"限时5折\"),仅 min_tokens=0 锚点行使用,NULL/空=不展示"` // 折扣标签(对外展示,如"7折起"、"限时5折"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
|
||||
PriceChangeNote string `json:"price_change_note" orm:"price_change_note" description:"价格调整说明(对外展示,提示用户价格有变动,如\"9月1日起输入价下调\"),仅 min_tokens=0 锚点行使用,NULL/空=不展示"` // 价格调整说明(对外展示,提示用户价格有变动,如"9月1日起输入价下调"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
|
||||
Pricing string `json:"pricing" orm:"pricing" description:"计费详情(JSONB,按 billing_mode 单模式存储):token={input_price,output_price,cache_read_price,cache_creation_price};tiered={tiers:[{min_tokens,max_tokens,input_price,output_price}],cache_read_price,cache_creation_price};per_request={price};per_second={unit:\"second\",prices:{分辨率规格:每秒单价,\"*\":兜底价}};顶层可含 time_segments(时段定价)"` // 计费详情(JSONB,按 billing_mode 单模式存储):token={input_price,output_price,cache_read_price,cache_creation_price};tiered={tiers:[{min_tokens,max_tokens,input_price,output_price}],cache_read_price,cache_creation_price};per_request={price};per_second={unit:"second",prices:{分辨率规格:每秒单价,"*":兜底价}};顶层可含 time_segments(时段定价)
|
||||
OfficialPricing string `json:"official_pricing" orm:"official_pricing" description:"官方参考定价 JSONB(结构与 pricing 一致 + billing_mode 键,本位币;仅作定价折扣计算基准与几折展示,非计费依据)"` // 官方参考定价 JSONB(结构与 pricing 一致 + billing_mode 键,本位币;仅作定价折扣计算基准与几折展示,非计费依据)
|
||||
}
|
||||
@@ -29,4 +29,6 @@ type TntTenants struct {
|
||||
DefaultChannelScope string `json:"default_channel_scope" orm:"default_channel_scope" description:"默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组)"` // 默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组)
|
||||
Level int `json:"level" orm:"level" description:"当前等级(对应 tnt_tenant_level_configs.level)"` // 当前等级(对应 tnt_tenant_level_configs.level)
|
||||
TeamEnabled bool `json:"team_enabled" orm:"team_enabled" description:"团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度)"` // 团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度)
|
||||
Tags string `json:"tags" orm:"tags" description:"租户标签(JSONB 字符串数组,管理后台运营标注,不暴露给租户控制台)"` // 租户标签(JSONB 字符串数组,管理后台运营标注,不暴露给租户控制台)
|
||||
Remark string `json:"remark" orm:"remark" description:"管理员备注(运营备注,不暴露给租户控制台)"` // 管理员备注(运营备注,不暴露给租户控制台)
|
||||
}
|
||||
@@ -24,6 +24,9 @@ type (
|
||||
// UpdateUserStatus enables or disables an admin user.
|
||||
UpdateUserStatus(ctx context.Context, req *v1.AdminUserUpdateStatusReq) (*v1.AdminUserUpdateStatusRes, error)
|
||||
// UnlockUser 清除管理员的登录锁定状态(重置失败计数与锁定截止时间)。
|
||||
//
|
||||
// 解锁是本文件里唯一漏掉 assertCanManageAdminUser 的账号处置动作:锁定本身是暴力破解
|
||||
// 的防线,非超管若能解锁超级管理员账号,就等于可以无限次重置对方的失败计数继续爆破。
|
||||
UnlockUser(ctx context.Context, req *v1.AdminUserUnlockReq) (*v1.AdminUserUnlockRes, error)
|
||||
// ResetUserPassword resets an admin user's password.
|
||||
ResetUserPassword(ctx context.Context, req *v1.AdminUserResetPasswordReq) (*v1.AdminUserResetPasswordRes, error)
|
||||
@@ -329,7 +332,7 @@ type (
|
||||
UpdateModel(ctx context.Context, req *v1.ModelUpdateReq) (*v1.ModelUpdateRes, error)
|
||||
// DeleteModel 删除模型(同时删除定价记录、租户分配记录和分组关联)
|
||||
DeleteModel(ctx context.Context, req *v1.ModelDeleteReq) (*v1.ModelDeleteRes, error)
|
||||
// GetModelPricing 获取模型定价
|
||||
// GetModelPricing 获取模型定价(pricing JSONB 单行还原为 PricingItem 列表,响应结构与旧多行结构保持兼容)
|
||||
GetModelPricing(ctx context.Context, req *v1.PricingGetReq) (*v1.PricingGetRes, error)
|
||||
// SetModelPricing 设置模型定价(全量替换)
|
||||
SetModelPricing(ctx context.Context, req *v1.PricingSetReq) (*v1.PricingSetRes, error)
|
||||
|
||||
@@ -155,6 +155,10 @@ type (
|
||||
// UpdateMemberRole updates a member's role.
|
||||
UpdateMemberRole(ctx context.Context, req *v1.TenantMemberUpdateRoleReq) (*v1.TenantMemberUpdateRoleRes, error)
|
||||
// ResetMemberPassword resets a member's password. Only admins can reset other members' passwords.
|
||||
//
|
||||
// 角色校验此前缺失:只挡了「不能重置自己」和「不能重置 owner」,任意 member 都能改掉
|
||||
// 同组织 admin 的密码并登录该账号,等于组织内横向提权。与相邻的 UnlockMember /
|
||||
// RemoveMember / UpdateMemberRole 保持同一道闸门。
|
||||
ResetMemberPassword(ctx context.Context, req *v1.TenantMemberResetPasswordReq) (*v1.TenantMemberResetPasswordRes, error)
|
||||
// UnlockMember 解除成员登录锁定。仅 owner/admin 可操作;租户隔离双键校验。
|
||||
UnlockMember(ctx context.Context, req *v1.TenantMemberUnlockReq) (*v1.TenantMemberUnlockRes, error)
|
||||
@@ -168,6 +172,9 @@ type (
|
||||
// 导出列与成员列表页表格保持一致(用户/角色/状态/额度限制/可用模型/本月消费/加入时间/最后更新)。
|
||||
ExportMembers(ctx context.Context, req *v1.TenantMemberExportReq) (*v1.TenantMemberExportRes, error)
|
||||
// MemberImport parses CSV content, validates, creates an import record.
|
||||
//
|
||||
// 角色校验此前缺失:批量导入会直接创建租户成员账号(并按套餐占用席位),
|
||||
// 与 MemberCreate / MemberInvite 是同一类动作,必须同为 owner/admin。
|
||||
MemberImport(ctx context.Context, req *v1.TenantMemberImportReq) (*v1.TenantMemberImportRes, error)
|
||||
// ImportRecords returns a paginated list of import records.
|
||||
ImportRecords(ctx context.Context, req *v1.TenantImportRecordsReq) (*v1.TenantImportRecordsRes, error)
|
||||
@@ -277,6 +284,9 @@ type (
|
||||
// ProjectArchive archives a project and revokes all its keys.
|
||||
ProjectArchive(ctx context.Context, req *v1.TenantProjectArchiveReq) (*v1.TenantProjectArchiveRes, error)
|
||||
// ProjectUnarchive restores an archived project. Keys are NOT auto-restored.
|
||||
//
|
||||
// 角色校验此前缺失:Create/Update/Archive 都要求 owner/admin,唯独恢复归档没拦,
|
||||
// 任意 member 都能把管理员刚归档的项目恢复回来(归档是治理动作,撤销它同样是)。
|
||||
ProjectUnarchive(ctx context.Context, req *v1.TenantProjectUnarchiveReq) (*v1.TenantProjectUnarchiveRes, error)
|
||||
// ProjectGet 根据 ID 获取单个项目详情(含统计摘要)
|
||||
ProjectGet(ctx context.Context, req *v1.TenantProjectGetReq) (*v1.TenantProjectGetRes, error)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//go:build integration
|
||||
|
||||
package admin_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/qianfree/team-api/tests/integration/admin/testinfra"
|
||||
)
|
||||
|
||||
// TestTenantTagsAndRemark 租户标签/备注管理:更新 → 列表/详情可读 → 标签筛选 → 清空。
|
||||
// 运行前置:服务已部署包含 000025 迁移(tnt_tenants.tags/remark 列)。
|
||||
func TestTenantTagsAndRemark(t *testing.T) {
|
||||
client := testinfra.GetAuthedClient(t)
|
||||
|
||||
// 取一个真实租户:列表第一条
|
||||
listResp := client.Get("/api/admin/tenants", map[string]string{"page": "1", "page_size": "1"})
|
||||
listResp.AssertSuccess(t)
|
||||
var listData struct {
|
||||
List []struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"list"`
|
||||
}
|
||||
listResp.DecodeData(t, &listData)
|
||||
if len(listData.List) == 0 {
|
||||
t.Skip("无租户数据,跳过标签/备注集成测试")
|
||||
}
|
||||
tenantID := listData.List[0].ID
|
||||
|
||||
marker := fmt.Sprintf("it-tag-%d", tenantID)
|
||||
|
||||
// 1. 更新标签与备注
|
||||
updateResp := client.Put(fmt.Sprintf("/api/admin/tenants/%d", tenantID), map[string]any{
|
||||
"tags": []string{marker, "集成测试"},
|
||||
"remark": "集成测试备注",
|
||||
})
|
||||
updateResp.AssertSuccess(t)
|
||||
|
||||
// 2. 详情可读
|
||||
getResp := client.Get(fmt.Sprintf("/api/admin/tenants/%d", tenantID), nil)
|
||||
getResp.AssertSuccess(t)
|
||||
var detail struct {
|
||||
Tags []string `json:"tags"`
|
||||
Remark string `json:"remark"`
|
||||
TotalConsumed string `json:"total_consumed"`
|
||||
}
|
||||
getResp.DecodeData(t, &detail)
|
||||
if len(detail.Tags) != 2 || detail.Tags[0] != marker {
|
||||
t.Fatalf("tags = %v, want [%s 集成测试]", detail.Tags, marker)
|
||||
}
|
||||
if detail.Remark != "集成测试备注" {
|
||||
t.Fatalf("remark = %q", detail.Remark)
|
||||
}
|
||||
if detail.TotalConsumed == "" {
|
||||
t.Fatal("total_consumed 应返回 decimal 字符串(无消费时为 \"0\")")
|
||||
}
|
||||
|
||||
// 3. 标签精确筛选能命中
|
||||
filterResp := client.Get("/api/admin/tenants", map[string]string{
|
||||
"page": "1", "page_size": "20", "tag": marker,
|
||||
})
|
||||
filterResp.AssertSuccess(t)
|
||||
var filterData struct {
|
||||
List []struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"list"`
|
||||
}
|
||||
filterResp.DecodeData(t, &filterData)
|
||||
if len(filterData.List) != 1 || filterData.List[0].ID != tenantID {
|
||||
t.Fatalf("tag filter should hit tenant %d exactly, got %v", tenantID, filterData.List)
|
||||
}
|
||||
|
||||
// 4. 标签数量超限被拒(11 个)
|
||||
tooMany := make([]string, 11)
|
||||
for i := range tooMany {
|
||||
tooMany[i] = fmt.Sprintf("tag-%d", i)
|
||||
}
|
||||
badResp := client.Put(fmt.Sprintf("/api/admin/tenants/%d", tenantID), map[string]any{
|
||||
"tags": tooMany,
|
||||
})
|
||||
if badResp.Code == 0 {
|
||||
t.Fatal("11 个标签应被拒绝")
|
||||
}
|
||||
|
||||
// 5. 清空标签/备注(空数组语义)
|
||||
clearResp := client.Put(fmt.Sprintf("/api/admin/tenants/%d", tenantID), map[string]any{
|
||||
"tags": []string{}, "remark": "",
|
||||
})
|
||||
clearResp.AssertSuccess(t)
|
||||
getResp2 := client.Get(fmt.Sprintf("/api/admin/tenants/%d", tenantID), nil)
|
||||
getResp2.AssertSuccess(t)
|
||||
var detail2 struct {
|
||||
Tags []string `json:"tags"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
getResp2.DecodeData(t, &detail2)
|
||||
if len(detail2.Tags) != 0 || detail2.Remark != "" {
|
||||
t.Fatalf("清空后 tags=%v remark=%q", detail2.Tags, detail2.Remark)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import request from '@/utils/request'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
import { vendorOptions, vendorLabelMap, vendorTagColor, filterVendorOption } from '@/constants/vendor'
|
||||
|
||||
const loading = ref(false)
|
||||
const data = ref<any[]>([])
|
||||
@@ -23,6 +24,7 @@ const pagination = reactive({
|
||||
})
|
||||
|
||||
const filterCategory = ref<string | null>(null)
|
||||
const filterVendor = ref<string | null>(null)
|
||||
const filterStatus = ref<string | null>(null)
|
||||
const filterSearch = ref('')
|
||||
const filterPricingStatus = ref<string | null>(null)
|
||||
@@ -48,6 +50,8 @@ const categoryOptions = [
|
||||
{ label: '重排', value: 'rerank' },
|
||||
]
|
||||
|
||||
const vendorFilterOptions = [{ label: '全部厂商', value: '' }, ...vendorOptions]
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '启用', value: 'active' },
|
||||
@@ -66,6 +70,7 @@ const form = reactive({
|
||||
model_id: '',
|
||||
model_name: '',
|
||||
category: 'chat',
|
||||
vendor: '',
|
||||
max_context_tokens: null as number | null,
|
||||
max_output_tokens: null as number | null,
|
||||
capabilities: {} as Record<string, boolean>,
|
||||
@@ -213,6 +218,15 @@ const columns: TableColumnData[] = [
|
||||
return h(Tag, { color: categoryTagColor[record.category], size: 'small' }, () => categoryTagLabel[record.category] || record.category)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '厂商',
|
||||
dataIndex: 'vendor',
|
||||
width: 100,
|
||||
render({ record }) {
|
||||
if (!record.vendor) return h('span', { style: 'color: var(--color-text-4);' }, '—')
|
||||
return h(Tag, { color: vendorTagColor[record.vendor] || 'gray', size: 'small' }, () => vendorLabelMap[record.vendor] || record.vendor)
|
||||
},
|
||||
},
|
||||
{ title: '上下文', dataIndex: 'max_context_tokens', width: 100 },
|
||||
{ title: '输出上限', dataIndex: 'max_output_tokens', width: 100 },
|
||||
{
|
||||
@@ -313,6 +327,7 @@ async function fetchData() {
|
||||
page_size: pagination.pageSize,
|
||||
}
|
||||
if (filterCategory.value) params.category = filterCategory.value
|
||||
if (filterVendor.value) params.vendor = filterVendor.value
|
||||
if (filterStatus.value) params.status = filterStatus.value
|
||||
if (filterSearch.value) params.search = filterSearch.value
|
||||
if (filterPricingStatus.value) params.pricing_status = filterPricingStatus.value
|
||||
@@ -340,6 +355,7 @@ function openCreate() {
|
||||
form.model_id = ''
|
||||
form.model_name = ''
|
||||
form.category = 'chat'
|
||||
form.vendor = ''
|
||||
form.max_context_tokens = null
|
||||
form.max_output_tokens = null
|
||||
form.capabilities = {}
|
||||
@@ -358,6 +374,7 @@ function openEdit(row: any) {
|
||||
form.model_id = row.model_id
|
||||
form.model_name = row.model_name || ''
|
||||
form.category = row.category
|
||||
form.vendor = row.vendor || ''
|
||||
form.max_context_tokens = row.max_context_tokens || null
|
||||
form.max_output_tokens = row.max_output_tokens || null
|
||||
form.capabilities = row.capabilities || {}
|
||||
@@ -428,6 +445,7 @@ const { exporting, exportFile } = useExport({
|
||||
url: '/admin/models/export',
|
||||
getFilters: () => ({
|
||||
category: filterCategory.value,
|
||||
vendor: filterVendor.value,
|
||||
status: filterStatus.value,
|
||||
search: filterSearch.value,
|
||||
}),
|
||||
@@ -511,6 +529,15 @@ const importColumns: TableColumnData[] = [
|
||||
return h(Tag, { color: categoryTagColor[record.category], size: 'small' }, () => categoryTagLabel[record.category] || record.category)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '厂商',
|
||||
dataIndex: 'vendor',
|
||||
width: 90,
|
||||
render({ record }) {
|
||||
if (!record.vendor) return h('span', { style: 'color: var(--color-text-4);' }, '—')
|
||||
return h(Tag, { color: vendorTagColor[record.vendor] || 'gray', size: 'small' }, () => vendorLabelMap[record.vendor] || record.vendor)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
@@ -676,6 +703,16 @@ function resetImport() {
|
||||
style="width: 140px"
|
||||
@change="handleFilter"
|
||||
/>
|
||||
<ASelect
|
||||
v-model="filterVendor"
|
||||
:options="vendorFilterOptions"
|
||||
placeholder="厂商"
|
||||
allow-clear
|
||||
allow-search
|
||||
:filter-option="filterVendorOption"
|
||||
style="width: 150px"
|
||||
@change="handleFilter"
|
||||
/>
|
||||
<ASelect
|
||||
v-model="filterStatus"
|
||||
:options="statusOptions"
|
||||
@@ -710,7 +747,7 @@ function resetImport() {
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:loading="loading"
|
||||
:scroll="{ x: 1500 }"
|
||||
:scroll="{ x: 1600 }"
|
||||
:stripe="true"
|
||||
row-key="id"
|
||||
:row-selection="{ type: 'checkbox', showCheckedAll: true }"
|
||||
@@ -814,6 +851,15 @@ function resetImport() {
|
||||
</AButton>
|
||||
</AFormItem>
|
||||
</div>
|
||||
<AFormItem label="厂商">
|
||||
<ASelect
|
||||
v-model="form.vendor"
|
||||
:options="[{ label: '未分类', value: '' }, ...vendorOptions]"
|
||||
placeholder="选择研发厂商"
|
||||
allow-search
|
||||
:filter-option="filterVendorOption"
|
||||
/>
|
||||
</AFormItem>
|
||||
<div style="display: flex; gap: 16px;">
|
||||
<AFormItem label="最大上下文" style="flex: 1;">
|
||||
<AInputNumber v-model="form.max_context_tokens" :min="0" placeholder="如 128000" class="w-full" />
|
||||
|
||||
@@ -26,6 +26,8 @@ const pagination = reactive({
|
||||
|
||||
const filterStatus = ref<string | null>(null)
|
||||
const filterSearch = ref('')
|
||||
const filterTag = ref('')
|
||||
const filterLevel = ref<number | null>(null)
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: '' },
|
||||
@@ -61,6 +63,8 @@ const form = reactive({
|
||||
max_members: null as number | null,
|
||||
max_concurrency: null as number | null,
|
||||
level: null as number | null,
|
||||
tags: [] as string[],
|
||||
remark: '',
|
||||
})
|
||||
|
||||
// Create modal
|
||||
@@ -124,34 +128,30 @@ const columns = computed<TableColumnData[]>(() => [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{ title: '租户名', dataIndex: 'name', width: 160, ellipsis: true },
|
||||
{ title: '代码', dataIndex: 'code', width: 120 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render({ record }) {
|
||||
return h(Tag, { color: statusTagColor[record.status], size: 'small' }, () => statusTagLabel[record.status] || record.status)
|
||||
},
|
||||
},
|
||||
{ title: '所有者', dataIndex: 'owner_name', width: 100, ellipsis: true },
|
||||
{ title: '成员', dataIndex: 'member_count', width: 70 },
|
||||
{
|
||||
title: '成员上限',
|
||||
dataIndex: 'effective_max_members',
|
||||
title: '成员数',
|
||||
dataIndex: 'member_count',
|
||||
width: 100,
|
||||
render({ record }) {
|
||||
const val = record.effective_max_members ?? record.max_members
|
||||
const custom = record.max_members != null ? ' (自定义)' : ''
|
||||
return `${val}${custom}`
|
||||
const limit = record.effective_max_members ?? record.max_members
|
||||
return `${record.member_count || 0}/${limit ? limit : '不限'}`
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '并发上限',
|
||||
dataIndex: 'effective_max_concurrency',
|
||||
width: 100,
|
||||
width: 110,
|
||||
render({ record }) {
|
||||
const val = record.effective_max_concurrency ?? record.max_concurrency
|
||||
const custom = record.max_concurrency != null ? ' (自定义)' : ''
|
||||
return val ? `${val}${custom}` : `不限${custom}`
|
||||
const text = val ? `${val}` : '不限'
|
||||
if (record.max_concurrency != null) {
|
||||
return h(Space, { size: 4 }, () => [
|
||||
h('span', text),
|
||||
h(Tag, { color: 'arcoblue', size: 'small' }, () => '自定义'),
|
||||
])
|
||||
}
|
||||
return text
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -181,6 +181,35 @@ const columns = computed<TableColumnData[]>(() => [
|
||||
return formatBilling(val, 2)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: `累计消费(${displayCurrency.value})`,
|
||||
dataIndex: 'total_consumed',
|
||||
width: 130,
|
||||
render({ record }) {
|
||||
const val = parseFloat(record.total_consumed || '0')
|
||||
return formatBilling(val, 2)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '标签',
|
||||
dataIndex: 'tags',
|
||||
width: 140,
|
||||
render({ record }) {
|
||||
const tags: string[] = record.tags || []
|
||||
if (!tags.length) return '-'
|
||||
return h(Space, { size: 4, wrap: 'wrap' }, () => tags.slice(0, 3).map((t) =>
|
||||
h(Tag, { color: 'arcoblue', size: 'small' }, () => t),
|
||||
).concat(tags.length > 3 ? [h('span', { class: 'text-xs text-gray-400' }, `+${tags.length - 3}`)] : []))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render({ record }) {
|
||||
return h(Tag, { color: statusTagColor[record.status], size: 'small' }, () => statusTagLabel[record.status] || record.status)
|
||||
},
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 170 },
|
||||
{
|
||||
title: '操作',
|
||||
@@ -205,6 +234,8 @@ async function fetchData() {
|
||||
}
|
||||
if (filterStatus.value) params.status = filterStatus.value
|
||||
if (filterSearch.value) params.keyword = filterSearch.value
|
||||
if (filterTag.value.trim()) params.tag = filterTag.value.trim()
|
||||
if (filterLevel.value != null) params.level = filterLevel.value
|
||||
|
||||
const res: any = await request.get('/admin/tenants', { params })
|
||||
data.value = res.data?.data?.list || res.data?.list || []
|
||||
@@ -230,6 +261,8 @@ function openEdit(row: any) {
|
||||
form.max_members = row.max_members ?? null
|
||||
form.max_concurrency = row.max_concurrency ?? null
|
||||
form.level = row.level ?? null
|
||||
form.tags = Array.isArray(row.tags) ? [...row.tags] : []
|
||||
form.remark = row.remark || ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
@@ -248,6 +281,8 @@ async function handleSubmit(done: () => void) {
|
||||
max_members: form.max_members,
|
||||
max_concurrency: form.max_concurrency,
|
||||
level: form.level,
|
||||
tags: form.tags,
|
||||
remark: form.remark,
|
||||
})
|
||||
Message.success('更新成功')
|
||||
done()
|
||||
@@ -282,6 +317,8 @@ const { exporting, exportFile } = useExport({
|
||||
getFilters: () => ({
|
||||
status: filterStatus.value,
|
||||
keyword: filterSearch.value,
|
||||
tag: filterTag.value.trim() || undefined,
|
||||
level: filterLevel.value ?? undefined,
|
||||
}),
|
||||
})
|
||||
</script>
|
||||
@@ -312,6 +349,17 @@ const { exporting, exportFile } = useExport({
|
||||
style="width: 120px"
|
||||
@change="handleFilter"
|
||||
/>
|
||||
<ASelect
|
||||
v-model="filterLevel"
|
||||
placeholder="等级"
|
||||
allow-clear
|
||||
style="width: 140px"
|
||||
@change="handleFilter"
|
||||
>
|
||||
<AOption v-for="opt in levelOptions" :key="opt.level" :value="opt.level">
|
||||
{{ opt.name }}
|
||||
</AOption>
|
||||
</ASelect>
|
||||
<AInput
|
||||
v-model="filterSearch"
|
||||
placeholder="搜索租户名/代码..."
|
||||
@@ -320,6 +368,14 @@ const { exporting, exportFile } = useExport({
|
||||
@keydown.enter="handleFilter"
|
||||
@clear="handleFilter"
|
||||
/>
|
||||
<AInput
|
||||
v-model="filterTag"
|
||||
placeholder="按标签筛选..."
|
||||
allow-clear
|
||||
style="width: 160px"
|
||||
@keydown.enter="handleFilter"
|
||||
@clear="handleFilter"
|
||||
/>
|
||||
<AButton @click="handleFilter">搜索</AButton>
|
||||
</ASpace>
|
||||
</ACard>
|
||||
@@ -330,13 +386,13 @@ const { exporting, exportFile } = useExport({
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:loading="loading"
|
||||
:scroll="{ x: 1300 }"
|
||||
:scroll="{ x: 1530 }"
|
||||
:stripe="true"
|
||||
row-key="id"
|
||||
card-title-key="name"
|
||||
card-subtitle-key="code"
|
||||
card-badge-key="status"
|
||||
:card-fields="['owner_name', 'member_count', 'level', 'wallet_balance']"
|
||||
:card-fields="['owner_name', 'member_count', 'level', 'wallet_balance', 'total_consumed']"
|
||||
/>
|
||||
<div class="table-footer">
|
||||
<TableStats :total="pagination.total" />
|
||||
@@ -357,7 +413,7 @@ const { exporting, exportFile } = useExport({
|
||||
v-model:visible="showModal"
|
||||
:title="modalTitle"
|
||||
:mask-closable="false"
|
||||
:width="480"
|
||||
:width="540"
|
||||
:on-before-ok="handleSubmit"
|
||||
:ok-loading="formLoading"
|
||||
>
|
||||
@@ -385,6 +441,12 @@ const { exporting, exportFile } = useExport({
|
||||
<AInputNumber v-model="form.max_concurrency" :min="0" placeholder="并发上限" allow-clear class="w-full" />
|
||||
</AFormItem>
|
||||
</ASpace>
|
||||
<AFormItem field="tags" label="标签" extra="最多 10 个,每个不超过 30 字符">
|
||||
<AInputTag v-model="form.tags" :max-tag-count="10" placeholder="输入后回车添加标签" allow-clear />
|
||||
</AFormItem>
|
||||
<AFormItem field="remark" label="备注" extra="仅管理后台可见">
|
||||
<ATextarea v-model="form.remark" placeholder="运营备注(最长 1000 字符)" :max-length="1000" show-word-limit :auto-size="{ minRows: 2, maxRows: 5 }" />
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
</AModal>
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ const editForm = reactive({
|
||||
level: null as number | null,
|
||||
max_members: null as number | null,
|
||||
max_concurrency: null as number | null,
|
||||
tags: [] as string[],
|
||||
remark: '',
|
||||
})
|
||||
|
||||
// 等级配置选项(后端接口)
|
||||
@@ -69,6 +71,8 @@ watch(() => props.detail, (d) => {
|
||||
editForm.level = d.level ?? null
|
||||
editForm.max_members = d.max_members ?? null
|
||||
editForm.max_concurrency = d.max_concurrency ?? null
|
||||
editForm.tags = Array.isArray(d.tags) ? [...d.tags] : []
|
||||
editForm.remark = d.remark || ''
|
||||
}, { immediate: true })
|
||||
|
||||
async function saveEdit() {
|
||||
@@ -108,6 +112,18 @@ onMounted(fetchLevelOptions)
|
||||
<ADescriptionsItem label="钱包余额">
|
||||
<span class="money">{{ formatBilling(detail.wallet_balance, 2) }}</span>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="累计消费">
|
||||
<span class="money">{{ formatBilling(detail.total_consumed, 2) }}</span>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="标签" :span="isMobile ? 1 : 2">
|
||||
<template v-if="detail.tags?.length">
|
||||
<ATag v-for="t in detail.tags" :key="t" size="small" color="arcoblue" style="margin: 0 4px 4px 0">{{ t }}</ATag>
|
||||
</template>
|
||||
<template v-else>-</template>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="备注" :span="isMobile ? 1 : 2">
|
||||
{{ detail.remark || '-' }}
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="并发上限">
|
||||
{{ getEffectiveMaxConcurrency() ?? '不限' }}
|
||||
<ATag v-if="detail.max_concurrency != null" size="small" color="arcoblue" style="margin-left: 4px">自定义</ATag>
|
||||
@@ -147,6 +163,12 @@ onMounted(fetchLevelOptions)
|
||||
</AOption>
|
||||
</ASelect>
|
||||
</AFormItem>
|
||||
<AFormItem label="标签" extra="最多 10 个,每个不超过 30 字符">
|
||||
<AInputTag v-model="editForm.tags" :max-tag-count="10" placeholder="输入后回车添加标签" allow-clear class="w-full" />
|
||||
</AFormItem>
|
||||
<AFormItem label="备注" extra="仅管理后台可见">
|
||||
<ATextarea v-model="editForm.remark" placeholder="运营备注(最长 1000 字符)" :max-length="1000" show-word-limit :auto-size="{ minRows: 2, maxRows: 5 }" class="w-full" />
|
||||
</AFormItem>
|
||||
<AFormItem>
|
||||
<AButton type="primary" :loading="editLoading" @click="saveEdit">保存</AButton>
|
||||
</AFormItem>
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface MarketplaceModel {
|
||||
model_id: string
|
||||
model_name: string
|
||||
category: string
|
||||
/** 研发厂商枚举(openai/anthropic/alibaba 等,空串=未分类) */
|
||||
vendor?: string | null
|
||||
description: string
|
||||
max_context_tokens: number
|
||||
max_output_tokens: number
|
||||
@@ -36,6 +38,7 @@ export interface MarketplaceModel {
|
||||
export interface MarketplaceListParams {
|
||||
keyword?: string
|
||||
category?: string
|
||||
vendor?: string
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
@@ -45,6 +48,8 @@ export interface MarketplaceListResponse {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
/** 当前条件下有模型的厂商去重列表(前端只渲染这些厂商筛选项) */
|
||||
vendors?: string[] | null
|
||||
}
|
||||
|
||||
export const getMarketplaceModels = (params: MarketplaceListParams) => {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="white" d="M23.919 14.545L20.817 9.17l1.47-2.544a.56.56 0 0 0 0-.566l-1.633-2.83a.57.57 0 0 0-.49-.283h-6.207L12.487.402a.57.57 0 0 0-.49-.284H8.732a.56.56 0 0 0-.49.284L5.139 5.775h-2.94a.56.56 0 0 0-.49.284L.077 8.887a.56.56 0 0 0 0 .567L3.18 14.83l-1.47 2.545a.56.56 0 0 0 0 .566l1.634 2.83a.57.57 0 0 0 .49.283h6.205l1.47 2.545a.57.57 0 0 0 .49.284h3.266a.57.57 0 0 0 .49-.284l3.104-5.375h2.94a.57.57 0 0 0 .49-.283l1.634-2.828a.55.55 0 0 0-.004-.568M8.733.686l1.634 2.828l-1.634 2.828H21.8L20.164 9.17H7.425L5.63 6.06Zm1.306 19.801l-6.205-.002l1.634-2.83h3.265L2.201 6.344h3.267q3.182 5.517 6.367 11.032zm10.124-5.66L18.53 12l-6.532 11.315l-1.634-2.83c2.129-3.673 4.25-7.351 6.373-11.028h3.592l3.102 5.374z"/></svg>
|
||||
|
After Width: | Height: | Size: 816 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="white" d="M17.304 3.541h-3.672l6.696 16.918H24Zm-10.608 0L0 20.459h3.744l1.37-3.553h7.005l1.369 3.553h3.744L10.536 3.541Zm-.371 10.223L8.616 7.82l2.291 5.945Z"/></svg>
|
||||
|
After Width: | Height: | Size: 264 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="white" d="M9.154 0C7.71 0 6.54 1.658 6.54 3.707c0 2.051 1.171 3.71 2.615 3.71c1.446 0 2.614-1.659 2.614-3.71C11.768 1.658 10.6 0 9.154 0m7.025.594C14.86.58 13.347 2.589 13.2 3.927c-.187 1.745.25 3.487 2.179 3.735c1.933.25 3.175-1.806 3.422-3.364c.252-1.555-.995-3.364-2.362-3.674a1.2 1.2 0 0 0-.261-.03zM3.582 5.535a3 3 0 0 0-.156.008c-2.118.19-2.428 3.24-2.428 3.24c-.287 1.41.686 4.425 3.297 3.864c2.617-.561 2.262-3.68 2.183-4.362c-.125-1.018-1.292-2.773-2.896-2.75m16.534 1.753c-2.308 0-2.617 2.119-2.617 3.616c0 1.43.121 3.425 2.988 3.362s2.553-3.238 2.553-3.988c0-.745-.62-2.99-2.924-2.99m-8.264 2.478c-1.424.014-2.708.925-3.323 1.947c-1.118 1.868-2.863 3.05-3.112 3.363c-.25.309-3.61 2.116-2.864 5.42c.746 3.301 3.365 3.237 3.365 3.237s1.93.19 4.171-.31c2.24-.495 4.17.123 4.17.123s5.233 1.748 6.665-1.616c1.43-3.364-.808-5.109-.808-5.109s-2.99-2.306-4.736-4.798c-1.072-1.665-2.348-2.268-3.528-2.257m-2.234 3.84l1.542.024v8.197H7.758c-1.47-.291-2.055-1.292-2.13-1.462c-.072-.173-.488-.976-.268-2.343c.635-2.049 2.447-2.196 2.447-2.196h1.81zm3.964 2.39v3.881c.096.413.612.488.612.488h1.614v-4.343h1.689v5.782h-3.915c-1.517-.39-1.59-1.465-1.59-1.465v-4.317zm-5.458 1.147c-.66.197-.978.708-1.05.928c-.076.22-.247.78-.1 1.269c.294 1.095 1.248 1.144 1.248 1.144h1.37v-3.34z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="white" d="M19.877 1.469L24 2.532v18.942l-4.123 1.056zM6.53 10.897l4.115 1.064v8.978L6.53 22.003zM0 2.572l4.115 1.064v16.736L0 21.428zm17.455 5.62V19.3l-4.122-1.065V9.257z"/></svg>
|
||||
|
After Width: | Height: | Size: 276 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 75 75"><path fill="#39594d" fill-rule="evenodd" d="M24.3 44.7c2 0 6-.1 11.6-2.4c6.5-2.7 19.3-7.5 28.6-12.5c6.5-3.5 9.3-8.1 9.3-14.3C73.8 7 66.9 0 58.3 0h-36C10 0 0 10 0 22.3s9.4 22.4 24.3 22.4"/><path fill="#d18ee2" fill-rule="evenodd" d="M30.4 60c0-6 3.6-11.5 9.2-13.8l11.3-4.7C62.4 36.8 75 45.2 75 57.6C75 67.2 67.2 75 57.6 75H45.3c-8.2 0-14.9-6.7-14.9-15"/><path fill="#ff7759" d="M12.9 47.6C5.8 47.6 0 53.4 0 60.5v1.7C0 69.2 5.8 75 12.9 75s12.9-5.8 12.9-12.9v-1.7c-.1-7-5.8-12.8-12.9-12.8"/></svg>
|
||||
|
After Width: | Height: | Size: 579 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="white" d="M23.748 4.651c-.254-.124-.364.113-.512.233c-.051.04-.094.09-.137.137c-.372.397-.806.657-1.373.626c-.829-.046-1.537.214-2.163.848c-.133-.782-.575-1.248-1.247-1.548c-.352-.155-.708-.311-.955-.65c-.172-.24-.219-.509-.305-.774c-.055-.16-.11-.323-.293-.35c-.2-.031-.278.136-.356.276c-.313.572-.434 1.202-.422 1.84c.027 1.436.633 2.58 1.838 3.393c.137.094.172.187.129.323c-.082.28-.18.553-.266.833c-.055.179-.137.218-.328.14a5.5 5.5 0 0 1-1.737-1.179c-.857-.828-1.631-1.743-2.597-2.46a12 12 0 0 0-.689-.47c-.985-.957.13-1.743.387-1.836c.27-.098.094-.433-.778-.428c-.872.003-1.67.295-2.687.685a3 3 0 0 1-.465.136a9.6 9.6 0 0 0-2.883-.101c-1.885.21-3.39 1.1-4.497 2.622C.082 8.776-.231 10.854.152 13.02c.403 2.284 1.568 4.175 3.36 5.653c1.857 1.533 3.997 2.284 6.438 2.14c1.482-.085 3.132-.284 4.994-1.86c.47.234.962.328 1.78.398c.629.058 1.235-.031 1.705-.129c.735-.155.684-.836.418-.961c-2.155-1.004-1.682-.595-2.112-.926c1.095-1.295 2.768-3.598 3.284-6.733c.05-.346.115-.834.108-1.114c-.004-.171.035-.238.23-.257a4.2 4.2 0 0 0 1.545-.475c1.397-.763 1.96-2.016 2.093-3.517c.02-.23-.004-.467-.247-.588M11.58 18.168c-2.088-1.642-3.101-2.183-3.52-2.16c-.39.024-.32.472-.234.763c.09.288.207.487.371.74c.114.167.192.416-.113.603c-.673.416-1.842-.14-1.897-.168c-1.361-.801-2.5-1.86-3.301-3.306c-.775-1.393-1.225-2.888-1.299-4.482c-.02-.385.094-.522.477-.592a4.7 4.7 0 0 1 1.53-.038c2.131.311 3.946 1.264 5.467 2.774c.868.86 1.525 1.887 2.202 2.89c.72 1.066 1.494 2.082 2.48 2.915c.348.291.626.513.892.677c-.802.09-2.14.109-3.055-.615zm1.001-6.44a.306.306 0 0 1 .415-.287a.3.3 0 0 1 .113.074a.3.3 0 0 1 .086.214c0 .17-.136.307-.308.307a.303.303 0 0 1-.306-.307m3.11 1.596c-.2.081-.4.151-.591.16a1.25 1.25 0 0 1-.798-.254c-.274-.23-.47-.358-.551-.758a1.7 1.7 0 0 1 .015-.588c.07-.327-.007-.537-.238-.727c-.188-.156-.426-.199-.689-.199a.6.6 0 0 1-.254-.078a.253.253 0 0 1-.114-.358a1 1 0 0 1 .192-.21c.356-.202.767-.136 1.146.016c.352.144.618.408 1.001.782c.392.451.462.576.685.915c.176.264.336.536.446.848c.066.194-.02.353-.25.45"/></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="white" d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133c-1.147 1.147-2.933 2.4-6.053 2.4c-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0C5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36c2.16-2.16 2.84-5.213 2.84-7.667c0-.76-.053-1.467-.173-2.053z"/></svg>
|
||||
|
After Width: | Height: | Size: 459 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="white" d="M18.315 12.264c2.33 0 4.218 1.88 4.218 4.2V19.8c0 2.32-1.888 4.2-4.218 4.2h-6.202a4.22 4.22 0 0 1-4.023-2.938l-3.676 1.833a2.04 2.04 0 0 1-2.731-.903a2 2 0 0 1-.216-.907v-5.94a2.03 2.03 0 0 1 2.035-2.024a2.04 2.04 0 0 1 .919.218l3.673 1.85a4.22 4.22 0 0 1 4.02-2.925zm-.062 2.162h-6.078c-1.153 0-2.09.921-2.108 2.065v3.247c0 1.148.925 2.081 2.073 2.1h6.113c1.153 0 2.09-.922 2.109-2.065v-3.247a2.104 2.104 0 0 0-2.074-2.1zM4.18 15.72a.554.554 0 0 0-.555.542v3.734a.556.556 0 0 0 .798.496l.01-.004l3.463-1.756V17.51l-3.467-1.73a.56.56 0 0 0-.249-.06M9.28 0a5.67 5.67 0 0 1 4.98 2.965a4.92 4.92 0 0 1 3.36-1.317c2.714 0 4.913 2.177 4.913 4.863s-2.2 4.863-4.912 4.863a4.92 4.92 0 0 1-3.996-2.034a5.65 5.65 0 0 1-4.345 2.034c-3.131 0-5.67-2.546-5.67-5.687S6.149 0 9.28 0m8.34 3.926c-1.441 0-2.61 1.157-2.61 2.585s1.169 2.585 2.61 2.585c1.443 0 2.612-1.157 2.612-2.585s-1.169-2.585-2.611-2.585zM9.28 2.287a3.395 3.395 0 0 0-3.39 3.4c0 1.877 1.518 3.4 3.39 3.4a3.395 3.395 0 0 0 3.39-3.4c0-1.878-1.518-3.4-3.39-3.4"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="white" d="M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a7 7 0 0 0 .265.86a5.3 5.3 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927c1.497 0 2.633-.671 3.965-2.444c.76-1.012 1.144-1.626 2.663-4.32l.756-1.339l.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314c1.046.987 1.992 1.22 3.06 1.22c1.075 0 1.876-.355 2.455-.843a3.7 3.7 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745c0-2.72-.681-5.357-2.084-7.45c-1.282-1.912-2.957-2.93-4.716-2.93c-1.047 0-2.088.467-3.053 1.308c-.652.57-1.257 1.29-1.82 2.05c-.69-.875-1.335-1.547-1.958-2.056c-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999c1.132 1.748 1.647 4.195 1.647 6.4c0 1.548-.368 2.9-1.839 2.9c-.58 0-1.027-.23-1.664-1.004c-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a45 45 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327c1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446c.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338c-1.191 1.649-1.81 1.817-2.486 1.817c-.524 0-1.038-.237-1.383-.794c-.263-.426-.464-1.13-.464-2.046c0-2.221.63-4.535 1.66-6.088c.454-.687.964-1.226 1.533-1.533a2.26 2.26 0 0 1 1.088-.285"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1.2em" height="1em" viewBox="0 0 256 214"><path fill="white" d="M240.86 162.892c.662 1.719.119 3.158-1.35 4.34c-13.753 11.071-28.19 21.114-43.904 29.24l-13.12 6.776c2.706 2.161 5.76 2.769 9.003 2.648c3.55-.133 6.524-1.763 9.27-3.858c3.283-2.504 6.387-5.274 9.836-7.519c8.35-5.435 17.35-4.944 25.248 1.185c3.355 2.604 6.439 5.578 9.92 7.988c1.683 1.165 3.838 1.8 5.904 2.257l.457.099c2.507.528 4.03 1.79 3.863 3.955c-.165 2.148-1.867 3.403-4.437 3.237c-5.445-.352-9.713-3.16-13.713-6.556c-2.498-2.12-4.91-4.38-7.606-6.219c-4.999-3.409-10.303-3.508-15.396-.235c-3.001 1.929-5.73 4.289-8.539 6.508c-4.527 3.578-9.413 6.212-15.374 6.302c-6.538.099-11.8-2.835-16.62-6.866c-1.724-1.442-3.431-2.905-5.188-4.306c-6.586-5.254-13.334-5.251-19.943.004a177 177 0 0 0-2.292 1.86l-1.52 1.25a122 122 0 0 1-3.849 3.062c-8.09 6.115-17.355 6.407-25.738.681c-3.05-2.083-5.831-4.561-8.74-6.853c-6.61-5.205-12.096-5.19-18.824-.022l-.55.424l-1.65 1.275c-2.2 1.7-4.415 3.385-6.74 4.896c-8.76 5.696-17.677 5.458-26.384-.047c-3.603-2.278-6.892-5.052-10.305-7.631l-.513-.386c-1.362-1.017-2.708-2.059-4.106-3.024c-1.95-1.346-4.05-1.722-6.162-.44c-3.732 2.266-7.42 4.604-11.116 6.928c-3.493 2.196-6.868 4.61-10.5 6.542c-1.968 1.046-4.332 1.533-6.575 1.82c-2.088.266-3.524-1.284-3.604-3.24c-.078-1.926 1.18-3.136 3.135-3.692l.114-.031c2.078-.556 4.219-1.262 6.022-2.395c5.087-3.199 10.017-6.649 15.003-10.008c.142-.095.224-.276.393-.493l-3.095-5.854q-1.552-2.93-3.093-5.87c-1.8-3.438-.629-5.744 3.237-6.02c10.423-.745 20.853-1.384 31.28-2.055l60.021-3.852c14.187-.913 28.373-1.835 42.56-2.75q30.008-1.936 60.018-3.87c5.32-.342 10.64-.676 15.96-1.024l5.32-.353c1.832-.123 3.302.43 3.982 2.192m-15.22 6.079l-.179-.437l-198.607 12.787l.49.916c1.288 2.412 2.428 4.59 3.673 6.706c.115.195.517.235.938.25l.43.01l.129.005l.122.007c1.726.127 3.612-.177 5.145.428c2.446.966 4.784 2.35 6.94 3.876c3.86 2.733 7.432 5.881 11.34 8.536c6.946 4.716 13.487 4.576 20.32-.206c2.883-2.018 5.618-4.248 8.475-6.306c8.932-6.435 17.394-6.302 26.113.417c2.548 1.964 4.977 4.082 7.52 6.051c6.378 4.938 12.849 4.956 19.194.051c2.113-1.633 4.125-3.397 6.199-5.082c9.406-7.641 18.94-8.378 28.19-1.563l.28.208c2.96 2.232 5.163 2.12 8.113.649l1.436-.718c13.157-6.597 26.065-13.607 37.982-22.318c1.928-1.41 3.838-2.844 5.756-4.267M41.217.675c21.496 9.698 40.8 22.744 58.345 38.424c21.83 19.512 39.036 42.552 51.744 68.903c6.688 13.87 11.89 28.276 14.28 43.572l.114.75l.174 1.198c.003 2.96-2.368 4.699-4.84 3.7c-12.272-4.959-24.944-8.398-38.039-10.362c-17.305-2.596-34.393-1.95-51.217 3.115c-11.157 3.36-21.77 7.942-31.632 14.195c-1.821 1.155-3.752.953-4.94-.46c-1.236-1.467-1.234-3.238.193-4.787c10.303-11.187 16.298-24.622 20.273-39.068c3.883-14.115 5.301-28.538 4.431-43.13c-.653-10.967-3.283-21.579-6.725-31.996c-4.453-13.477-10.231-26.36-17.198-38.717c-1.138-2.02-1.117-3.593.034-4.885c1.191-1.338 2.725-1.48 5.003-.452m66.27 25.192c1.993.581 4.678 1.155 7.204 2.137c10.083 3.925 18.77 10.152 27.046 16.955c13.45 11.056 25.765 23.285 36.779 36.772c7.665 9.387 13.953 19.713 19.686 30.371c6.5 12.087 12.099 24.582 16.85 37.454l.33.899c.126.342.255.683.37 1.028c.501 1.488.439 2.928-.727 4.065c-1.19 1.161-2.618 1.238-4.125.648l-.105-.042c-2.16-.888-4.35-1.707-6.505-2.604l-.922-.389c-6.066-2.604-12.3-3.124-18.72-1.666c-.592.135-1.192.237-1.792.329c-2.382.367-3.87-.564-4.52-2.87c-6.253-22.186-15.069-43.311-26.23-63.467l-.558-1.004c-7.196-12.857-15.434-24.948-26.14-35.22c-6.321-6.067-12.973-11.722-20.32-16.525c-1.702-1.113-2.34-2.658-1.78-4.344c.552-1.658 1.84-2.31 4.18-2.527M47.645 11.789c.02.561.171 1 .375 1.41c4.672 9.423 8.735 19.102 12.034 29.089c3.13 9.474 5.53 19.13 6.68 29.06c1.287 11.114.758 22.2-.767 33.254c-2.007 14.558-6.045 28.482-13.009 41.487l-.81 1.515l-1.228 2.303c.257.07.388.153.472.12a64 64 0 0 0 2.185-.907c14.78-6.488 30.181-10.406 46.357-10.809c18.47-.46 36.36 2.87 53.83 8.67l1.193.396c.864.284 1.786.576 2.98.963c-7.077-30.978-21.748-57.605-41.522-81.557c-19.063-23.09-42.249-41.132-68.77-54.994m87.764 37.559l.234.265l.465.518c.18.202.343.392.496.59a274 274 0 0 0 3.305 4.18l1.819 2.262c2.732 3.404 5.43 6.832 7.802 10.47c15.005 23.014 26.039 47.899 34.082 74.124l.243.795c.418 1.373.992 1.759 2.33 1.557l.093-.015c6.017-1.002 11.898-.481 17.6 1.715l.295.115c.545.216 1.127.336 2.183.645c-7.725-19.275-16.855-37.217-28.408-53.942c-9.99-14.462-36.741-40.14-42.54-43.28"/></svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="white" d="M11.43 3.92a.86.86 0 1 0-1.718 0v14.236a1.999 1.999 0 0 1-3.997 0V9.022a.86.86 0 1 0-1.718 0v3.87a1.999 1.999 0 0 1-3.997 0V11.49a.57.57 0 0 1 1.139 0v1.404a.86.86 0 0 0 1.719 0V9.022a1.999 1.999 0 0 1 3.997 0v9.134a.86.86 0 0 0 1.719 0V3.92a1.998 1.998 0 1 1 3.996 0v11.788a.57.57 0 1 1-1.139 0zm10.572 3.105a2 2 0 0 0-1.999 1.997v7.63a.86.86 0 0 1-1.718 0V3.923a1.999 1.999 0 0 0-3.997 0v16.16a.86.86 0 0 1-1.719 0V18.08a.57.57 0 1 0-1.138 0v2a1.998 1.998 0 0 0 3.996 0V3.92a.86.86 0 0 1 1.719 0v12.73a1.999 1.999 0 0 0 3.996 0V9.023a.86.86 0 1 1 1.72 0v6.686a.57.57 0 0 0 1.138 0V9.022a2 2 0 0 0-1.998-1.997"/></svg>
|
||||
|
After Width: | Height: | Size: 726 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="white" d="M17.143 3.429v3.428h-3.429v3.429h-3.428V6.857H6.857V3.43H3.43v13.714H0v3.428h10.286v-3.428H6.857v-3.429h3.429v3.429h3.429v-3.429h3.428v3.429h-3.428v3.428H24v-3.428h-3.43V3.429z"/></svg>
|
||||
|
After Width: | Height: | Size: 292 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="white" fill-rule="evenodd" d="m1.052 16.916l9.539 2.552a21 21 0 0 0 .06 2.033l5.956 1.593a12 12 0 0 1-5.586.865l-.18-.016l-.044-.004l-.084-.009l-.094-.01l-.157-.02l-.107-.014l-.11-.016a12 12 0 0 1-.32-.051l-.042-.008l-.075-.013l-.107-.02l-.07-.015l-.093-.019l-.075-.016l-.095-.02l-.097-.023l-.094-.022l-.068-.017l-.088-.022l-.09-.024l-.095-.025l-.082-.023l-.109-.03l-.062-.02l-.084-.025l-.093-.028l-.105-.034l-.058-.019l-.08-.026l-.09-.031l-.066-.024l-.044-.015l-.068-.025l-.101-.037l-.057-.022l-.08-.03l-.087-.035l-.088-.035l-.079-.032l-.095-.04l-.063-.028l-.063-.027l-.041-.018l-.066-.03l-.103-.047l-.052-.024l-.096-.046l-.062-.03l-.084-.04l-.086-.044l-.093-.047l-.052-.027l-.103-.055l-.057-.03l-.058-.032l-.046-.026l-.094-.053l-.06-.034l-.051-.03l-.072-.041l-.082-.05l-.093-.056l-.052-.032l-.084-.053l-.061-.039l-.079-.05l-.07-.047l-.053-.035l-.054-.036l-.044-.03l-.044-.03l-.04-.028l-.057-.04l-.076-.054l-.069-.05l-.074-.054l-.056-.042l-.076-.057l-.076-.059l-.086-.067l-.045-.035l-.064-.052l-.074-.06l-.089-.073l-.046-.039l-.046-.039l-.043-.037l-.045-.04l-.061-.053l-.07-.062l-.068-.06l-.062-.058l-.067-.062l-.053-.05l-.088-.084l-.099-.097l-.029-.028l-.041-.042l-.069-.07l-.05-.051l-.05-.053a7 7 0 0 1-.168-.179l-.08-.088l-.062-.07l-.071-.08l-.042-.049l-.053-.062l-.058-.068l-.046-.056l-.027-.033l-.045-.055l-.066-.082l-.041-.052l-.05-.064l-.02-.025a12 12 0 0 1-1.44-2.402m-1.02-5.794l11.353 3.037a21 21 0 0 0-.469 2.011l10.817 2.894a12 12 0 0 1-1.845 2.005L.657 15.923l-.016-.046l-.035-.104l-.05-.153l-.007-.023a12 12 0 0 1-.207-.741l-.03-.126l-.018-.08l-.021-.097l-.018-.081l-.018-.09l-.017-.084l-.018-.094q-.04-.212-.071-.426l-.017-.118l-.011-.083l-.013-.102l-.019-.161l-.005-.047a12 12 0 0 1-.034-2.145m1.593-5.15l11.948 3.196a21 21 0 0 0-1.01 1.875l11.295 3.022c-.142.82-.368 1.612-.668 2.365l-11.55-3.09L.124 10.26l.015-.1l.008-.049l.01-.067l.015-.087l.018-.098q.04-.222.088-.442l.028-.124l.02-.085l.024-.097q.033-.135.07-.268l.028-.102l.023-.083l.03-.1l.025-.082l.03-.096l.026-.082l.031-.095a12 12 0 0 1 1.01-2.232zm4.442-4.4L17.352 4.59a21 21 0 0 0-1.688 1.721l7.823 2.093c.267.852.442 1.744.513 2.665L2.106 5.213l.045-.065l.027-.04l.04-.055l.046-.065l.055-.076l.054-.072l.064-.086l.05-.065l.057-.073l.055-.07l.06-.074l.055-.069l.065-.077l.054-.066l.066-.077l.053-.06l.072-.082l.053-.06l.067-.074l.054-.058l.073-.078l.058-.06l.063-.067l.168-.17l.1-.098l.059-.056l.076-.071a12 12 0 0 1 2.272-1.677M12.017 0h.097l.082.001l.069.001l.054.002l.068.002l.046.001l.076.003l.047.002l.06.003l.054.002l.087.005l.105.007l.144.011l.088.007l.044.004l.077.008l.082.008l.047.005l.102.012l.05.006l.108.014l.081.01l.042.006l.065.01l.207.032l.07.012l.065.011l.14.026l.092.018l.11.022l.046.01l.075.016l.041.01L14.7.3l.042.01l.065.015l.049.012l.071.017l.096.024l.112.03l.113.03l.113.032l.05.015l.07.02l.078.024l.073.023l.05.016l.05.016l.076.025l.099.033l.102.036l.048.017l.064.023l.093.034l.11.041l.116.045l.1.04l.047.02l.06.024l.041.018l.063.026l.04.018l.057.025l.11.048l.1.046l.074.035l.075.036l.06.028l.092.046l.091.045l.102.052l.053.028l.049.026l.046.024l.06.033l.041.022l.052.029l.088.05l.106.06l.087.051l.057.034l.053.032l.096.059l.088.055l.098.062l.036.024l.064.041l.084.056l.04.027l.062.042l.062.043l.023.017q.081.055.161.114l.083.06l.065.048l.056.043l.086.065l.082.064l.04.03l.05.041l.086.069l.079.065l.085.071c.712.6 1.353 1.283 1.909 2.031L7.222.994l.062-.027l.065-.028l.081-.034l.086-.035q.17-.068.341-.131l.096-.035l.093-.033l.084-.03l.096-.031A8 8 0 0 1 8.49.525l.091-.027l.086-.025l.102-.03l.085-.023l.1-.026L9.04.37l.09-.023l.091-.022l.095-.022l.09-.02l.098-.021l.091-.02l.095-.018l.092-.018l.1-.018l.091-.016l.098-.017l.092-.014l.097-.015l.092-.013l.102-.013l.091-.012l.105-.012l.09-.01l.105-.01q.14-.015.28-.024l.106-.008l.09-.005l.11-.006l.093-.004l.1-.004l.097-.002l.099-.002l.197-.002z"/></svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="white" d="M22.282 9.821a6 6 0 0 0-.516-4.91a6.05 6.05 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a6 6 0 0 0-3.998 2.9a6.05 6.05 0 0 0 .743 7.097a5.98 5.98 0 0 0 .51 4.911a6.05 6.05 0 0 0 6.515 2.9A6 6 0 0 0 13.26 24a6.06 6.06 0 0 0 5.772-4.206a6 6 0 0 0 3.997-2.9a6.06 6.06 0 0 0-.747-7.073M13.26 22.43a4.48 4.48 0 0 1-2.876-1.04l.141-.081l4.779-2.758a.8.8 0 0 0 .392-.681v-6.737l2.02 1.168a.07.07 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494M3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085l4.783 2.759a.77.77 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646M2.34 7.896a4.5 4.5 0 0 1 2.366-1.973V11.6a.77.77 0 0 0 .388.677l5.815 3.354l-2.02 1.168a.08.08 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.08.08 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667m2.01-3.023l-.141-.085l-4.774-2.782a.78.78 0 0 0-.785 0L9.409 9.23V6.897a.07.07 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.8.8 0 0 0-.393.681zm1.097-2.365l2.602-1.5l2.607 1.5v2.999l-2.597 1.5l-2.607-1.5Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="white" fill-rule="evenodd" d="M6.469 8.776L16.512 23h-4.464L2.005 8.776zm-.004 7.9l2.233 3.164L6.467 23H2zM22 2.582V23h-3.659V7.764zM22 1l-9.952 14.095l-2.233-3.163L17.533 1z"/></svg>
|
||||
|
After Width: | Height: | Size: 280 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="white" fill-rule="evenodd" d="M20.713 6.655c-.414-1.426-1.748-2.472-3.357-2.472a3.6 3.6 0 0 0-1.7.423A5.9 5.9 0 0 0 10.735 2C7.77 2 5.31 4.16 4.943 6.944C2.138 7.39 0 9.728 0 12.58c0 3.14 2.62 5.68 5.862 5.68c1.61 0 3.08-.646 4.138-1.671c.276-.267.529-.557.736-.89a5 5 0 0 1-.713.845a9 9 0 0 0-1.77 5.39V22a16.7 16.7 0 0 1 8.666-2.717h.046c3.035 0 5.633-1.871 6.621-4.499A6.6 6.6 0 0 0 24 12.445c0-2.427-1.31-4.565-3.287-5.79M6.966 12.869a.836.836 0 0 1-.851.824a.81.81 0 0 1-.805-.824v-2.183a.81.81 0 0 1 .805-.824c.46 0 .85.379.85.824zm3.011 1.069a.86.86 0 0 1-.874.846a.86.86 0 0 1-.873-.846v-4.9a.86.86 0 0 1 .873-.846a.86.86 0 0 1 .874.846zm3.104-1.047c0 .445-.414.824-.874.824s-.85-.379-.85-.824v-2.227c0-.446.367-.824.85-.824c.46 0 .873.378.873.824v2.227zm3.149 1.069a.86.86 0 0 1-.874.846a.86.86 0 0 1-.873-.846v-4.9a.86.86 0 0 1 .873-.846a.86.86 0 0 1 .874.846zm3.08-1.091a.836.836 0 0 1-.85.824a.81.81 0 0 1-.805-.824v-2.183a.81.81 0 0 1 .805-.824c.46 0 .85.379.85.824z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="white" fill-rule="evenodd" d="M11.991 23.503a.24.24 0 0 0-.244.248a.24.24 0 0 0 .244.249a.24.24 0 0 0 .245-.249a.24.24 0 0 0-.22-.247zM9.671 5.365a1.697 1.697 0 0 1 1.099 2.132l-.071.172l-.016.04l-.018.054c-.07.16-.104.32-.104.498c-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523c-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368c0 .124.018.23.053.337c.209.373.54.658.96.8c.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711c.854.284 1.378 1.226 1.099 2.167a1.66 1.66 0 0 1-2.077 1.102a1.7 1.7 0 0 1-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 0 0-1.954.746a1.66 1.66 0 0 1-1.065.764a1.677 1.677 0 0 1-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.5 1.5 0 0 1 .296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.3 1.3 0 0 0-.227-.693a2.5 2.5 0 0 1-.366-1.403a2.4 2.4 0 0 1 .366-1.208c.14-.195.21-.444.227-.693c.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.4 1.4 0 0 1-.299-.07l-.05-.019a1.7 1.7 0 0 1-1.047-2.114a1.68 1.68 0 0 1 2.094-1.101m-5.575 10.11c.26-.264.639-.367.994-.27s.633.379.728.74c.095.362-.007.748-.267 1.013c-.402.41-1.053.41-1.455 0a1.06 1.06 0 0 1 0-1.482zm14.845-.294c.359-.09.738.024.992.297c.254.274.344.665.237 1.025s-.396.634-.756.718c-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 0 1 .757-1.26zm-.064-4.39c.314.32.49.753.49 1.206s-.176.886-.49 1.206c-.315.32-.74.5-1.185.5c-.444 0-.87-.18-1.184-.5a1.727 1.727 0 0 1 0-2.412a1.654 1.654 0 0 1 2.369 0m-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 0 1-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 0 1 1.33-1.038a1.66 1.66 0 0 1 1.547.652m11.545-4.221c.368 0 .708.2.892.524s.184.724 0 1.048a1.03 1.03 0 0 1-.892.524a1.04 1.04 0 0 1-1.03-1.048a1.04 1.04 0 0 1 1.03-1.048m-14.358 0c.368 0 .707.2.891.524s.184.724 0 1.048a1.03 1.03 0 0 1-.891.524a1.04 1.04 0 0 1-1.03-1.048c0-.579.461-1.048 1.03-1.048m10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705s-1.674-.763-1.674-1.705s.75-1.706 1.674-1.706m-2.626-.684c.362-.082.653-.356.761-.718a1.06 1.06 0 0 0-.238-1.028a1.02 1.02 0 0 0-.996-.294c-.547.14-.881.7-.752 1.257c.13.558.675.907 1.225.783m0 16.876c.359-.087.644-.36.75-.72a1.06 1.06 0 0 0-.237-1.019a1.02 1.02 0 0 0-.985-.301a1.04 1.04 0 0 0-.762.717c-.108.361-.017.754.239 1.028c.245.263.606.377.953.305zM17.19 3.5a.63.63 0 0 0 .628-.64a.63.63 0 0 0-.628-.64a.63.63 0 0 0-.628.64c0 .355.28.64.628.64m-10.38 0a.63.63 0 0 0 .628-.64c0-.355-.28-.64-.628-.64a.63.63 0 0 0-.628.64c0 .355.279.64.628.64m-5.182 7.852a.63.63 0 0 0-.628.64c0 .354.28.639.628.639a.63.63 0 0 0 .627-.606l.001-.034a.62.62 0 0 0-.628-.64zm5.182 9.13a.63.63 0 0 0-.628.64c0 .355.279.64.628.64a.63.63 0 0 0 .628-.64c0-.355-.28-.64-.628-.64m10.38.018a.63.63 0 0 0-.628.64c0 .355.28.64.628.64a.63.63 0 0 0 .628-.64a.63.63 0 0 0-.628-.64m5.182-9.148a.63.63 0 0 0-.628.64c0 .354.279.639.628.639a.63.63 0 0 0 .628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 0 0 .244-.249a.24.24 0 0 0-.244-.249a.24.24 0 0 0-.244.249c0 .142.122.249.244.249M11.991.497a.24.24 0 0 0 .245-.248A.24.24 0 0 0 11.99 0a.24.24 0 0 0-.244.249c0 .133.108.236.223.247zM2.011 6.36a.24.24 0 0 0 .245-.249a.24.24 0 0 0-.244-.249a.24.24 0 0 0-.244.249a.24.24 0 0 0 .244.249zm0 11.263a.24.24 0 0 0-.243.248a.24.24 0 0 0 .244.249a.24.24 0 0 0 .244-.249a.25.25 0 0 0-.244-.248zm19.995-.018a.24.24 0 0 0-.245.248a.24.24 0 0 0 .245.25a.24.24 0 0 0 .244-.25a.25.25 0 0 0-.244-.248"/></svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { getVendorMeta, vendorLabel } from '@/constants/vendor'
|
||||
import openaiLogo from '@/assets/vendors/openai.svg'
|
||||
import anthropicLogo from '@/assets/vendors/anthropic.svg'
|
||||
import googleLogo from '@/assets/vendors/google.svg'
|
||||
import xaiLogo from '@/assets/vendors/xai.svg'
|
||||
import mistralLogo from '@/assets/vendors/mistral.svg'
|
||||
import cohereLogo from '@/assets/vendors/cohere.svg'
|
||||
import metaLogo from '@/assets/vendors/meta.svg'
|
||||
import alibabaLogo from '@/assets/vendors/alibaba.svg'
|
||||
import bytedanceLogo from '@/assets/vendors/bytedance.svg'
|
||||
import deepseekLogo from '@/assets/vendors/deepseek.svg'
|
||||
import zhipuLogo from '@/assets/vendors/zhipu.svg'
|
||||
import moonshotLogo from '@/assets/vendors/moonshot.svg'
|
||||
import minimaxLogo from '@/assets/vendors/minimax.svg'
|
||||
import baiduLogo from '@/assets/vendors/baidu.svg'
|
||||
import tencentLogo from '@/assets/vendors/tencent.svg'
|
||||
import xunfeiLogo from '@/assets/vendors/xunfei.svg'
|
||||
import kuaishouLogo from '@/assets/vendors/kuaishou.svg'
|
||||
import midjourneyLogo from '@/assets/vendors/midjourney.svg'
|
||||
import sunoLogo from '@/assets/vendors/suno.svg'
|
||||
|
||||
// 厂商 slug → 本地白色单色 logo(多色 logo 见 constants/vendor.ts 的 colored 标记)
|
||||
const logoMap: Record<string, string> = {
|
||||
openai: openaiLogo,
|
||||
anthropic: anthropicLogo,
|
||||
google: googleLogo,
|
||||
xai: xaiLogo,
|
||||
mistral: mistralLogo,
|
||||
cohere: cohereLogo,
|
||||
meta: metaLogo,
|
||||
alibaba: alibabaLogo,
|
||||
bytedance: bytedanceLogo,
|
||||
deepseek: deepseekLogo,
|
||||
zhipu: zhipuLogo,
|
||||
moonshot: moonshotLogo,
|
||||
minimax: minimaxLogo,
|
||||
baidu: baiduLogo,
|
||||
tencent: tencentLogo,
|
||||
xunfei: xunfeiLogo,
|
||||
kuaishou: kuaishouLogo,
|
||||
midjourney: midjourneyLogo,
|
||||
suno: sunoLogo,
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 厂商枚举值(空串/null = 未分类,渲染灰色兜底头像) */
|
||||
vendor?: string | null
|
||||
/** 是否附带厂商名称 */
|
||||
showName?: boolean
|
||||
size?: 'xs' | 'sm' | 'md'
|
||||
}>(),
|
||||
{ vendor: '', showName: false, size: 'sm' },
|
||||
)
|
||||
|
||||
const meta = computed(() => getVendorMeta(props.vendor))
|
||||
const logoSrc = computed(() => (meta.value ? logoMap[meta.value.value] ?? null : null))
|
||||
|
||||
// 未收录/未设置时的兜底首字:未收录取原值首字符大写,未设置显示 ?
|
||||
const fallbackInitial = computed(() => {
|
||||
if (props.vendor) return props.vendor.slice(0, 1).toUpperCase()
|
||||
return '?'
|
||||
})
|
||||
|
||||
const chipClass = computed(() => {
|
||||
const sizeClass = { xs: 'h-4 w-4 rounded-[5px]', sm: 'h-5 w-5 rounded-md', md: 'h-7 w-7 rounded-lg' }[props.size]
|
||||
const base = 'inline-flex shrink-0 items-center justify-center'
|
||||
// 多色 logo 用浅色底 + 细边框;未知/未设置用中性灰
|
||||
if (logoSrc.value && !meta.value?.colored) return `${base} ${sizeClass}`
|
||||
return `${base} ${sizeClass} border border-gray-200/80 bg-gray-100 text-gray-500`
|
||||
})
|
||||
|
||||
const chipStyle = computed(() => {
|
||||
if (!logoSrc.value) return {}
|
||||
if (meta.value?.colored) return { backgroundColor: 'rgba(255, 255, 255, 0.92)' }
|
||||
return { backgroundColor: meta.value?.color }
|
||||
})
|
||||
|
||||
const imgClass = computed(() => ({ xs: 'h-2.5 w-2.5', sm: 'h-3 w-3', md: 'h-4 w-4' }[props.size]))
|
||||
const nameClass = computed(() => ({ xs: 'text-[10px]', sm: 'text-xs', md: 'text-sm' }[props.size]))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-flex min-w-0 items-center gap-1.5" :title="showName ? undefined : vendorLabel(vendor)">
|
||||
<span :class="chipClass" :style="chipStyle">
|
||||
<img v-if="logoSrc" :src="logoSrc" :alt="vendorLabel(vendor)" class="object-contain" :class="imgClass" />
|
||||
<span v-else class="font-bold leading-none" :class="nameClass">{{ fallbackInitial }}</span>
|
||||
</span>
|
||||
<span v-if="showName" class="truncate font-medium text-gray-600" :class="nameClass">{{ vendorLabel(vendor) }}</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -5,7 +5,9 @@ import BasePagination from '@/components/common/BasePagination.vue'
|
||||
import ModelCard from './components/ModelCard.vue'
|
||||
import ModelDetailDrawer from './components/ModelDetailDrawer.vue'
|
||||
import { getMarketplaceModelDetail, getMarketplaceModels, type MarketplaceModel } from '@/api/marketplace'
|
||||
import VendorLogo from '@/components/common/VendorLogo.vue'
|
||||
import { categoryMetaList } from './marketplaceMeta'
|
||||
import { vendorMetaList, vendorLabel } from '@/constants/vendor'
|
||||
import { usePublicSettings } from '@/composables/usePublicSettings'
|
||||
import { useSeo } from '@/composables/useSeo'
|
||||
import { useTenantAuthStore } from '@/stores/tenant-auth'
|
||||
@@ -30,6 +32,9 @@ const categories = categoryMetaList
|
||||
|
||||
const keyword = ref('')
|
||||
const selectedCategory = ref<string | null>(null)
|
||||
const selectedVendor = ref<string | null>(null)
|
||||
// 当前条件下有模型的厂商(后端 facet 返回),厂商筛选项据此动态渲染
|
||||
const availableVendors = ref<string[]>([])
|
||||
const loading = ref(false)
|
||||
const loadError = ref(false)
|
||||
const modelList = ref<MarketplaceModel[]>([])
|
||||
@@ -46,7 +51,22 @@ let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let listRequestId = 0
|
||||
let detailRequestId = 0
|
||||
|
||||
const hasActiveFilters = computed(() => keyword.value.trim() !== '' || selectedCategory.value !== null)
|
||||
const hasActiveFilters = computed(() => keyword.value.trim() !== '' || selectedCategory.value !== null || selectedVendor.value !== null)
|
||||
|
||||
// 厂商筛选项:只显示当前条件下实际有模型的厂商(按字典序);已选厂商不在可用列表时保留,
|
||||
// 否则筛选条件生效但按钮消失,用户无法取消选择。字典外的厂商值(历史数据)按原值追加,渲染回退头像。
|
||||
const visibleVendorOptions = computed(() => {
|
||||
const available = new Set(availableVendors.value)
|
||||
if (selectedVendor.value) available.add(selectedVendor.value)
|
||||
const known = vendorMetaList
|
||||
.filter(v => available.has(v.value))
|
||||
.map(v => ({ value: v.value, label: v.label }))
|
||||
const knownValues = new Set(vendorMetaList.map(v => v.value))
|
||||
const unknown = [...available]
|
||||
.filter(v => !knownValues.has(v))
|
||||
.map(v => ({ value: v, label: vendorLabel(v) }))
|
||||
return [...known, ...unknown]
|
||||
})
|
||||
|
||||
async function loadModels(): Promise<void> {
|
||||
const requestId = ++listRequestId
|
||||
@@ -57,12 +77,14 @@ async function loadModels(): Promise<void> {
|
||||
const response = await getMarketplaceModels({
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
category: selectedCategory.value || undefined,
|
||||
vendor: selectedVendor.value || undefined,
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value,
|
||||
})
|
||||
if (requestId !== listRequestId) return
|
||||
modelList.value = response.list || []
|
||||
total.value = response.total || 0
|
||||
availableVendors.value = response.vendors || []
|
||||
} catch {
|
||||
if (requestId !== listRequestId) return
|
||||
modelList.value = []
|
||||
@@ -100,9 +122,17 @@ function selectCategory(category: string | null): void {
|
||||
loadModels()
|
||||
}
|
||||
|
||||
function selectVendor(vendor: string | null): void {
|
||||
if (selectedVendor.value === vendor) return
|
||||
selectedVendor.value = vendor
|
||||
currentPage.value = 1
|
||||
loadModels()
|
||||
}
|
||||
|
||||
function resetFilters(): void {
|
||||
keyword.value = ''
|
||||
selectedCategory.value = null
|
||||
selectedVendor.value = null
|
||||
currentPage.value = 1
|
||||
loadModels()
|
||||
}
|
||||
@@ -263,7 +293,45 @@ useSeo({
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- 重置筛选:仅在存在筛选条件时出现 -->
|
||||
<!-- 厂商:只显示当前条件下有模型的厂商;移动端单行横滑 chips,桌面端菜单列表 -->
|
||||
<template v-if="visibleVendorOptions.length">
|
||||
<div class="my-4 h-px bg-gray-200/60"></div>
|
||||
|
||||
<span class="mb-3 hidden text-xs font-semibold uppercase tracking-wider text-gray-400 lg:block">研发厂商</span>
|
||||
<nav class="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1 lg:mx-0 lg:flex-col lg:gap-1 lg:overflow-visible lg:px-0 lg:pb-0" aria-label="按厂商筛选">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex shrink-0 items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-all duration-200 active:scale-[0.98] lg:w-full lg:rounded-xl lg:px-3 lg:py-2"
|
||||
:class="
|
||||
selectedVendor === null
|
||||
? 'border-transparent bg-gray-900 text-white lg:font-semibold'
|
||||
: 'border-gray-200/70 bg-white/70 text-gray-600 hover:border-primary-200 hover:text-primary-700 lg:border-transparent lg:bg-transparent lg:hover:bg-primary-500/5 lg:hover:text-primary-700'
|
||||
"
|
||||
@click="selectVendor(null)"
|
||||
>
|
||||
<span class="inline-flex h-5 w-5 items-center justify-center rounded-md bg-gray-100 text-[10px] font-bold text-gray-400 lg:hidden">全部</span>
|
||||
<span class="hidden lg:inline">全部厂商</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="vendor in visibleVendorOptions"
|
||||
:key="vendor.value"
|
||||
type="button"
|
||||
class="inline-flex shrink-0 items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-all duration-200 active:scale-[0.98] lg:w-full lg:rounded-xl lg:px-3 lg:py-2"
|
||||
:class="
|
||||
selectedVendor === vendor.value
|
||||
? 'border-transparent bg-gray-900 text-white lg:font-semibold'
|
||||
: 'border-gray-200/70 bg-white/70 text-gray-600 hover:border-primary-200 hover:text-primary-700 lg:border-transparent lg:bg-transparent lg:hover:bg-primary-500/5 lg:hover:text-primary-700'
|
||||
"
|
||||
@click="selectVendor(vendor.value)"
|
||||
>
|
||||
<VendorLogo :vendor="vendor.value" size="sm" />
|
||||
<span class="whitespace-nowrap">{{ vendor.label }}</span>
|
||||
<Icon v-if="selectedVendor === vendor.value" name="check" size="xs" class="ml-auto hidden shrink-0 lg:block" />
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<!-- 重置筛选:仅在存在筛选条件时出现 -->
|
||||
<button v-if="hasActiveFilters" type="button" class="btn btn-secondary btn-sm mt-4 w-full" @click="resetFilters">
|
||||
<Icon name="refresh" size="sm" />重置筛选
|
||||
</button>
|
||||
@@ -313,14 +381,14 @@ useSeo({
|
||||
<div v-else-if="modelList.length === 0" class="empty-state">
|
||||
<Icon name="search" size="xl" class="empty-state-icon" />
|
||||
<p class="empty-state-title">没有找到匹配的模型</p>
|
||||
<p class="empty-state-description">换一个关键词,或者清除当前分类后再试。</p>
|
||||
<p class="empty-state-description">换一个关键词,或者清除当前筛选条件后再试。</p>
|
||||
<button type="button" class="btn btn-secondary btn-sm mt-4" @click="resetFilters">
|
||||
<Icon name="refresh" size="sm" />重置筛选
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 卡片网格:key 携带分类与页码,翻页 / 切分类时重放入场动画 -->
|
||||
<div v-else :key="`${selectedCategory ?? 'all'}-${currentPage}`" class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
<div v-else :key="`${selectedCategory ?? 'all'}-${selectedVendor ?? 'all'}-${currentPage}`" class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
<ModelCard
|
||||
v-for="(model, index) in modelList"
|
||||
:key="model.model_id"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import VendorLogo from '@/components/common/VendorLogo.vue'
|
||||
import type { MarketplaceModel } from '@/api/marketplace'
|
||||
import { formatPrice, formatTokens, getCategoryMeta, getCapabilityList } from '../marketplaceMeta'
|
||||
|
||||
@@ -69,6 +70,7 @@ const cardStyle = computed(() => ({
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start gap-2">
|
||||
<VendorLogo :vendor="model.vendor" size="sm" class="mt-0.5 shrink-0" />
|
||||
<h3 class="line-clamp-1 text-base font-semibold text-gray-900">{{ model.model_name || model.model_id }}</h3>
|
||||
<span v-if="model.discount_label" class="shrink-0 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-700">
|
||||
{{ model.discount_label }}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import { NDrawer, NDrawerContent } from 'naive-ui'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import VendorLogo from '@/components/common/VendorLogo.vue'
|
||||
import type { MarketplaceModel } from '@/api/marketplace'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import { displayCurrency } from '@/composables/useCurrency'
|
||||
@@ -97,6 +98,7 @@ const drawerWidth = computed(() => (isMobile.value ? '100%' : 560))
|
||||
<Icon name="copy" size="xs" class="shrink-0 text-gray-400 transition-colors group-hover:text-primary-600" />
|
||||
</button>
|
||||
</div>
|
||||
<VendorLogo :vendor="model.vendor" size="sm" show-name class="shrink-0" />
|
||||
<span class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium" :style="categoryBadgeStyle">{{ meta.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,8 +3,10 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { NInput } from 'naive-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import VendorLogo from '@/components/common/VendorLogo.vue'
|
||||
import request from '@/utils/request'
|
||||
import { formatBilling } from '@/composables/useCurrency'
|
||||
import { vendorMetaList, vendorLabel } from '@/constants/vendor'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -38,6 +40,8 @@ interface ModelItem {
|
||||
model_id: string
|
||||
model_name: string
|
||||
category: string
|
||||
/** 研发厂商枚举(openai/anthropic/alibaba 等,空串/未设置=未分类) */
|
||||
vendor?: string | null
|
||||
max_context_tokens: number
|
||||
max_output_tokens: number
|
||||
description: string
|
||||
@@ -110,8 +114,31 @@ const models = ref<ModelItem[]>([])
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const activeCategory = ref('')
|
||||
// 厂商多选:选中的厂商按 OR 过滤,空数组 = 不过滤(全部)
|
||||
const activeVendors = ref<string[]>([])
|
||||
const expandedPricingId = ref<number | null>(null)
|
||||
|
||||
function toggleVendor(vendor: string) {
|
||||
activeVendors.value = activeVendors.value.includes(vendor)
|
||||
? activeVendors.value.filter(v => v !== vendor)
|
||||
: [...activeVendors.value, vendor]
|
||||
}
|
||||
|
||||
// 平铺厂商项:当前分类+搜索(忽略厂商筛选本身)下有模型的厂商,字典序在前、字典外原值追加;
|
||||
// 已选厂商即使当前无匹配模型也保留,避免筛选生效但按钮消失导致无法取消
|
||||
const vendorChips = computed(() => {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
const pool = models.value.filter(m =>
|
||||
(!activeCategory.value || m.category === activeCategory.value)
|
||||
&& (!q || m.model_id.toLowerCase().includes(q) || m.model_name.toLowerCase().includes(q)))
|
||||
const available = new Set(pool.map(m => m.vendor).filter((v): v is string => !!v))
|
||||
for (const v of activeVendors.value) available.add(v)
|
||||
const knownValues = new Set(vendorMetaList.map(v => v.value))
|
||||
const known = vendorMetaList.filter(v => available.has(v.value)).map(v => ({ value: v.value, label: v.label }))
|
||||
const unknown = [...available].filter(v => !knownValues.has(v)).map(v => ({ value: v, label: vendorLabel(v) }))
|
||||
return [...known, ...unknown]
|
||||
})
|
||||
|
||||
function togglePricingExpand(id: number) {
|
||||
expandedPricingId.value = expandedPricingId.value === id ? null : id
|
||||
}
|
||||
@@ -146,6 +173,9 @@ const filteredModels = computed(() => {
|
||||
if (activeCategory.value) {
|
||||
result = result.filter((m) => m.category === activeCategory.value)
|
||||
}
|
||||
if (activeVendors.value.length) {
|
||||
result = result.filter((m) => !!m.vendor && activeVendors.value.includes(m.vendor))
|
||||
}
|
||||
if (searchQuery.value) {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
result = result.filter(
|
||||
@@ -296,6 +326,39 @@ onMounted(fetchModels)
|
||||
</n-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 厂商平铺多选:只显示当前条件下有模型的厂商,点选过滤(OR),「全部」清空 -->
|
||||
<div v-if="vendorChips.length" class="flex flex-wrap items-center gap-2 border-t border-gray-100 px-6 pb-4 pt-3">
|
||||
<span class="mr-1 text-xs font-medium text-gray-400">厂商</span>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-all duration-200 active:scale-[0.98]"
|
||||
:class="
|
||||
activeVendors.length === 0
|
||||
? 'border-transparent bg-gray-900 text-white'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:border-primary-300 hover:text-primary-700'
|
||||
"
|
||||
@click="activeVendors = []"
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
v-for="vendor in vendorChips"
|
||||
:key="vendor.value"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-all duration-200 active:scale-[0.98]"
|
||||
:class="
|
||||
activeVendors.includes(vendor.value)
|
||||
? 'border-transparent bg-gray-900 text-white'
|
||||
: 'border-gray-200 bg-white text-gray-600 hover:border-primary-300 hover:text-primary-700'
|
||||
"
|
||||
@click="toggleVendor(vendor.value)"
|
||||
>
|
||||
<VendorLogo :vendor="vendor.value" size="xs" />
|
||||
{{ vendor.label }}
|
||||
<Icon v-if="activeVendors.includes(vendor.value)" name="check" size="xs" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
@@ -345,8 +408,9 @@ onMounted(fetchModels)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Header: Category badge + Model name -->
|
||||
<!-- Header: Vendor logo + Category badge + Model name -->
|
||||
<div class="flex items-center gap-2" :class="{ 'pr-14': m.discount_ratio && m.discount_ratio < 1 }">
|
||||
<VendorLogo :vendor="m.vendor" size="sm" class="shrink-0" />
|
||||
<span
|
||||
class="shrink-0 px-1.5 py-0.5 text-[10px] font-medium rounded"
|
||||
:style="categoryBadgeStyle[m.category] || categoryBadgeStyle.rerank"
|
||||
|
||||