diff --git a/CLAUDE.md b/CLAUDE.md index 79ff840..8573818 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 认领即删。 diff --git a/api/admin/v1/model.go b/api/admin/v1/model.go index f69a7dd..de52cdf 100644 --- a/api/admin/v1/model.go +++ b/api/admin/v1/model.go @@ -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 数"` diff --git a/api/admin/v1/tenant.go b/api/admin/v1/tenant.go index 57c40a9..dc240ea 100644 --- a/api/admin/v1/tenant.go +++ b/api/admin/v1/tenant.go @@ -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{} diff --git a/api/tenant/v1/marketplace.go b/api/tenant/v1/marketplace.go index 40dc531..a9b432a 100644 --- a/api/tenant/v1/marketplace.go +++ b/api/tenant/v1/marketplace.go @@ -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 diff --git a/api/tenant/v1/models.go b/api/tenant/v1/models.go index 55eb3e6..79d6866 100644 --- a/api/tenant/v1/models.go +++ b/api/tenant/v1/models.go @@ -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"` diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 5ca5c39..23d5276 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -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) diff --git a/internal/dao/internal/bil_wallets.go b/internal/dao/internal/bil_wallets.go index 2d84fa0..f5d8ceb 100644 --- a/internal/dao/internal/bil_wallets.go +++ b/internal/dao/internal/bil_wallets.go @@ -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. diff --git a/internal/dao/internal/mdl_models.go b/internal/dao/internal/mdl_models.go index 88fe9ec..49998fe 100644 --- a/internal/dao/internal/mdl_models.go +++ b/internal/dao/internal/mdl_models.go @@ -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. diff --git a/internal/dao/internal/mdl_pricing.go b/internal/dao/internal/mdl_pricing.go index b207f94..e5e754d 100644 --- a/internal/dao/internal/mdl_pricing.go +++ b/internal/dao/internal/mdl_pricing.go @@ -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. diff --git a/internal/dao/internal/tnt_tenants.go b/internal/dao/internal/tnt_tenants.go index 0fba4f9..fbb7b1a 100644 --- a/internal/dao/internal/tnt_tenants.go +++ b/internal/dao/internal/tnt_tenants.go @@ -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. diff --git a/internal/logic/admin/model.go b/internal/logic/admin/model.go index e7e2fec..1350ff7 100644 --- a/internal/logic/admin/model.go +++ b/internal/logic/admin/model.go @@ -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 diff --git a/internal/logic/admin/model_import_export.go b/internal/logic/admin/model_import_export.go index a16149f..886d308 100644 --- a/internal/logic/admin/model_import_export.go +++ b/internal/logic/admin/model_import_export.go @@ -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, diff --git a/internal/logic/admin/tenant.go b/internal/logic/admin/tenant.go index 4c4f408..2c84896 100644 --- a/internal/logic/admin/tenant.go +++ b/internal/logic/admin/tenant.go @@ -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) { diff --git a/internal/logic/admin/tenant_model.go b/internal/logic/admin/tenant_model.go index 26bc783..a8b2643 100644 --- a/internal/logic/admin/tenant_model.go +++ b/internal/logic/admin/tenant_model.go @@ -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 } diff --git a/internal/logic/admin/tenant_tags_test.go b/internal/logic/admin/tenant_tags_test.go new file mode 100644 index 0000000..2c5184b --- /dev/null +++ b/internal/logic/admin/tenant_tags_test.go @@ -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) + } +} diff --git a/internal/logic/billing/reconciliation.go b/internal/logic/billing/reconciliation.go index 330fa28..b13d0e8 100644 --- a/internal/logic/billing/reconciliation.go +++ b/internal/logic/billing/reconciliation.go @@ -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 } diff --git a/internal/logic/billing/settlement.go b/internal/logic/billing/settlement.go index 6f2a522..4792bee 100644 --- a/internal/logic/billing/settlement.go +++ b/internal/logic/billing/settlement.go @@ -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) diff --git a/internal/logic/billing/sweeper.go b/internal/logic/billing/sweeper.go index 5708fb9..d3e0952 100644 --- a/internal/logic/billing/sweeper.go +++ b/internal/logic/billing/sweeper.go @@ -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) diff --git a/internal/logic/billing/wallet.go b/internal/logic/billing/wallet.go index c219282..58e9b96 100644 --- a/internal/logic/billing/wallet.go +++ b/internal/logic/billing/wallet.go @@ -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 重建而回滚未物化增量。 diff --git a/internal/logic/billing/wallet_lua_test.go b/internal/logic/billing/wallet_lua_test.go index eee53fd..2eeefba 100644 --- a/internal/logic/billing/wallet_lua_test.go +++ b/internal/logic/billing/wallet_lua_test.go @@ -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) + } +} diff --git a/internal/logic/billing/wallet_seed.go b/internal/logic/billing/wallet_seed.go new file mode 100644 index 0000000..0b5e351 --- /dev/null +++ b/internal/logic/billing/wallet_seed.go @@ -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)) + } +} diff --git a/internal/logic/common/tenant_model.go b/internal/logic/common/tenant_model.go index a2f06b2..ee1c014 100644 --- a/internal/logic/common/tenant_model.go +++ b/internal/logic/common/tenant_model.go @@ -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, diff --git a/internal/logic/tenant/marketplace.go b/internal/logic/tenant/marketplace.go index 5cbfee5..c461cb6 100644 --- a/internal/logic/tenant/marketplace.go +++ b/internal/logic/tenant/marketplace.go @@ -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, diff --git a/internal/logic/tenant/model.go b/internal/logic/tenant/model.go index 06f802f..2f7fcdd 100644 --- a/internal/logic/tenant/model.go +++ b/internal/logic/tenant/model.go @@ -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, diff --git a/internal/model/do/bil_wallets.go b/internal/model/do/bil_wallets.go index 67fdda5..e11122c 100644 --- a/internal/model/do/bil_wallets.go +++ b/internal/model/do/bil_wallets.go @@ -23,4 +23,5 @@ type BilWallets struct { UpdatedAt *gtime.Time // 更新时间 CumulativeRecharge any // 累计充值总额(USD) LowBalanceNotified any // 低余额预警是否已推送(充值恢复后重置为 false) + TotalConsumed any // 累计消费总额(本位币;Redis 钱包 hash total_consumed 的物化副本,随结算事件递增/补偿递减) } diff --git a/internal/model/do/mdl_models.go b/internal/model/do/mdl_models.go index 0a54954..5bc4e80 100644 --- a/internal/model/do/mdl_models.go +++ b/internal/model/do/mdl_models.go @@ -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,空串=未分类 } diff --git a/internal/model/do/mdl_pricing.go b/internal/model/do/mdl_pricing.go index 4fcec57..ed361fb 100644 --- a/internal/model/do/mdl_pricing.go +++ b/internal/model/do/mdl_pricing.go @@ -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 键,本位币;仅作定价折扣计算基准与几折展示,非计费依据) } diff --git a/internal/model/do/tnt_tenants.go b/internal/model/do/tnt_tenants.go index ec96349..6f6e881 100644 --- a/internal/model/do/tnt_tenants.go +++ b/internal/model/do/tnt_tenants.go @@ -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 // 管理员备注(运营备注,不暴露给租户控制台) } diff --git a/internal/model/entity/bil_wallets.go b/internal/model/entity/bil_wallets.go index 9b68c57..6dcf5fb 100644 --- a/internal/model/entity/bil_wallets.go +++ b/internal/model/entity/bil_wallets.go @@ -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 的物化副本,随结算事件递增/补偿递减) } diff --git a/internal/model/entity/mdl_models.go b/internal/model/entity/mdl_models.go index 5fa76c0..e09e9dc 100644 --- a/internal/model/entity/mdl_models.go +++ b/internal/model/entity/mdl_models.go @@ -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,空串=未分类 } diff --git a/internal/model/entity/mdl_pricing.go b/internal/model/entity/mdl_pricing.go index 1691874..a969082 100644 --- a/internal/model/entity/mdl_pricing.go +++ b/internal/model/entity/mdl_pricing.go @@ -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 键,本位币;仅作定价折扣计算基准与几折展示,非计费依据) } diff --git a/internal/model/entity/tnt_tenants.go b/internal/model/entity/tnt_tenants.go index 77adadc..8750a72 100644 --- a/internal/model/entity/tnt_tenants.go +++ b/internal/model/entity/tnt_tenants.go @@ -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:"管理员备注(运营备注,不暴露给租户控制台)"` // 管理员备注(运营备注,不暴露给租户控制台) } diff --git a/internal/service/admin.go b/internal/service/admin.go index 26ce8f7..0007d16 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -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) diff --git a/internal/service/tenant.go b/internal/service/tenant.go index 4b505b0..af85b76 100644 --- a/internal/service/tenant.go +++ b/internal/service/tenant.go @@ -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) diff --git a/tests/integration/admin/tenant_tags_test.go b/tests/integration/admin/tenant_tags_test.go new file mode 100644 index 0000000..26385b0 --- /dev/null +++ b/tests/integration/admin/tenant_tags_test.go @@ -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) + } +} diff --git a/web/admin/src/views/ai/ModelsPage.vue b/web/admin/src/views/ai/ModelsPage.vue index 5b722d1..ce845a1 100644 --- a/web/admin/src/views/ai/ModelsPage.vue +++ b/web/admin/src/views/ai/ModelsPage.vue @@ -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([]) @@ -23,6 +24,7 @@ const pagination = reactive({ }) const filterCategory = ref(null) +const filterVendor = ref(null) const filterStatus = ref(null) const filterSearch = ref('') const filterPricingStatus = ref(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, @@ -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" /> + + + +
diff --git a/web/admin/src/views/tenant/TenantsPage.vue b/web/admin/src/views/tenant/TenantsPage.vue index 848def8..0d33b3d 100644 --- a/web/admin/src/views/tenant/TenantsPage.vue +++ b/web/admin/src/views/tenant/TenantsPage.vue @@ -26,6 +26,8 @@ const pagination = reactive({ const filterStatus = ref(null) const filterSearch = ref('') +const filterTag = ref('') +const filterLevel = ref(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(() => [ { 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(() => [ 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, }), }) @@ -312,6 +349,17 @@ const { exporting, exportFile } = useExport({ style="width: 120px" @change="handleFilter" /> + + + {{ opt.name }} + + + 搜索 @@ -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']" /> + + +
+ 厂商 + + +
@@ -345,8 +408,9 @@ onMounted(fetchModels) - +
+