diff --git a/CLAUDE.md b/CLAUDE.md index 3c26385..4145c5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -258,16 +258,25 @@ cmd(路由注册) ### 计费流程 ``` -预扣(Redis 原子操作)→ 转发请求 → 结算(实际用量)→ 退款/补扣差额 +预扣(Redis Lua 原子冻结)→ 转发请求 → 结算(幂等闸门 → Redis 认领扣款 → 记流水)→ 退款/补扣差额 ``` 价格查询优先级:租户独立价 > 套餐价 > 模型基础价 > 硬编码默认。 最终价格公式:`最终价格 = 基础价格 × 模型乘数 × 租户乘数`。模型乘数根据模型稀有度/成本动态调整;租户乘数根据租户等级/用量等级设置(VIP 折扣等)。双层乘数替代单纯的"租户独立定价",提供更灵活的定价能力。 -### 钱包冻结余额 +### 钱包状态:Redis 权威 + DB 物化(强约束) -wallets 表包含 `frozen_balance` 字段,用于支付中/退款中的金额冻结,防止并发超扣。可用余额 = `balance - frozen_balance`。预扣和支付操作需检查可用余额而非总余额。 +钱包资金状态采用 **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**:`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 认领即删。 +- **Redis 服务不可用**:预扣/结算 fail-closed(拒绝请求),禁止降级放行无额度保护的流量;接受 Redis 数据丢失窗口内的少量超扣。 +- 充值/兑换/调账/退款等低频写路径:**Redis Lua 为提交点**(实时生效)→ DB 只写账本流水与累计充值(余额由物化器覆盖),事务回滚时补偿逆转 Redis。 +- 流水(`bil_transactions`)的 `balance_after / frozen_after` 一律取 Redis Lua 返回值(账本链连续可信)。 ### 系统货币规则 diff --git a/api/admin/admin.go b/api/admin/admin.go index cb702e7..a1bbc83 100644 --- a/api/admin/admin.go +++ b/api/admin/admin.go @@ -63,6 +63,7 @@ type IAdminV1 interface { ChannelHealthTrend(ctx context.Context, req *v1.ChannelHealthTrendReq) (res *v1.ChannelHealthTrendRes, err error) ChannelExport(ctx context.Context, req *v1.ChannelExportReq) (res *v1.ChannelExportRes, err error) ChannelClone(ctx context.Context, req *v1.ChannelCloneReq) (res *v1.ChannelCloneRes, err error) + ChannelResetHealth(ctx context.Context, req *v1.ChannelResetHealthReq) (res *v1.ChannelResetHealthRes, err error) ChannelErrorEventList(ctx context.Context, req *v1.ChannelErrorEventListReq) (res *v1.ChannelErrorEventListRes, err error) ChannelErrorStats(ctx context.Context, req *v1.ChannelErrorStatsReq) (res *v1.ChannelErrorStatsRes, err error) ChannelErrorTrend(ctx context.Context, req *v1.ChannelErrorTrendReq) (res *v1.ChannelErrorTrendRes, err error) @@ -86,7 +87,11 @@ type IAdminV1 interface { AdminWalletList(ctx context.Context, req *v1.AdminWalletListReq) (res *v1.AdminWalletListRes, err error) AdminWalletInfo(ctx context.Context, req *v1.AdminWalletInfoReq) (res *v1.AdminWalletInfoRes, err error) AdminWalletAdjust(ctx context.Context, req *v1.AdminWalletAdjustReq) (res *v1.AdminWalletAdjustRes, err error) + AdminWalletOfflineRecharge(ctx context.Context, req *v1.AdminWalletOfflineRechargeReq) (res *v1.AdminWalletOfflineRechargeRes, err error) AdminWalletTransactionList(ctx context.Context, req *v1.AdminWalletTransactionListReq) (res *v1.AdminWalletTransactionListRes, err error) + AdminWalletFrozenItemList(ctx context.Context, req *v1.AdminWalletFrozenItemListReq) (res *v1.AdminWalletFrozenItemListRes, err error) + AdminWalletFrozenRelease(ctx context.Context, req *v1.AdminWalletFrozenReleaseReq) (res *v1.AdminWalletFrozenReleaseRes, err error) + AdminWalletFrozenReleaseAll(ctx context.Context, req *v1.AdminWalletFrozenReleaseAllReq) (res *v1.AdminWalletFrozenReleaseAllRes, err error) AdminWalletSetWarningThreshold(ctx context.Context, req *v1.AdminWalletSetWarningThresholdReq) (res *v1.AdminWalletSetWarningThresholdRes, err error) AdminUsageLogExport(ctx context.Context, req *v1.AdminUsageLogExportReq) (res *v1.AdminUsageLogExportRes, err error) AdminBillingRecordExport(ctx context.Context, req *v1.AdminBillingRecordExportReq) (res *v1.AdminBillingRecordExportRes, err error) diff --git a/api/admin/v1/audit.go b/api/admin/v1/audit.go index 3b1d085..2647980 100644 --- a/api/admin/v1/audit.go +++ b/api/admin/v1/audit.go @@ -62,7 +62,9 @@ type RequestAuditLogListReq struct { g.Meta `path:"/audit/request-logs" method:"get" mime:"json" tags:"管理后台-审计" summary:"请求审计日志"` Page int `json:"page" d:"1"` PageSize int `json:"page_size" d:"20"` + ID int64 `json:"id" dc:"日志ID"` TenantID int `json:"tenant_id" dc:"租户ID"` + UserID int64 `json:"user_id" dc:"用户ID"` ApiKeyID int `json:"api_key_id" dc:"API Key ID"` Username string `json:"username" dc:"用户名(模糊匹配)"` RequestId string `json:"request_id" dc:"Request ID(精确匹配)"` @@ -70,8 +72,8 @@ type RequestAuditLogListReq struct { Method string `json:"method" dc:"HTTP 方法"` Path string `json:"path" dc:"请求路径"` StatusCode int `json:"status_code" dc:"状态码"` - StartDate string `json:"start_date" dc:"开始日期"` - EndDate string `json:"end_date" dc:"结束日期"` + StartDate string `json:"start_date" dc:"开始时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"` + EndDate string `json:"end_date" dc:"结束时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"` } type RequestAuditLogListRes struct { diff --git a/api/admin/v1/channel.go b/api/admin/v1/channel.go index a51f580..8205d27 100644 --- a/api/admin/v1/channel.go +++ b/api/admin/v1/channel.go @@ -9,7 +9,10 @@ type ChannelListReq struct { PageSize int `json:"page_size" d:"20" v:"min:1|max:100" dc:"每页数量"` Type int `json:"type" dc:"供应商类型筛选"` Status string `json:"status" dc:"状态筛选:active/disabled/testing"` - Search string `json:"search" dc:"搜索关键词"` + Search string `json:"search" dc:"搜索关键词(渠道名称/备注)"` + ID int64 `json:"id" dc:"按渠道 ID 精确筛选"` + Model string `json:"model" dc:"按支持的模型名筛选(模糊匹配平台模型名/上游模型名)"` + BaseURL string `json:"base_url" dc:"按 API 地址筛选(模糊匹配)"` } // ChannelListRes 渠道列表响应 @@ -42,6 +45,8 @@ type ChannelItem struct { BorrowingCooldownSeconds *int `json:"borrowing_cooldown_seconds"` CreatedAt string `json:"created_at"` HealthScore *float64 `json:"health_score"` + BreakerState int `json:"breaker_state"` // 调度熔断状态:0=正常 1=熔断中 2=半开探活 + BreakerModels int `json:"breaker_models"` // 处于熔断/半开的模型数量(渠道×模型级汇总) } // ChannelCreateReq 创建渠道请求 @@ -128,6 +133,8 @@ type ChannelDetailRes struct { CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` HealthScore *float64 `json:"health_score"` + BreakerState int `json:"breaker_state"` // 调度熔断状态:0=正常 1=熔断中 2=半开探活 + BreakerModels int `json:"breaker_models"` // 处于熔断/半开的模型数量(渠道×模型级汇总) KeyType string `json:"key_type"` KeyStatus string `json:"key_status"` KeyName string `json:"key_name"` @@ -246,11 +253,14 @@ type HealthTrendPoint struct { // ChannelExportReq 导出渠道列表请求 type ChannelExportReq struct { - g.Meta `path:"/channels/export" method:"get" mime:"json" tags:"管理后台-渠道" summary:"导出渠道列表"` - Format string `json:"format" in:"query" d:"csv" v:"in:csv,xlsx" dc:"导出格式:csv / xlsx"` - Type int `json:"type" in:"query" dc:"供应商类型筛选"` - Status string `json:"status" in:"query" dc:"状态筛选:active/disabled/testing"` - Search string `json:"search" in:"query" dc:"搜索关键词"` + g.Meta `path:"/channels/export" method:"get" mime:"json" tags:"管理后台-渠道" summary:"导出渠道列表"` + Format string `json:"format" in:"query" d:"csv" v:"in:csv,xlsx" dc:"导出格式:csv / xlsx"` + Type int `json:"type" in:"query" dc:"供应商类型筛选"` + Status string `json:"status" in:"query" dc:"状态筛选:active/disabled/testing"` + Search string `json:"search" in:"query" dc:"搜索关键词(渠道名称/备注)"` + ID int64 `json:"id" in:"query" dc:"按渠道 ID 精确筛选"` + Model string `json:"model" in:"query" dc:"按支持的模型名筛选(模糊匹配平台模型名/上游模型名)"` + BaseURL string `json:"base_url" in:"query" dc:"按 API 地址筛选(模糊匹配)"` } type ChannelExportRes struct{} @@ -267,3 +277,12 @@ type ChannelCloneReq struct { type ChannelCloneRes struct { ID int64 `json:"id"` } + +// ChannelResetHealthReq 重置渠道健康度请求 +type ChannelResetHealthReq struct { + g.Meta `path:"/channels/{id}/reset-health" method:"post" mime:"json" tags:"管理后台-渠道" summary:"重置渠道健康度"` + ID int64 `json:"id" in:"path" v:"required" dc:"渠道ID"` +} + +// ChannelResetHealthRes 重置渠道健康度响应 +type ChannelResetHealthRes struct{} diff --git a/api/admin/v1/dashboard.go b/api/admin/v1/dashboard.go index 4baed2c..b7875d8 100644 --- a/api/admin/v1/dashboard.go +++ b/api/admin/v1/dashboard.go @@ -122,13 +122,17 @@ type AdminUsageLogListReq struct { g.Meta `path:"/usage-logs" method:"get" mime:"json" tags:"管理后台-用量" summary:"用量日志列表"` Page int `json:"page" d:"1" dc:"页码"` PageSize int `json:"page_size" d:"20" v:"between:1,100" dc:"每页数量"` + ID int64 `json:"id" dc:"用量记录ID"` TenantID int64 `json:"tenant_id" dc:"租户ID"` + UserID int64 `json:"user_id" dc:"用户ID"` Username string `json:"username" dc:"用户名(模糊匹配)"` + ApiKeyID int64 `json:"api_key_id" dc:"API Key ID"` + ChannelID int64 `json:"channel_id" dc:"渠道ID"` Model string `json:"model" dc:"模型名称"` Status string `json:"status" dc:"状态"` RequestType int `json:"request_type" dc:"请求类型: 1=同步, 2=流式, 3=异步, 4=WebSocket"` - StartDate string `json:"start_date" dc:"开始日期"` - EndDate string `json:"end_date" dc:"结束日期"` + StartDate string `json:"start_date" dc:"开始时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"` + EndDate string `json:"end_date" dc:"结束时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"` } type AdminUsageLogItem struct { @@ -282,6 +286,20 @@ type AdminWalletAdjustReq struct { type AdminWalletAdjustRes struct{} +type AdminWalletOfflineRechargeReq struct { + g.Meta `path:"/wallets/{tenant_id}/offline-recharge" method:"post" mime:"json" tags:"管理后台-钱包" summary:"线下充值入账"` + TenantID int64 `json:"tenant_id" in:"path" v:"required|min:1" dc:"租户ID"` + Amount float64 `json:"amount" v:"required|min:0.01" dc:"线下入账金额(人民币 CNY),按平台汇率换算为 USD 到账"` + TransactionNo string `json:"transaction_no" dc:"银行转账流水号(选填,用于对账与防重复入账)"` + Description string `json:"description" v:"required" dc:"入账说明(必填,如客户名称、转账原因)"` +} + +type AdminWalletOfflineRechargeRes struct { + CreditedUSD float64 `json:"credited_usd" dc:"实际入账美元金额"` + Rate float64 `json:"rate" dc:"使用的 CNY→USD 汇率"` + Balance float64 `json:"balance" dc:"入账后总余额(USD)"` +} + type AdminWalletTransactionListReq struct { g.Meta `path:"/wallets/{tenant_id}/transactions" method:"get" mime:"json" tags:"管理后台-钱包" summary:"交易流水"` TenantID int64 `json:"tenant_id" in:"path" v:"required|min:1" dc:"租户ID"` @@ -311,6 +329,58 @@ type AdminWalletTransactionListRes struct { PageSize int `json:"page_size"` } +// AdminWalletFrozenItemListReq 冻结明细列表请求(预扣追踪表中 status=frozen 的记录) +type AdminWalletFrozenItemListReq struct { + g.Meta `path:"/wallets/{tenant_id}/frozen-items" method:"get" mime:"json" tags:"管理后台-钱包" summary:"冻结明细列表"` + TenantID int64 `json:"tenant_id" in:"path" v:"required|min:1" dc:"租户ID"` +} + +// AdminWalletFrozenItem 单笔冻结明细 +type AdminWalletFrozenItem struct { + RequestID string `json:"request_id" dc:"请求ID"` + ModelName string `json:"model_name" dc:"模型名称"` + Amount float64 `json:"amount" dc:"冻结金额(USD)"` + CreatedAt string `json:"created_at" dc:"冻结时间"` + AgeSeconds int64 `json:"age_seconds" dc:"已冻结时长(秒)"` + Releasable bool `json:"releasable" dc:"是否可释放(false 时 block_reason 说明原因)"` + NeedForce bool `json:"need_force" dc:"是否需要强制释放(冻结时长过短,可能仍有请求进行中)"` + BlockReason string `json:"block_reason" dc:"不可释放原因(如关联异步任务进行中)"` + TaskStatus string `json:"task_status" dc:"关联异步任务状态(无关联时为空)"` +} + +type AdminWalletFrozenItemListRes struct { + List []*AdminWalletFrozenItem `json:"list"` +} + +// AdminWalletFrozenReleaseReq 按笔释放冻结请求 +type AdminWalletFrozenReleaseReq struct { + g.Meta `path:"/wallets/{tenant_id}/frozen-items/release" method:"post" mime:"json" tags:"管理后台-钱包" summary:"释放单笔冻结"` + TenantID int64 `json:"tenant_id" in:"path" v:"required|min:1" dc:"租户ID"` + RequestID string `json:"request_id" v:"required" dc:"要释放的冻结项请求ID"` + Force bool `json:"force" dc:"强制释放(冻结时长不足保护期时必须显式传 true)"` + Reason string `json:"reason" v:"required" dc:"释放原因(必填,写入审计)"` +} + +type AdminWalletFrozenReleaseRes struct { + ReleasedAmount float64 `json:"released_amount" dc:"实际释放金额(USD)"` +} + +// AdminWalletFrozenReleaseAllReq 一键释放全部冻结请求 +type AdminWalletFrozenReleaseAllReq struct { + g.Meta `path:"/wallets/{tenant_id}/frozen-items/release-all" method:"post" mime:"json" tags:"管理后台-钱包" summary:"一键释放全部冻结"` + TenantID int64 `json:"tenant_id" in:"path" v:"required|min:1" dc:"租户ID"` + Force bool `json:"force" dc:"是否强制释放保护期内(冻结不足 10 分钟)的冻结项"` + Reason string `json:"reason" v:"required" dc:"释放原因(必填,写入审计)"` +} + +// AdminWalletFrozenReleaseAllRes 一键释放结果 +type AdminWalletFrozenReleaseAllRes struct { + ReleasedCount int64 `json:"released_count" dc:"实际释放笔数"` + ReleasedAmount float64 `json:"released_amount" dc:"实际释放金额合计(USD)"` + SkippedCount int64 `json:"skipped_count" dc:"跳过笔数(任务关联/待结算/保护期未强制)"` + SkippedReasons []string `json:"skipped_reasons" dc:"跳过原因摘要(去重)"` +} + type AdminWalletSetWarningThresholdReq struct { g.Meta `path:"/wallets/{tenant_id}/warning-threshold" method:"put" mime:"json" tags:"管理后台-钱包" summary:"设置预警阈值"` TenantID int64 `json:"tenant_id" in:"path" v:"required|min:1" dc:"租户ID"` @@ -323,13 +393,17 @@ type AdminWalletSetWarningThresholdRes struct{} type AdminUsageLogExportReq struct { g.Meta `path:"/usage-logs/export" method:"get" mime:"json" tags:"管理后台-用量" summary:"导出用量日志"` Format string `json:"format" in:"query" d:"csv" v:"in:csv,xlsx" dc:"导出格式:csv / xlsx"` + ID int64 `json:"id" in:"query" dc:"用量记录ID"` TenantID int64 `json:"tenant_id" in:"query" dc:"租户ID"` + UserID int64 `json:"user_id" in:"query" dc:"用户ID"` Username string `json:"username" in:"query" dc:"用户名(模糊匹配)"` + ApiKeyID int64 `json:"api_key_id" in:"query" dc:"API Key ID"` + ChannelID int64 `json:"channel_id" in:"query" dc:"渠道ID"` Model string `json:"model" in:"query" dc:"模型名称"` Status string `json:"status" in:"query" dc:"状态"` RequestType int `json:"request_type" in:"query" dc:"请求类型: 1=同步, 2=流式, 3=异步, 4=WebSocket"` - StartDate string `json:"start_date" in:"query" dc:"开始日期"` - EndDate string `json:"end_date" in:"query" dc:"结束日期"` + StartDate string `json:"start_date" in:"query" dc:"开始时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"` + EndDate string `json:"end_date" in:"query" dc:"结束时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"` } type AdminUsageLogExportRes struct{} diff --git a/api/admin/v1/task_management.go b/api/admin/v1/task_management.go index c2c2299..3e412e3 100644 --- a/api/admin/v1/task_management.go +++ b/api/admin/v1/task_management.go @@ -11,6 +11,11 @@ type TaskListReq struct { Status string `json:"status" in:"query" dc:"筛选状态"` Platform string `json:"platform" in:"query" dc:"筛选平台(sora/kling/midjourney/suno)"` PublicTaskID string `json:"public_task_id" in:"query" dc:"任务ID(精确匹配)"` + ModelName string `json:"model_name" in:"query" dc:"筛选模型名(精确匹配)"` + TenantID int64 `json:"tenant_id" in:"query" dc:"筛选租户ID"` + UserID int64 `json:"user_id" in:"query" dc:"筛选用户ID"` + StartDate string `json:"start_date" in:"query" dc:"开始时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"` + EndDate string `json:"end_date" in:"query" dc:"结束时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"` } type ModelTaskItem struct { diff --git a/api/tenant/v1/models.go b/api/tenant/v1/models.go index 004e46e..e2f674d 100644 --- a/api/tenant/v1/models.go +++ b/api/tenant/v1/models.go @@ -7,6 +7,8 @@ type TenantAvailableModelsReq struct { g.Meta `path:"/models" method:"get" mime:"json" tags:"租户控制台-模型" summary:"租户可用模型列表"` Category string `json:"category" dc:"模型分类筛选:chat/embedding/image/audio/rerank"` Search string `json:"search" dc:"搜索关键词(模型名或显示名)"` + // ApiKeyID 按指定 API Key 的模型范围过滤(可选;不传或为 0 时返回租户全部可用模型) + ApiKeyID int64 `json:"api_key_id" dc:"API Key ID(可选,传入时按该 Key 的模型范围过滤)"` } type TenantAvailableModelsRes struct { diff --git a/api/tenant/v1/order_tenant.go b/api/tenant/v1/order_tenant.go index 0f6a447..8b3397a 100644 --- a/api/tenant/v1/order_tenant.go +++ b/api/tenant/v1/order_tenant.go @@ -119,6 +119,7 @@ type TenantPaymentInfoRes struct { AmountDiscount map[int]float64 `json:"amount_discount,omitempty"` MinTopUp int `json:"min_topup,omitempty"` Currency string `json:"currency,omitempty"` + PaymentNotice string `json:"payment_notice,omitempty"` } // TenantOrderExportReq 导出订单列表请求 diff --git a/api/tenant/v1/task_tenant.go b/api/tenant/v1/task_tenant.go index 2d16891..4a4d631 100644 --- a/api/tenant/v1/task_tenant.go +++ b/api/tenant/v1/task_tenant.go @@ -11,6 +11,8 @@ type TenantTaskListReq struct { Status string `json:"status" in:"query" dc:"筛选状态"` Platform string `json:"platform" in:"query" dc:"筛选平台(sora/kling/midjourney/suno)"` PublicTaskID string `json:"public_task_id" in:"query" dc:"任务ID(精确匹配)"` + StartDate string `json:"start_date" in:"query" dc:"开始日期 YYYY-MM-DD"` + EndDate string `json:"end_date" in:"query" dc:"结束日期 YYYY-MM-DD"` } type TenantTaskItem struct { @@ -56,6 +58,8 @@ type TenantTaskExportReq struct { Status string `json:"status" in:"query" dc:"筛选状态"` Platform string `json:"platform" in:"query" dc:"筛选平台(sora/kling/midjourney/suno)"` PublicTaskID string `json:"public_task_id" in:"query" dc:"任务ID(精确匹配)"` + StartDate string `json:"start_date" in:"query" dc:"开始日期 YYYY-MM-DD"` + EndDate string `json:"end_date" in:"query" dc:"结束日期 YYYY-MM-DD"` } type TenantTaskExportRes struct{} diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 36c43c1..e976bf7 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -33,6 +33,7 @@ import ( "github.com/qianfree/team-api/internal/utility/crypto" adminHandler "github.com/qianfree/team-api/internal/handler/admin" + "github.com/qianfree/team-api/internal/handler/landing" "github.com/qianfree/team-api/internal/handler/public" "github.com/qianfree/team-api/internal/handler/relay" setupHandler "github.com/qianfree/team-api/internal/handler/setup" @@ -127,6 +128,9 @@ var ( registerCronJobs(cs) cs.StartBackground(ctx) + // 启动钱包物化器(boot goroutine,秒级刷新 Redis 权威钱包状态到 DB;不走 cron) + billing.StartWalletMaterializer(ctx) + // Initialize update manager update.InitManager(ctx) update.CheckPendingVerification(ctx) @@ -258,6 +262,9 @@ var ( // Embedded frontend SPA serving registerFrontendRoutes(s) + // Landing page for backend root when frontend is not embedded + registerLandingPage(s) + // Migrate encryption key (legacy default → configured key) relayLogic.MigrateEncryptionKey(ctx) @@ -380,12 +387,8 @@ func registerCronJobs(cs *common.CronScheduler) { cs.Register("oauth_token_refresh", "OAuth 令牌刷新", "*/10 * * * *", func(ctx context.Context) error { return task.RefreshExpiringOAuthTokens(ctx) }) - cs.Register("prededuct_orphan_cleanup", "预扣孤儿清理", "*/2 * * * *", func(ctx context.Context) error { - billing.CleanExpiredPreDeducts(ctx) - return nil - }) - cs.Register("prededuct_tracks_cleanup", "预扣轨迹清理", "0 4 * * *", func(ctx context.Context) error { - billing.CleanSettledPreDeductTracks(ctx) + cs.Register("prededuct_sweep", "预扣清扫", "*/2 * * * *", func(ctx context.Context) error { + billing.PredeductSweep(ctx) return nil }) cs.Register("billing_daily_reconciliation", "计费日对账", "20 5 * * *", func(ctx context.Context) error { @@ -469,6 +472,18 @@ func registerRelayRoutes(server *ghttp.Server) { }) } +// registerLandingPage 在未内嵌前端(非 embedweb 构建)时,为后端根路径注册项目介绍页, +// 避免浏览器直接访问后端地址时显示 404。内嵌前端时根路径由租户 SPA 承接,此页不注册。 +func registerLandingPage(s *ghttp.Server) { + if web.Enabled { + return + } + s.Group("/", func(group *ghttp.RouterGroup) { + group.GET("/", landing.HandleLanding) + group.GET("/index.html", landing.HandleLanding) + }) +} + // registerFrontendRoutes serves embedded frontend SPA assets. // Admin console at /admin, tenant console at / (catch-all). // Existing API routes take priority over these wildcard routes. diff --git a/internal/controller/admin/admin_v1_channel.go b/internal/controller/admin/admin_v1_channel.go index 45a9b3e..b6539e8 100644 --- a/internal/controller/admin/admin_v1_channel.go +++ b/internal/controller/admin/admin_v1_channel.go @@ -49,3 +49,6 @@ func (c *ControllerV1) ChannelExport(ctx context.Context, req *v1.ChannelExportR func (c *ControllerV1) ChannelClone(ctx context.Context, req *v1.ChannelCloneReq) (res *v1.ChannelCloneRes, err error) { return service.Admin().CloneChannel(ctx, req) } +func (c *ControllerV1) ChannelResetHealth(ctx context.Context, req *v1.ChannelResetHealthReq) (res *v1.ChannelResetHealthRes, err error) { + return service.Admin().ResetChannelHealth(ctx, req) +} diff --git a/internal/controller/admin/admin_v1_dashboard.go b/internal/controller/admin/admin_v1_dashboard.go index d8496b0..0a90f52 100644 --- a/internal/controller/admin/admin_v1_dashboard.go +++ b/internal/controller/admin/admin_v1_dashboard.go @@ -58,3 +58,15 @@ func (c *ControllerV1) AdminTransactionList(ctx context.Context, req *v1.AdminTr func (c *ControllerV1) AdminDashboardModelHourly(ctx context.Context, req *v1.AdminDashboardModelHourlyReq) (res *v1.AdminDashboardModelHourlyRes, err error) { return service.Admin().GetModelHourlyCost(ctx, req) } +func (c *ControllerV1) AdminWalletOfflineRecharge(ctx context.Context, req *v1.AdminWalletOfflineRechargeReq) (res *v1.AdminWalletOfflineRechargeRes, err error) { + return service.Admin().OfflineRecharge(ctx, req) +} +func (c *ControllerV1) AdminWalletFrozenItemList(ctx context.Context, req *v1.AdminWalletFrozenItemListReq) (res *v1.AdminWalletFrozenItemListRes, err error) { + return service.Admin().GetWalletFrozenItems(ctx, req) +} +func (c *ControllerV1) AdminWalletFrozenRelease(ctx context.Context, req *v1.AdminWalletFrozenReleaseReq) (res *v1.AdminWalletFrozenReleaseRes, err error) { + return service.Admin().ReleaseWalletFrozenItem(ctx, req) +} +func (c *ControllerV1) AdminWalletFrozenReleaseAll(ctx context.Context, req *v1.AdminWalletFrozenReleaseAllReq) (res *v1.AdminWalletFrozenReleaseAllRes, err error) { + return service.Admin().ReleaseAllWalletFrozenItems(ctx, req) +} diff --git a/internal/dispatchadapter/catalog.go b/internal/dispatchadapter/catalog.go index 730076b..5973117 100644 --- a/internal/dispatchadapter/catalog.go +++ b/internal/dispatchadapter/catalog.go @@ -147,6 +147,41 @@ func (c *Catalog) ChannelModels() map[int64][]string { return out } +// ChannelRuntimeState 单个渠道的调度运行状态聚合(管理后台展示用)。 +// 数据来源:目录内存快照(每 5s 刷新),与调度器 buildCandidates 使用同一份数据; +// Redis 读值已在 Rebuild 时经 EffectiveBreakerState 做惰性 OPEN→HALF_OPEN 判定。 +type ChannelRuntimeState struct { + Breaker dispatch.BreakerState // 渠道级熔断状态(同一渠道所有模型取值相同) + BreakerModels int // 处于 OPEN/HALF_OPEN 的模型数量(渠道×模型级汇总) +} + +// ChannelRuntimeStates 返回目录快照中各渠道的熔断聚合视图。 +// 遍历 byModel 按渠道 ID 去重;渠道级 Breaker 取首次遇到值(各模型行相同), +// 模型级 ModelBreaker 按行计数(同一渠道在 byModel 中每个模型至多出现一次 → 计数即按模型去重)。 +// 注意:disabled / 无 enabled 能力的渠道不在快照(loadCatalogFromDB 仅载 c.status='active'), +// 不会出现在返回 map 中——调用方需按运营状态自行区分展示。 +// 并发安全:c.current 为 atomic.Pointer,Load() 取到的快照不可变,无锁。 +func (c *Catalog) ChannelRuntimeStates() map[int64]ChannelRuntimeState { + idx := c.current.Load() + if idx == nil { + return nil + } + out := make(map[int64]ChannelRuntimeState) + for _, chans := range idx.byModel { + for _, ch := range chans { + agg, ok := out[ch.ID] + if !ok { + agg = ChannelRuntimeState{Breaker: ch.Breaker} + } + if ch.ModelBreaker != dispatch.BreakerClosed { + agg.BreakerModels++ + } + out[ch.ID] = agg + } + } + return out +} + // Invalidate 主动触发一次刷新(渠道/能力/Key 管理操作后调用;跨实例走 pub/sub)。 func (c *Catalog) Invalidate() { select { diff --git a/internal/dispatchadapter/catalog_test.go b/internal/dispatchadapter/catalog_test.go index e9b089b..dafb0d8 100644 --- a/internal/dispatchadapter/catalog_test.go +++ b/internal/dispatchadapter/catalog_test.go @@ -148,3 +148,46 @@ func TestRampElapsed(t *testing.T) { // 窗口未配置:不爬坡 assert.Equal(t, int64(-1), rampElapsed(now, now-10, 0, 0)) } + +// TestCatalog_渠道运行状态聚合 验证 ChannelRuntimeStates 按渠道去重、渠道级熔断取同值、 +// 模型级熔断按模型计数。 +func TestCatalog_渠道运行状态聚合(t *testing.T) { + rows := []catalogRow{ + {ChannelID: 1, ChannelName: "A", ModelName: "gpt-4o", Weight: 10, Tier: "primary"}, + {ChannelID: 1, ChannelName: "A", ModelName: "claude-sonnet", Weight: 10, Tier: "primary"}, + {ChannelID: 2, ChannelName: "B", ModelName: "gpt-4o", Weight: 5, Tier: "secondary"}, + {ChannelID: 3, ChannelName: "C", ModelName: "gpt-4o", Weight: 5, Tier: "secondary"}, + } + c := testCatalog(rows, nil, func(_ context.Context, channelID int64, _ string) RuntimeReadout { + switch channelID { + case 1: + return RuntimeReadout{Breaker: dispatch.BreakerOpen, ModelBreaker: dispatch.BreakerOpen} + case 2: + return RuntimeReadout{Breaker: dispatch.BreakerClosed, ModelBreaker: dispatch.BreakerHalfOpen} + default: + return RuntimeReadout{Breaker: dispatch.BreakerClosed, ModelBreaker: dispatch.BreakerClosed} + } + }) + + got := c.ChannelRuntimeStates() + require.Len(t, got, 3) + + // 渠道 1:渠道级熔断 OPEN,两个模型均熔断 + assert.Equal(t, dispatch.BreakerOpen, got[1].Breaker) + assert.Equal(t, 2, got[1].BreakerModels, "两个模型均熔断") + + // 渠道 2:渠道级正常,仅 gpt-4o 模型半开 + assert.Equal(t, dispatch.BreakerClosed, got[2].Breaker) + assert.Equal(t, 1, got[2].BreakerModels, "仅 gpt-4o 半开") + + // 渠道 3:完全正常 + assert.Equal(t, dispatch.BreakerClosed, got[3].Breaker) + assert.Equal(t, 0, got[3].BreakerModels) +} + +// TestCatalog_运行状态_未Rebuild返回空 验证目录未构建时返回空 map(而非 nil), +// 调用方遍历时安全。 +func TestCatalog_运行状态_未Rebuild返回空(t *testing.T) { + c := NewCatalog(func() *dispatch.RoutingPolicy { return dispatch.DefaultRoutingPolicy() }, nil, nil) + assert.Empty(t, c.ChannelRuntimeStates()) +} diff --git a/internal/dispatchadapter/state_redis.go b/internal/dispatchadapter/state_redis.go index 6d6b206..9394ca3 100644 --- a/internal/dispatchadapter/state_redis.go +++ b/internal/dispatchadapter/state_redis.go @@ -326,6 +326,44 @@ func (s *RedisState) MarkChannelRecovered(ctx context.Context, channelID int64) } } +// --------------------------------------------------------------------------- +// 健康度重置 +// --------------------------------------------------------------------------- + +// resetHealthSuccEwma 重置健康度时写入的成功率 EWMA:对应健康分≈80(0.9²×100=81)。 +const resetHealthSuccEwma = 0.9 + +// luaHealthReset 健康 EWMA 重置(管理后台"重置健康度"):成功率恢复为健康值、延迟清零。 +// ARGV: succ, nowMs, keyTtlMs +const luaHealthReset = ` +local succ = tonumber(ARGV[1]) +if succ <= 0 then succ = 1 end +redis.call('HSET', KEYS[1], 'succ_ewma', succ, 'lat_ewma', 0, 'updated_ms', ARGV[2]) +redis.call('PEXPIRE', KEYS[1], ARGV[3]) +return 1` + +// ResetHealth 重置渠道健康(管理后台"重置健康度"):渠道级 + 各模型级熔断复位为 CLOSED +// 并开启爬坡窗口(recovered_ms),各模型成功率 EWMA 恢复为健康值,渠道立即恢复被调度选择的能力。 +// 失败仅记 Debug 日志(管理后台手动操作,重试成本低)。 +func (s *RedisState) ResetHealth(ctx context.Context, channelID int64, models []string) { + chStr := strconv.FormatInt(channelID, 10) + now := time.Now().UnixMilli() + + s.resetBreakerState(ctx, keyBreaker+chStr, now) + for _, model := range models { + s.resetBreakerState(ctx, keyBreaker+chStr+":"+model, now) + if _, err := g.Redis().Do(ctx, "EVAL", luaHealthReset, 1, + keyHealth+chStr+":"+model, resetHealthSuccEwma, now, stateKeyTTLMs); err != nil { + g.Log().Debugf(ctx, "[Dispatch] 健康重置失败: channel=%d model=%s err=%v", channelID, model, err) + } + } +} + +// resetBreakerState 熔断复位为 CLOSED 并记录 recovered_ms(开启爬坡窗口)。 +func (s *RedisState) resetBreakerState(ctx context.Context, key string, nowMs int64) { + _, _ = g.Redis().Do(ctx, "EVAL", luaBreakerManualReset, 1, key, nowMs, stateKeyTTLMs) +} + // --------------------------------------------------------------------------- // 熔断探测令牌 // --------------------------------------------------------------------------- diff --git a/internal/dispatchadapter/wire.go b/internal/dispatchadapter/wire.go index e54763a..c6a2a7b 100644 --- a/internal/dispatchadapter/wire.go +++ b/internal/dispatchadapter/wire.go @@ -91,6 +91,19 @@ func MarkChannelRecovered(ctx context.Context, channelID int64) { } } +// ResetChannelHealth 重置渠道健康度(管理后台"重置健康度"按钮):熔断复位 + 成功率恢复, +// 渠道立即恢复被调度选择的能力。models 从目录快照取(该渠道服务的模型列表)。 +func ResetChannelHealth(ctx context.Context, channelID int64) { + if redisState == nil { + return + } + var models []string + if catalog != nil { + models = catalog.ChannelModels()[channelID] + } + redisState.ResetHealth(ctx, channelID, models) +} + // InvalidateChannel 渠道禁用/删除时的联动清理:清绑定 + 跨实例目录失效。 // 管理后台渠道写操作(更新/删除/Key 变更/能力变更)后调用。 func InvalidateChannel(ctx context.Context, channelID int64) { diff --git a/internal/handler/landing/landing.go b/internal/handler/landing/landing.go new file mode 100644 index 0000000..899e43b --- /dev/null +++ b/internal/handler/landing/landing.go @@ -0,0 +1,19 @@ +package landing + +import ( + "strings" + + "github.com/gogf/gf/v2/net/ghttp" + + "github.com/qianfree/team-api/internal/consts" + "github.com/qianfree/team-api/internal/packed" +) + +// HandleLanding serves the embedded landing page for the backend root path. +// 未内嵌前端(非 embedweb 构建)时访问后端根路径会命中默认 404,这里提供项目介绍页, +// 引导用户从管理后台 / 租户控制台进入,并展示版本号与常用接口入口。 +func HandleLanding(r *ghttp.Request) { + html := strings.Replace(string(packed.LandingHTML), "__VERSION__", consts.Version, 1) + r.Response.Header().Set("Content-Type", "text/html; charset=utf-8") + r.Response.Write([]byte(html)) +} diff --git a/internal/handler/relay/relay.go b/internal/handler/relay/relay.go index 983ac5d..f42da2e 100644 --- a/internal/handler/relay/relay.go +++ b/internal/handler/relay/relay.go @@ -3,6 +3,7 @@ package relay import ( "encoding/json" "fmt" + "net/http" "runtime/debug" "strings" "time" @@ -366,7 +367,7 @@ func HandleModelDetail(r *ghttp.Request) { return } - resp, err := handler.HandleModelDetail(r.Context(), rc.TenantID, modelName, dataProvider) + resp, err := handler.HandleModelDetail(r.Context(), rc.TenantID, rc.ApiKeyID, rc.UserID, modelName, dataProvider) if err != nil { // 根据错误类型返回不同状态码 statusCode := 500 @@ -377,7 +378,9 @@ func HandleModelDetail(r *ghttp.Request) { statusCode = 404 errType = "model_not_found" errMsg = "The model '" + modelName + "' does not exist" - } else if err == relay_common.ErrTenantModelNotEnabled { + } else if err == relay_common.ErrTenantModelNotEnabled || + err == relay_common.ErrMemberModelNotAllowed || + err == relay_common.ErrApiKeyModelNotAllowed { statusCode = 403 errType = "permission_denied" errMsg = "You do not have access to model '" + modelName + "'" @@ -437,15 +440,37 @@ func HandleGeminiModelDetail(r *ghttp.Request) { return } - resp, err := handler.HandleGeminiModelDetail(r.Context(), rc.TenantID, modelName, dataProvider) + resp, err := handler.HandleGeminiModelDetail(r.Context(), rc.TenantID, rc.ApiKeyID, rc.UserID, modelName, dataProvider) if err != nil { - handler.WriteGeminiRelayError(r.Response.Writer, err) + handler.WriteGeminiRelayError(r.Response.Writer, geminiModelDetailError(err, modelName)) return } r.Response.WriteJsonExit(resp) } +// geminiModelDetailError 将模型详情接口的模型不存在 / 权限拒绝错误映射为对应状态码的 RelayError, +// 供 WriteGeminiRelayError 输出 Gemini 原生错误格式(NOT_FOUND / PERMISSION_DENIED)。 +func geminiModelDetailError(err error, modelName string) error { + switch { + case err == relay_common.ErrModelNotFound: + return &relay_constant.RelayError{ + StatusCode: http.StatusNotFound, + Message: "model '" + modelName + "' does not exist", + Type: "model_not_found", + } + case err == relay_common.ErrTenantModelNotEnabled, + err == relay_common.ErrMemberModelNotAllowed, + err == relay_common.ErrApiKeyModelNotAllowed: + return &relay_constant.RelayError{ + StatusCode: http.StatusForbidden, + Message: "You do not have access to model '" + modelName + "'", + Type: "permission_denied", + } + } + return err +} + // buildRelayContext 从 GoFrame 请求中构建 relay 上下文 func buildRelayContext(r *ghttp.Request) *handler.RelayContext { return &handler.RelayContext{ diff --git a/internal/handler/relay/task.go b/internal/handler/relay/task.go index 8c50c69..84d6826 100644 --- a/internal/handler/relay/task.go +++ b/internal/handler/relay/task.go @@ -3,6 +3,9 @@ package relay import ( "context" "encoding/json" + "errors" + "fmt" + "net/http" "strings" "time" @@ -58,27 +61,7 @@ func HandleTaskSubmit(r *ghttp.Request) { // 选择渠道 channelMeta, err := selectTaskChannel(r, body) if err != nil { - statusCode := 503 - errType := "server_error" - errMsg := err.Error() - - if err == relay_common.ErrTenantModelNotEnabled { - statusCode = 403 - errType = "permission_denied" - errMsg = "当前租户未启用该模型,请联系管理员" - } else if err == relay_common.ErrMemberModelNotAllowed { - statusCode = 403 - errType = "permission_denied" - errMsg = "当前成员无权使用该模型" - } else if err == relay_common.ErrChannelUnavailable { - statusCode = 503 - errType = "server_error" - errMsg = "该模型暂无可用的渠道,请稍后重试或联系管理员" - } - - r.Response.WriteStatus(statusCode, g.Map{ - "error": g.Map{"type": errType, "message": errMsg}, - }) + writeTaskChannelSelectionError(r, err) return } @@ -166,6 +149,58 @@ func extractTaskID(r *ghttp.Request) string { return r.Get("task_id").String() } +type taskModelAccessChecker interface { + CheckMemberModelAccess(ctx context.Context, tenantID, userID int64, modelName string) (bool, error) + CheckApiKeyModelAccess(ctx context.Context, apiKeyID int64, modelName string) (bool, error) +} + +func checkTaskModelAccess( + ctx context.Context, + checker taskModelAccessChecker, + tenantID, userID, apiKeyID int64, + modelName string, +) error { + allowed, err := checker.CheckMemberModelAccess(ctx, tenantID, userID, modelName) + if err != nil { + return fmt.Errorf("check member model access: %w", err) + } + if !allowed { + return relay_common.ErrMemberModelNotAllowed + } + + allowed, err = checker.CheckApiKeyModelAccess(ctx, apiKeyID, modelName) + if err != nil { + return fmt.Errorf("check API key model access: %w", err) + } + if !allowed { + return relay_common.ErrApiKeyModelNotAllowed + } + + return nil +} + +func taskChannelSelectionError(err error) (statusCode int, errType, message string) { + switch { + case errors.Is(err, relay_common.ErrTenantModelNotEnabled): + return http.StatusForbidden, "permission_denied", "当前租户未启用该模型,请联系管理员" + case errors.Is(err, relay_common.ErrMemberModelNotAllowed): + return http.StatusForbidden, "permission_denied", "当前成员无权使用该模型" + case errors.Is(err, relay_common.ErrApiKeyModelNotAllowed): + return http.StatusForbidden, "permission_denied", "当前 API Key 无权使用该模型" + case errors.Is(err, relay_common.ErrChannelUnavailable): + return http.StatusServiceUnavailable, "server_error", "该模型暂无可用的渠道,请稍后重试或联系管理员" + default: + return http.StatusServiceUnavailable, "server_error", err.Error() + } +} + +func writeTaskChannelSelectionError(r *ghttp.Request, err error) { + statusCode, errType, message := taskChannelSelectionError(err) + r.Response.WriteStatus(statusCode, g.Map{ + "error": g.Map{"type": errType, "message": message}, + }) +} + // selectTaskChannel 为异步任务选择渠道(新调度引擎单次选择)。 // 任务提交是瞬时动作:选定并物化后立即结束调度会话释放租约; // 会话绑定已由调度器在选择时写入,提交结果不额外上报健康(与旧行为一致)。 @@ -183,11 +218,8 @@ func selectTaskChannel(r *ghttp.Request, body []byte) (*relay_common.ChannelMeta userID := middleware.GetUserID(ctx) apiKeyID := middleware.GetApiKeyID(ctx) - // 检查成员模型范围 - if allowed, err := relayDataProvider.CheckMemberModelAccess(ctx, tenantID, userID, req.Model); err != nil { + if err := checkTaskModelAccess(ctx, relayDataProvider, tenantID, userID, apiKeyID, req.Model); err != nil { return nil, err - } else if !allowed { - return nil, relay_common.ErrMemberModelNotAllowed } enabled, channelScope, err := relayDataProvider.CheckTenantModelAccess(ctx, tenantID, req.Model) @@ -267,9 +299,7 @@ func HandleMjSubmit(r *ghttp.Request) { channelMeta, err := selectTaskChannel(r, body) if err != nil { - r.Response.WriteStatus(503, g.Map{ - "error": g.Map{"type": "server_error", "message": err.Error()}, - }) + writeTaskChannelSelectionError(r, err) return } @@ -322,9 +352,7 @@ func HandleAliImageSubmit(r *ghttp.Request) { channelMeta, err := selectTaskChannel(r, body) if err != nil { - r.Response.WriteStatus(503, g.Map{ - "error": g.Map{"type": "server_error", "message": err.Error()}, - }) + writeTaskChannelSelectionError(r, err) return } diff --git a/internal/handler/relay/task_test.go b/internal/handler/relay/task_test.go new file mode 100644 index 0000000..2f658cd --- /dev/null +++ b/internal/handler/relay/task_test.go @@ -0,0 +1,168 @@ +package relay + +import ( + "context" + "errors" + "net/http/httptest" + "testing" + + "github.com/gogf/gf/v2/net/ghttp" + + "github.com/qianfree/team-api/internal/middleware" + relay_common "github.com/qianfree/team-api/relay/common" +) + +type taskModelAccessCheckerStub struct { + relay_common.DataProvider + + memberAllowed bool + apiKeyAllowed bool + memberErr error + apiKeyErr error + apiKeyChecks int + tenantChecks int +} + +func (s *taskModelAccessCheckerStub) CheckMemberModelAccess( + context.Context, + int64, + int64, + string, +) (bool, error) { + return s.memberAllowed, s.memberErr +} + +func (s *taskModelAccessCheckerStub) CheckApiKeyModelAccess( + context.Context, + int64, + string, +) (bool, error) { + s.apiKeyChecks++ + return s.apiKeyAllowed, s.apiKeyErr +} + +func (s *taskModelAccessCheckerStub) CheckTenantModelAccess( + context.Context, + int64, + string, +) (bool, []int64, error) { + s.tenantChecks++ + return false, nil, errors.New("tenant model check should not be reached") +} + +func TestCheckTaskModelAccess(t *testing.T) { + errMemberCheck := errors.New("member check failed") + errApiKeyCheck := errors.New("api key check failed") + + tests := []struct { + name string + checker *taskModelAccessCheckerStub + wantErr error + wantApiKeyChecks int + }{ + { + name: "allows model when both scopes allow it", + checker: &taskModelAccessCheckerStub{ + memberAllowed: true, + apiKeyAllowed: true, + }, + wantApiKeyChecks: 1, + }, + { + name: "rejects member before checking API key", + checker: &taskModelAccessCheckerStub{ + memberAllowed: false, + apiKeyAllowed: true, + }, + wantErr: relay_common.ErrMemberModelNotAllowed, + }, + { + name: "rejects model outside API key scope", + checker: &taskModelAccessCheckerStub{ + memberAllowed: true, + apiKeyAllowed: false, + }, + wantErr: relay_common.ErrApiKeyModelNotAllowed, + wantApiKeyChecks: 1, + }, + { + name: "propagates member scope lookup error", + checker: &taskModelAccessCheckerStub{ + memberErr: errMemberCheck, + }, + wantErr: errMemberCheck, + }, + { + name: "propagates API key scope lookup error", + checker: &taskModelAccessCheckerStub{ + memberAllowed: true, + apiKeyErr: errApiKeyCheck, + }, + wantErr: errApiKeyCheck, + wantApiKeyChecks: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := checkTaskModelAccess( + context.Background(), + tt.checker, + 1, + 2, + 3, + "video-model", + ) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("checkTaskModelAccess() error = %v, want %v", err, tt.wantErr) + } + if tt.checker.apiKeyChecks != tt.wantApiKeyChecks { + t.Fatalf("API key checks = %d, want %d", tt.checker.apiKeyChecks, tt.wantApiKeyChecks) + } + }) + } +} + +func TestSelectTaskChannelRejectsModelOutsideApiKeyScope(t *testing.T) { + checker := &taskModelAccessCheckerStub{ + memberAllowed: true, + apiKeyAllowed: false, + } + originalProvider := relayDataProvider + relayDataProvider = checker + t.Cleanup(func() { + relayDataProvider = originalProvider + }) + + r := &ghttp.Request{ + Request: httptest.NewRequest("POST", "/v1/video/generations", nil), + } + r.SetCtxVar(middleware.CtxKeyTenantID, int64(1)) + r.SetCtxVar(middleware.CtxKeyUserID, int64(2)) + r.SetCtxVar(middleware.CtxKeyApiKeyID, int64(3)) + + _, err := selectTaskChannel(r, []byte(`{"model":"video-model"}`)) + if !errors.Is(err, relay_common.ErrApiKeyModelNotAllowed) { + t.Fatalf("selectTaskChannel() error = %v, want %v", err, relay_common.ErrApiKeyModelNotAllowed) + } + if checker.apiKeyChecks != 1 { + t.Fatalf("API key checks = %d, want 1", checker.apiKeyChecks) + } + if checker.tenantChecks != 0 { + t.Fatalf("tenant model checks = %d, want 0 after authorization denial", checker.tenantChecks) + } +} + +func TestTaskChannelSelectionError_ApiKeyModelDenied(t *testing.T) { + statusCode, errType, message := taskChannelSelectionError(relay_common.ErrApiKeyModelNotAllowed) + + if statusCode != 403 { + t.Fatalf("status code = %d, want 403", statusCode) + } + if errType != "permission_denied" { + t.Fatalf("error type = %q, want permission_denied", errType) + } + if message != "当前 API Key 无权使用该模型" { + t.Fatalf("message = %q, want API Key model denial message", message) + } +} diff --git a/internal/handler/setup/setup.go b/internal/handler/setup/setup.go index c681633..901da01 100644 --- a/internal/handler/setup/setup.go +++ b/internal/handler/setup/setup.go @@ -2,6 +2,7 @@ package setup import ( "encoding/json" + "net/http" "sync/atomic" "unicode/utf8" @@ -69,19 +70,19 @@ func HandleSetupInitialize(r *ghttp.Request) { // Validate username format if utf8.RuneCountInString(req.Username) < 3 || utf8.RuneCountInString(req.Username) > 20 { - response.ErrorMsg(r, consts.CodeSetupInvalidUsername, consts.MsgSetupInvalidUsername) + response.ErrorWithCode(r, http.StatusUnprocessableEntity, consts.CodeSetupInvalidUsername, consts.MsgSetupInvalidUsername) return } // Validate password strength if err := common.ValidatePassword(req.Password); err != nil { - response.ErrorMsg(r, consts.CodePasswordTooWeak, err.Error()) + response.ErrorWithCode(r, http.StatusUnprocessableEntity, consts.CodePasswordTooWeak, err.Error()) return } // Validate password confirmation if req.Password != req.ConfirmPassword { - response.ErrorMsg(r, consts.CodeSetupPasswordMismatch, consts.MsgSetupPasswordMismatch) + response.ErrorWithCode(r, http.StatusUnprocessableEntity, consts.CodeSetupPasswordMismatch, consts.MsgSetupPasswordMismatch) return } diff --git a/internal/handler/setup/setup_test.go b/internal/handler/setup/setup_test.go new file mode 100644 index 0000000..3bbd618 --- /dev/null +++ b/internal/handler/setup/setup_test.go @@ -0,0 +1,74 @@ +package setup_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/guid" + + "github.com/qianfree/team-api/internal/consts" + setuphandler "github.com/qianfree/team-api/internal/handler/setup" +) + +func TestHandleSetupInitializeValidationErrors(t *testing.T) { + s := g.Server(guid.S()) + s.SetDumpRouterMap(false) + s.BindHandler("/initialize", setuphandler.HandleSetupInitialize) + + server := httptest.NewServer(s) + defer server.Close() + + tests := []struct { + name string + body string + wantCode int + }{ + { + name: "invalid username", + body: `{"username":"ab","password":"Password1","confirmPassword":"Password1"}`, + wantCode: consts.CodeSetupInvalidUsername, + }, + { + name: "weak password", + body: `{"username":"admin","password":"short","confirmPassword":"short"}`, + wantCode: consts.CodePasswordTooWeak, + }, + { + name: "password mismatch", + body: `{"username":"admin","password":"Password1","confirmPassword":"Password2"}`, + wantCode: consts.CodeSetupPasswordMismatch, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := server.Client().Post( + server.URL+"/initialize", + "application/json", + strings.NewReader(tt.body), + ) + if err != nil { + t.Fatalf("POST /initialize: %v", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusUnprocessableEntity { + t.Errorf("status = %d, want %d", res.StatusCode, http.StatusUnprocessableEntity) + } + + var body struct { + Code int `json:"code"` + } + if err := json.NewDecoder(res.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body.Code != tt.wantCode { + t.Errorf("code = %d, want %d", body.Code, tt.wantCode) + } + }) + } +} diff --git a/internal/logic/admin/audit.go b/internal/logic/admin/audit.go index b3be7a8..808998c 100644 --- a/internal/logic/admin/audit.go +++ b/internal/logic/admin/audit.go @@ -286,10 +286,10 @@ func MaskSensitiveData(data string) string { // ListRequestAuditLogs 分页查询请求审计日志(不返回 request_body/response_body 以优化性能) // 审计数据从审计库查询,关联信息(用户名、租户名等)从主库批量查询后在应用层合并。 func (s *sAdmin) ListRequestAuditLogs(ctx context.Context, req *v1.RequestAuditLogListReq) (*v1.RequestAuditLogListRes, error) { - if err := common.ValidateDateParam(req.StartDate, "开始日期"); err != nil { + if err := common.ValidateDateTimeParam(req.StartDate, "开始时间"); err != nil { return nil, err } - if err := common.ValidateDateParam(req.EndDate, "结束日期"); err != nil { + if err := common.ValidateDateTimeParam(req.EndDate, "结束时间"); err != nil { return nil, err } @@ -298,10 +298,18 @@ func (s *sAdmin) ListRequestAuditLogs(ctx context.Context, req *v1.RequestAuditL var conditions []string var args []any + if req.ID > 0 { + conditions = append(conditions, "id = ?") + args = append(args, req.ID) + } if req.TenantID > 0 { conditions = append(conditions, "tenant_id = ?") args = append(args, int64(req.TenantID)) } + if req.UserID > 0 { + conditions = append(conditions, "user_id = ?") + args = append(args, req.UserID) + } if req.ApiKeyID > 0 { conditions = append(conditions, "api_key_id = ?") args = append(args, int64(req.ApiKeyID)) @@ -354,11 +362,11 @@ func (s *sAdmin) ListRequestAuditLogs(ctx context.Context, req *v1.RequestAuditL } if req.StartDate != "" { conditions = append(conditions, "created_at >= ?") - args = append(args, req.StartDate+" 00:00:00") + args = append(args, common.StartOfRange(req.StartDate)) } if req.EndDate != "" { conditions = append(conditions, "created_at <= ?") - args = append(args, req.EndDate+" 23:59:59") + args = append(args, common.EndOfRange(req.EndDate)) } where := strings.Join(conditions, " AND ") @@ -408,7 +416,9 @@ func (s *sAdmin) ForwardingTraceGet(ctx context.Context, req *v1.ForwardingTrace OrderDesc("created_at"). Fields("forwarding_trace, audit_level"). Scan(&row) - if err != nil { + // Scan(&struct) 无匹配记录时返回 sql.ErrNoRows,归一化为 nil, + // 让下方「无审计记录 → found=false」分支生效,而不是把 NoRows 当错误返回。 + if err = common.IgnoreScanNoRows(err); err != nil { return nil, err } // 无审计记录(两级审计级别均为 none 时不会写入)→ found=false,前端据此展示「未开启审计」 diff --git a/internal/logic/admin/channel.go b/internal/logic/admin/channel.go index f30f304..b36ca6f 100644 --- a/internal/logic/admin/channel.go +++ b/internal/logic/admin/channel.go @@ -44,6 +44,16 @@ var providerTypeNames = map[int]string{ 42: "Sub2API", } +// channelRuntimeStates 返回目录快照的渠道熔断聚合(管理后台列表/详情展示用)。 +// 目录未初始化(首次 relay 请求前)时返回 nil,调用方按「正常」处理。 +func channelRuntimeStates() map[int64]dispatchadapter.ChannelRuntimeState { + cat := dispatchadapter.CatalogInstance() + if cat == nil { + return nil + } + return cat.ChannelRuntimeStates() +} + // ListChannels 获取渠道列表 func (s *sAdmin) ListChannels(ctx context.Context, req *v1.ChannelListReq) (*v1.ChannelListRes, error) { query := dao.ChnChannels.Ctx(ctx). @@ -58,6 +68,17 @@ func (s *sAdmin) ListChannels(ctx context.Context, req *v1.ChannelListReq) (*v1. if req.Search != "" { query = query.Where("chn_channels.name LIKE ? OR chn_channels.remark LIKE ?", "%"+req.Search+"%", "%"+req.Search+"%") } + if req.ID > 0 { + query = query.Where("chn_channels.id", req.ID) + } + if req.BaseURL != "" { + query = query.Where("chn_channels.base_url LIKE ?", "%"+req.BaseURL+"%") + } + if req.Model != "" { + // 按支持的模型名筛选:渠道需具备匹配的模型能力(chn_abilities)。 + // 用 EXISTS 相关子查询而非 JOIN,避免一个渠道命中多个模型时产生重复行、破坏分页与总数。 + query = query.Where("EXISTS (SELECT 1 FROM chn_abilities WHERE chn_abilities.channel_id = chn_channels.id AND (chn_abilities.model_name LIKE ? OR chn_abilities.upstream_model LIKE ?))", "%"+req.Model+"%", "%"+req.Model+"%") + } var total int var channels []struct { @@ -93,6 +114,7 @@ func (s *sAdmin) ListChannels(ctx context.Context, req *v1.ChannelListReq) (*v1. } list := make([]v1.ChannelItem, 0, len(channels)) + runtimeStates := channelRuntimeStates() for _, ch := range channels { typeName := providerTypeNames[ch.Type] if typeName == "" { @@ -101,7 +123,7 @@ func (s *sAdmin) ListChannels(ctx context.Context, req *v1.ChannelListReq) (*v1. settings := relay.ParseChannelSettings(ch.Settings) - list = append(list, v1.ChannelItem{ + item := v1.ChannelItem{ ID: ch.ID, Name: ch.Name, Type: ch.Type, @@ -122,7 +144,12 @@ func (s *sAdmin) ListChannels(ctx context.Context, req *v1.ChannelListReq) (*v1. BorrowingCooldownSeconds: ch.BorrowingCooldownSeconds, CreatedAt: ch.CreatedAt.String(), HealthScore: ch.HealthScore, - }) + } + if rs, ok := runtimeStates[ch.ID]; ok { + item.BreakerState = int(rs.Breaker) + item.BreakerModels = rs.BreakerModels + } + list = append(list, item) } return &v1.ChannelListRes{ @@ -512,6 +539,10 @@ func (s *sAdmin) GetChannelDetail(ctx context.Context, req *v1.ChannelDetailReq) KeyStatus: keyInfo.Status, KeyName: keyInfo.Name, } + if rs, ok := channelRuntimeStates()[req.ID]; ok { + res.BreakerState = int(rs.Breaker) + res.BreakerModels = rs.BreakerModels + } if res.KeyType == "" { res.KeyType = "apikey" } @@ -653,10 +684,13 @@ func (s *sAdmin) GetProviderDefaultURLs(ctx context.Context, _ *v1.ProviderDefau // defaultProviderURLs 供应商默认 API 地址 var defaultProviderURLs = map[int]string{ - 1: "https://api.openai.com", - 2: "https://api.anthropic.com", - 3: "https://generativelanguage.googleapis.com", - 4: "https://dashscope.aliyuncs.com/compatible-mode", + 1: "https://api.openai.com", + 2: "https://api.anthropic.com", + 3: "https://generativelanguage.googleapis.com", + // 阿里云百炼填裸域名:adaptor 会按 relay 模式自行拼 /compatible-mode/v1/...(OpenAI 兼容)、 + // /apps/anthropic/v1/messages(Claude)、/api/v1/services/aigc/...(图像), + // 这里若带上 /compatible-mode 会导致路径重复而 404。 + 4: "https://dashscope.aliyuncs.com", 5: "https://aip.baidubce.com", 6: "https://hunyuan.tencentcloudapi.com", 7: "https://open.bigmodel.cn", @@ -698,6 +732,55 @@ func (s *sAdmin) GetChannelHealthTrend(ctx context.Context, req *v1.ChannelHealt return &v1.ChannelHealthTrendRes{Points: points}, nil } +// ResetChannelHealth 重置渠道健康度(渠道可能已修复、重新可用):落库健康分 80(展示层), +// 并复位熔断 + 成功率 EWMA(调度层),使渠道立即恢复被调度选择的能力。 +func (s *sAdmin) ResetChannelHealth(ctx context.Context, req *v1.ChannelResetHealthReq) (*v1.ChannelResetHealthRes, error) { + // 渠道必须存在 + count, err := dao.ChnChannels.Ctx(ctx).Where("id", req.ID).Count() + if err != nil { + return nil, err + } + if count == 0 { + return nil, common.NewBusinessError(404, "渠道不存在") + } + + // 展示层:健康分落库为 80(调度不读此表,仅供仪表盘/审计展示;下次维护快照会按策略重算) + affected, err := dao.ChnHealthScores.Ctx(ctx). + Where("channel_id", req.ID). + Data(do.ChnHealthScores{ + SuccessRate: 90.00, + LatencyMs: 0, + StabilityScore: 100.00, + ConsecutiveFailures: 0, + HealthScore: 80.00, + CalculatedAt: gtime.Now(), + }). + UpdateAndGetAffected() + if err != nil { + return nil, err + } + // 健康分记录缺失时兜底插入(正常情况下创建渠道时已由 InitHealthScore 初始化) + if affected == 0 { + _, err = dao.ChnHealthScores.Ctx(ctx).Insert(do.ChnHealthScores{ + ChannelId: req.ID, + SuccessRate: 90.00, + LatencyMs: 0, + StabilityScore: 100.00, + ConsecutiveFailures: 0, + HealthScore: 80.00, + CalculatedAt: gtime.Now(), + }) + if err != nil { + return nil, err + } + } + + // 调度层:复位渠道级/模型级熔断 + 成功率恢复,渠道立即恢复被选择能力 + dispatchadapter.ResetChannelHealth(ctx, req.ID) + + return nil, nil +} + // ExportChannels exports channel list to CSV or Excel. func (s *sAdmin) ExportChannels(ctx context.Context, req *v1.ChannelExportReq) (*v1.ChannelExportRes, error) { channelFields := "chn_channels.id, chn_channels.name, chn_channels.type, chn_channels.status, chn_channels.priority, chn_channels.weight, chn_channels.created_at, h.health_score" @@ -731,6 +814,16 @@ func (s *sAdmin) ExportChannels(ctx context.Context, req *v1.ChannelExportReq) ( if req.Search != "" { query = query.Where("chn_channels.name LIKE ? OR chn_channels.remark LIKE ?", "%"+req.Search+"%", "%"+req.Search+"%") } + if req.ID > 0 { + query = query.Where("chn_channels.id", req.ID) + } + if req.BaseURL != "" { + query = query.Where("chn_channels.base_url LIKE ?", "%"+req.BaseURL+"%") + } + if req.Model != "" { + // 与 ListChannels 保持一致:EXISTS 相关子查询避免 JOIN 重复行 + query = query.Where("EXISTS (SELECT 1 FROM chn_abilities WHERE chn_abilities.channel_id = chn_channels.id AND (chn_abilities.model_name LIKE ? OR chn_abilities.upstream_model LIKE ?))", "%"+req.Model+"%", "%"+req.Model+"%") + } var batch []struct { ID int64 `json:"id"` Name string `json:"name"` diff --git a/internal/logic/admin/dashboard.go b/internal/logic/admin/dashboard.go index e8b20fd..1c0f932 100644 --- a/internal/logic/admin/dashboard.go +++ b/internal/logic/admin/dashboard.go @@ -351,10 +351,10 @@ func (s *sAdmin) GetModelHourlyCost(ctx context.Context, req *v1.AdminDashboardM // GetAllUsageLogs 获取所有租户的用量日志(管理后台) func (s *sAdmin) GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListReq) (*v1.AdminUsageLogListRes, error) { - if err := common.ValidateDateParam(req.StartDate, "开始日期"); err != nil { + if err := common.ValidateDateTimeParam(req.StartDate, "开始时间"); err != nil { return nil, err } - if err := common.ValidateDateParam(req.EndDate, "结束日期"); err != nil { + if err := common.ValidateDateTimeParam(req.EndDate, "结束时间"); err != nil { return nil, err } @@ -363,14 +363,30 @@ func (s *sAdmin) GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListR var conditions []string var args []any + if req.ID > 0 { + conditions = append(conditions, "u.id = ?") + args = append(args, req.ID) + } if req.TenantID > 0 { conditions = append(conditions, "u.tenant_id = ?") args = append(args, req.TenantID) } + if req.UserID > 0 { + conditions = append(conditions, "u.user_id = ?") + args = append(args, req.UserID) + } if req.Username != "" { conditions = append(conditions, "t.username LIKE ?") args = append(args, "%"+req.Username+"%") } + if req.ApiKeyID > 0 { + conditions = append(conditions, "u.api_key_id = ?") + args = append(args, req.ApiKeyID) + } + if req.ChannelID > 0 { + conditions = append(conditions, "u.channel_id = ?") + args = append(args, req.ChannelID) + } if req.Model != "" { conditions = append(conditions, "u.model_name = ?") args = append(args, req.Model) @@ -385,11 +401,11 @@ func (s *sAdmin) GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListR } if req.StartDate != "" { conditions = append(conditions, "u.created_at >= ?") - args = append(args, req.StartDate+" 00:00:00") + args = append(args, common.StartOfRange(req.StartDate)) } if req.EndDate != "" { conditions = append(conditions, "u.created_at <= ?") - args = append(args, req.EndDate+" 23:59:59") + args = append(args, common.EndOfRange(req.EndDate)) } where := "" @@ -534,68 +550,59 @@ func (s *sAdmin) GetTenantWallets(ctx context.Context, req *v1.AdminWalletListRe }, nil } -// AdjustBalance 调整租户余额(管理后台) +// AdjustBalance 调整租户余额(管理后台)。 +// Redis 权威化架构:余额变动以 Redis Lua 为资金提交点(扣减在权威可用余额上原子判断, +// 穿透冻结会破坏预扣一致性),DB 侧仅记流水;流水失败时逆转 Redis 已发生的变动。 func (s *sAdmin) AdjustBalance(ctx context.Context, req *v1.AdminWalletAdjustReq) (*v1.AdminWalletAdjustRes, error) { tenantID := req.TenantID - // 入口即转 decimal(float64 仅允许出现在 API 边界),后续 SQL 参数与流水全程 decimal 直传 + // 入口即转 decimal(float64 仅允许出现在 API 边界),后续流水字段全程 decimal 直传 amount := billing.NewFromFloat(req.Amount) description := req.Description - err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - // 原子更新余额,避免并发竞态。扣减(负数)时以【可用余额】(balance - frozen_balance) - // 为下限:frozen_balance 是支付中/退款中的占用,穿透冻结会破坏预扣一致性。 - updateQuery := "UPDATE bil_wallets SET balance = balance + ?, updated_at = ? WHERE tenant_id = ?" - args := []any{amount, gtime.Now(), tenantID} - if amount.IsNegative() { - updateQuery += " AND balance - frozen_balance >= ?" - args = append(args, amount.Neg()) - } - result, err := g.DB().Ctx(ctx).Exec(ctx, updateQuery, args...) - if err != nil { - return err - } - affected, _ := result.RowsAffected() - if affected == 0 { - return common.NewBadRequestError("钱包不存在或可用余额不足") - } - - // 查询更新后的余额,用于记录流水 - var wallet *struct { - ID int64 `json:"id"` - Balance decimal.Decimal `json:"balance"` - FrozenBalance decimal.Decimal `json:"frozen_balance"` - } - if err = dao.BilWallets.Ctx(ctx). - Where("tenant_id", tenantID). - Fields("id, balance, frozen_balance"). - Scan(&wallet); err != nil { - return err - } - if wallet == nil { - return common.NewBadRequestError("钱包不存在") - } - - // 记录流水 - if _, err = dao.BilTransactions.Ctx(ctx).Insert(do.BilTransactions{ - TenantId: tenantID, - WalletId: wallet.ID, - Type: "adjust", - Amount: amount, - BalanceAfter: wallet.Balance, - FrozenAfter: wallet.FrozenBalance, - Description: description, - }); err != nil { - return gerror.Wrapf(err, "record balance adjust transaction") - } - return nil - }) + walletID, err := billing.GetWalletID(ctx, tenantID) if err != nil { - return nil, err + return nil, common.NewBadRequestError("钱包不存在") } - // 事务提交后清除钱包两级缓存(进程内 + Redis),否则调整后的余额在 300s TTL 内 - // 不生效——下调余额时租户仍按旧的高余额预扣,可短时超支。 - billing.InvalidateWallet(ctx, tenantID) + var balanceAfter, frozenAfter decimal.Decimal + if amount.IsNegative() { + // 扣减:可用余额(balance - frozen_balance)门槛在 Redis 权威值原子判断 + ba, fa, ok, err := billing.DebitWalletRedis(ctx, tenantID, amount.Neg()) + if err != nil { + return nil, err + } + if !ok { + return nil, common.NewBadRequestError("钱包可用余额不足") + } + balanceAfter, frozenAfter = ba, fa + } else { + ba, fa, err := billing.CreditWalletRedis(ctx, tenantID, amount) + if err != nil { + return nil, err + } + balanceAfter, frozenAfter = ba, fa + } + + // 记录流水(append-only INSERT,无行锁竞争) + if _, err = dao.BilTransactions.Ctx(ctx).Insert(do.BilTransactions{ + TenantId: tenantID, + WalletId: walletID, + Type: "adjust", + Amount: amount, + BalanceAfter: balanceAfter, + FrozenAfter: frozenAfter, + Description: description, + }); err != nil { + // 流水失败:逆转已发生的 Redis 余额变动(amount.Neg():扣减→加回,加款→扣回) + compCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + if _, _, compErr := billing.CreditWalletRedis(compCtx, tenantID, amount.Neg()); compErr != nil { + g.Log().Errorf(ctx, + "CRITICAL: compensate adjust balance failed: tenant=%d usd=%s: %v — wallet drift, manual fix required", + tenantID, amount.String(), compErr) + } + return nil, gerror.Wrapf(err, "record balance adjust transaction") + } // 管理员调整余额后,重置低余额预警标记(可能余额已恢复到阈值以上) billing.ResetLowBalanceNotified(ctx, tenantID) @@ -603,6 +610,117 @@ func (s *sAdmin) AdjustBalance(ctx context.Context, req *v1.AdminWalletAdjustReq return &v1.AdminWalletAdjustRes{}, nil } +// OfflineRecharge 线下充值入账(管理后台) +// 场景:用户线下银行转账(人民币 CNY),运营确认到账后按平台汇率换算为 USD 入账。 +// 与 AdjustBalance 的区别:走正规充值链路——余额与累计充值同步累加、触发等级检查、 +// 流水类型为 recharge,并在描述中携带 CNY 快照(原始人民币 + 汇率 + 入账 USD + 转账流水号), +// 供现金对账与开票追溯。 +func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOfflineRechargeReq) (*v1.AdminWalletOfflineRechargeRes, error) { + tenantID := req.TenantID + cnyAmount := billing.NewFromFloat(req.Amount) + if !cnyAmount.IsPositive() { + return nil, common.NewBadRequestError("入账金额必须大于 0") + } + + // 唯一换汇点:CNY→USD 只经 billing.ConvertCNYToUSD(与充值履约 FulfillOrder 同一函数), + // 汇率取一次用于入账与快照,保证换算可重建。 + rate := billing.GetExchangeRateCNYToUSD(ctx) + usdAmount := billing.ConvertCNYToUSD(ctx, req.Amount) + if usdAmount.IsZero() { + return nil, common.NewBadRequestError("按当前汇率换算后到账金额为 0,请检查汇率配置") + } + + // 转账流水号软去重(防重复入账):同一租户下已入账过该流水号则拒绝。 + // 非强原子,配合前端 loading 禁用按钮,拦截人工重复提交足够。 + if req.TransactionNo != "" { + count, err := dao.BilTransactions.Ctx(ctx). + Where("tenant_id", tenantID). + Where("type", "recharge"). + Where("description like ?", "%转账流水号 "+req.TransactionNo).Count() + if err != nil { + return nil, err + } + if count > 0 { + return nil, common.NewBadRequestError("该转账流水号已入账,请勿重复操作") + } + } + + // redisCreditedAmount 记录事务内已发生的 Redis 钱包加款:Redis 是资金提交点、 + // 不受 DB 事务回滚,事务失败时必须按此补偿逆转。 + var redisCreditedAmount decimal.Decimal + + err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + // 钱包存在性校验 + walletID, err := billing.GetWalletID(ctx, tenantID) + if err != nil { + return common.NewBadRequestError("钱包不存在") + } + + // Redis 加款(资金提交点);流水快照取 Redis 返回值 + balanceAfter, frozenAfter, err := billing.CreditWalletRedis(ctx, tenantID, usdAmount) + // 先记录已发生的 Redis 加款(可能 >0 即使整体失败),供事务回滚后补偿逆转 + redisCreditedAmount = usdAmount + if err != nil { + return err + } + + // DB 仅累加累计充值(余额由物化器从 Redis 覆盖) + if _, err := g.DB().Ctx(ctx).Exec(ctx, + "UPDATE bil_wallets SET cumulative_recharge = cumulative_recharge + ?, updated_at = NOW() WHERE id = ?", + usdAmount, walletID); err != nil { + return err + } + + // 流水(type=recharge):描述拼装 CNY 快照 + 转账流水号,供现金对账与开票追溯 + desc := fmt.Sprintf("线下充值入账 CNY %.2f × 汇率 %.6f = USD %s", cnyAmount.InexactFloat64(), rate, usdAmount) + if req.Description != "" { + desc = req.Description + ";" + desc + } + if req.TransactionNo != "" { + desc += ";转账流水号 " + req.TransactionNo + } + if _, err := dao.BilTransactions.Ctx(ctx).Insert(do.BilTransactions{ + TenantId: tenantID, + WalletId: walletID, + Type: "recharge", + Amount: usdAmount, + BalanceAfter: balanceAfter, + FrozenAfter: frozenAfter, + Description: desc, + }); err != nil { + return gerror.Wrapf(err, "record offline recharge transaction") + } + + // 充值后检查租户等级(仅升不降)并重置低余额预警标记 + if err := billing.CheckAndUpgradeLevel(ctx, tenantID); err != nil { + return gerror.Wrapf(err, "check upgrade level after offline recharge") + } + billing.ResetLowBalanceNotified(ctx, tenantID) + + return nil + }) + if err != nil { + // 事务回滚:补偿逆转已发生的 Redis 钱包加款(入账未生效,钱包不得进账)。 + // 补偿自身失败意味着钱包多入账——打 CRITICAL 日志人工追回。 + if redisCreditedAmount.GreaterThan(billing.Zero) { + compCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + if _, _, compErr := billing.CreditWalletRedis(compCtx, tenantID, redisCreditedAmount.Neg()); compErr != nil { + g.Log().Errorf(ctx, + "CRITICAL: compensate offline recharge failed: tenant=%d usd=%s: %v — wallet over-credited, manual fix required", + tenantID, redisCreditedAmount.String(), compErr) + } + } + return nil, err + } + + return &v1.AdminWalletOfflineRechargeRes{ + CreditedUSD: billing.InexactFloat64(redisCreditedAmount), + Rate: rate, + Balance: billing.InexactFloat64(redisCreditedAmount), + }, nil +} + // GetWalletInfo 获取租户钱包信息(管理后台) func (s *sAdmin) GetWalletInfo(ctx context.Context, req *v1.AdminWalletInfoReq) (*v1.AdminWalletInfoRes, error) { type walletRow struct { @@ -657,6 +775,27 @@ func (s *sAdmin) GetWalletTransactions(ctx context.Context, req *v1.AdminWalletT return nil, err } + // 批量关联用户名:user_id → tnt_users.username(consume 类型为实际消费用户,其余类型一般为空)。 + // 复用审计模块的批量查询工具,一次 IN 查询解决,避免逐行 N+1。 + if len(records) > 0 { + userKeys := make([]string, 0, len(records)) + seen := make(map[int64]bool, len(records)) + for _, r := range records { + if r.UserId > 0 && !seen[r.UserId] { + seen[r.UserId] = true + userKeys = append(userKeys, fmt.Sprintf("%d:%d", req.TenantID, r.UserId)) + } + } + if len(userKeys) > 0 { + userMap := common.BatchQueryUserNames(ctx, userKeys) + for _, r := range records { + if r.UserId > 0 { + r.Username = userMap[fmt.Sprintf("%d:%d", req.TenantID, r.UserId)] + } + } + } + } + return &v1.AdminWalletTransactionListRes{ List: records, Total: total, @@ -695,6 +834,247 @@ func (s *sAdmin) SetWarningThreshold(ctx context.Context, req *v1.AdminWalletSet return &v1.AdminWalletSetWarningThresholdRes{}, nil } +// frozenReleaseMinAge 手动释放冻结的保护期。冻结时长低于此值的预扣大概率仍有请求在途 +// (长流式/realtime 会话),释放会使该请求失去超扣保护,必须显式 force 并由前端二次确认。 +const frozenReleaseMinAge = 10 * time.Minute + +// frozenTaskInfo 冻结项关联的异步任务信息(用于释放护栏判断) +type frozenTaskInfo struct { + Status string `json:"status"` + BillingSettled bool `json:"billing_settled"` +} + +// isTaskTerminal 异步任务是否已到终态 +func isTaskTerminal(status string) bool { + return status == "SUCCESS" || status == "FAILURE" +} + +// frozenItemGuard 计算单笔冻结项的释放护栏结论。 +// 返回:是否可释放、是否需要强制、拦截原因。 +func frozenItemGuard(task *frozenTaskInfo, age time.Duration) (releasable bool, needForce bool, blockReason string) { + if task != nil { + if !isTaskTerminal(task.Status) { + // 任务仍在推进:预扣由任务结算流程负责(成功结算/失败退款/30分钟超时退款), + // 手动释放会让后续结算认领不到冻结、track 终态错乱 + return false, false, "关联异步任务进行中,任务完成或超时后将自动结算/退款" + } + if !task.BillingSettled { + // 终态但未结算:轮询器 15 秒内会重试结算/退款,手动释放只会与之竞争 + return false, false, "关联异步任务待结算,系统将自动重试,请稍后刷新" + } + // 终态且已结算但 track 仍 frozen:结算认领异常留下的真孤儿,允许释放 + } + if age < frozenReleaseMinAge { + return true, true, "" + } + return true, false, "" +} + +// queryFrozenTaskInfos 批量查询冻结项关联的异步任务(request_id 剥离 _adjust 后缀后关联 tsk_model_tasks) +func queryFrozenTaskInfos(ctx context.Context, tenantID int64, requestIDs []string) map[string]*frozenTaskInfo { + result := make(map[string]*frozenTaskInfo) + if len(requestIDs) == 0 { + return result + } + baseIDs := make([]string, 0, len(requestIDs)) + seen := make(map[string]bool, len(requestIDs)) + for _, id := range requestIDs { + base := strings.TrimSuffix(id, "_adjust") + if !seen[base] { + seen[base] = true + baseIDs = append(baseIDs, base) + } + } + + type taskRow struct { + RequestId string `json:"request_id"` + Status string `json:"status"` + BillingSettled bool `json:"billing_settled"` + } + var rows []taskRow + if err := dao.TskModelTasks.Ctx(ctx). + Where("tenant_id", tenantID). + WhereIn("request_id", baseIDs). + Fields("request_id, status, billing_settled"). + Scan(&rows); err != nil { + g.Log().Warningf(ctx, "query frozen task infos: tenant=%d: %v", tenantID, err) + return result + } + for _, r := range rows { + result[r.RequestId] = &frozenTaskInfo{Status: r.Status, BillingSettled: r.BillingSettled} + } + return result +} + +// GetWalletFrozenItems 获取租户钱包冻结明细(管理后台)。 +// 数据源为 Redis 预扣明细(权威),DB 预扣追踪表已废弃。 +func (s *sAdmin) GetWalletFrozenItems(ctx context.Context, req *v1.AdminWalletFrozenItemListReq) (*v1.AdminWalletFrozenItemListRes, error) { + items, err := billing.GetFrozenItems(ctx, req.TenantID) + if err != nil { + return nil, err + } + + requestIDs := make([]string, 0, len(items)) + for _, it := range items { + requestIDs = append(requestIDs, it.RequestID) + } + taskInfos := queryFrozenTaskInfos(ctx, req.TenantID, requestIDs) + + now := time.Now() + out := make([]*v1.AdminWalletFrozenItem, 0, len(items)) + for _, it := range items { + createdAt := time.Unix(it.CreatedAt, 0) + age := now.Sub(createdAt) + task := taskInfos[strings.TrimSuffix(it.RequestID, "_adjust")] + releasable, needForce, blockReason := frozenItemGuard(task, age) + taskStatus := "" + if task != nil { + taskStatus = task.Status + } + out = append(out, &v1.AdminWalletFrozenItem{ + RequestID: it.RequestID, + ModelName: it.ModelName, + Amount: it.Amount, + CreatedAt: createdAt.String(), + AgeSeconds: int64(age.Seconds()), + Releasable: releasable, + NeedForce: needForce, + BlockReason: blockReason, + TaskStatus: taskStatus, + }) + } + + return &v1.AdminWalletFrozenItemListRes{List: out}, nil +} + +// ReleaseWalletFrozenItem 按笔释放冻结(管理后台运维逃生舱)。 +// 释放走 billing.UnfreezePreDeduct 的 Redis 认领路径:认领即删预扣 hash、幂等、逐笔精确, +// 绝不直接改写钱包 hash 的 frozen_balance 汇总值。 +func (s *sAdmin) ReleaseWalletFrozenItem(ctx context.Context, req *v1.AdminWalletFrozenReleaseReq) (*v1.AdminWalletFrozenReleaseRes, error) { + items, err := billing.GetFrozenItems(ctx, req.TenantID) + if err != nil { + return nil, err + } + var found *billing.FrozenItem + for i := range items { + if items[i].RequestID == req.RequestID { + found = &items[i] + break + } + } + if found == nil { + return nil, common.NewNotFoundError("冻结项(可能已结算/已释放)") + } + + // 护栏一:关联异步任务的预扣由任务结算流程负责,禁止手动释放(force 也不放行) + task := queryFrozenTaskInfos(ctx, req.TenantID, []string{req.RequestID})[strings.TrimSuffix(req.RequestID, "_adjust")] + age := time.Since(time.Unix(found.CreatedAt, 0)) + releasable, needForce, blockReason := frozenItemGuard(task, age) + if !releasable { + return nil, common.NewBadRequestError(blockReason) + } + + // 护栏二:保护期内(可能仍有请求在途)必须显式强制释放 + if needForce && !req.Force { + return nil, common.NewBadRequestError(fmt.Sprintf( + "该笔冻结仅 %.0f 分钟,对应请求可能仍在进行中(长流式/实时会话),释放将使其失去超扣保护;确认后请使用强制释放", + age.Minutes())) + } + + released, err := billing.UnfreezePreDeduct(ctx, req.TenantID, req.RequestID) + if err != nil { + return nil, err + } + + // 业务日志补充释放原因与操作上下文(HTTP 层审计由 OperationLog 中间件记录) + g.Log().Infof(ctx, "admin release frozen prededuct: tenant=%d request=%s amount=%s age=%s force=%v reason=%s", + req.TenantID, req.RequestID, released.String(), age.Round(time.Second), req.Force, req.Reason) + + return &v1.AdminWalletFrozenReleaseRes{ + ReleasedAmount: billing.InexactFloat64(released), + }, nil +} + +// ReleaseAllWalletFrozenItems 一键释放租户全部冻结(管理后台运维逃生舱)。 +// 遍历 Redis 活跃冻结明细,复用 frozenItemGuard 护栏逐项筛选: +// - 任务关联在途/待结算项自动跳过(由任务结算流程负责,禁止手动释放); +// - 保护期内(冻结不足 frozenReleaseMinAge)项默认跳过,Force=true 时才释放; +// - 其余项逐个 UnfreezePreDeduct(Lua 认领即删,幂等,与并发结算竞争安全)。 +// +// 返回实际释放笔数/金额与跳过原因摘要(去重)。 +func (s *sAdmin) ReleaseAllWalletFrozenItems(ctx context.Context, req *v1.AdminWalletFrozenReleaseAllReq) (*v1.AdminWalletFrozenReleaseAllRes, error) { + items, err := billing.GetFrozenItems(ctx, req.TenantID) + if err != nil { + return nil, err + } + + requestIDs := make([]string, 0, len(items)) + for _, it := range items { + requestIDs = append(requestIDs, it.RequestID) + } + taskInfos := queryFrozenTaskInfos(ctx, req.TenantID, requestIDs) + + now := time.Now() + releasedAmount := billing.Zero + var releasedCount int64 + var skippedCount int64 + skippedSet := make(map[string]bool) + var skippedReasons []string + + for i := range items { + it := items[i] + age := now.Sub(time.Unix(it.CreatedAt, 0)) + task := taskInfos[strings.TrimSuffix(it.RequestID, "_adjust")] + releasable, needForce, blockReason := frozenItemGuard(task, age) + + switch { + case !releasable: + skippedCount++ + if !skippedSet[blockReason] { + skippedSet[blockReason] = true + skippedReasons = append(skippedReasons, blockReason) + } + continue + case needForce && !req.Force: + reason := "保护期内(冻结不足 10 分钟),未勾选强制释放" + skippedCount++ + if !skippedSet[reason] { + skippedSet[reason] = true + skippedReasons = append(skippedReasons, reason) + } + continue + } + + claimed, err := billing.UnfreezePreDeduct(ctx, req.TenantID, it.RequestID) + if err != nil { + // 单项释放失败不中断整体:记录并继续,其余项照常释放 + g.Log().Warningf(ctx, "admin release-all: unfreeze %s failed: %v", it.RequestID, err) + skippedCount++ + reason := fmt.Sprintf("释放失败:%v", err) + if !skippedSet[reason] { + skippedSet[reason] = true + skippedReasons = append(skippedReasons, reason) + } + continue + } + if claimed.IsPositive() { + releasedCount++ + releasedAmount = billing.AddMoney(releasedAmount, claimed) + } + } + + // 业务日志补充释放上下文(HTTP 层审计由 OperationLog 中间件记录) + g.Log().Infof(ctx, "admin release-all frozen: tenant=%d released=%d amount=%s skipped=%d force=%v reason=%s", + req.TenantID, releasedCount, releasedAmount.String(), skippedCount, req.Force, req.Reason) + + return &v1.AdminWalletFrozenReleaseAllRes{ + ReleasedCount: releasedCount, + ReleasedAmount: billing.InexactFloat64(releasedAmount), + SkippedCount: skippedCount, + SkippedReasons: skippedReasons, + }, nil +} + // GetAllTransactions 获取所有租户交易流水(管理后台) func (s *sAdmin) GetAllTransactions(ctx context.Context, req *v1.AdminTransactionListReq) (*v1.AdminTransactionListRes, error) { page, pageSize := common.NormalizePagination(req.Page, req.PageSize) @@ -805,10 +1185,10 @@ func (s *sAdmin) GetDashboardRecentAlerts(ctx context.Context, req *v1.AdminDash // ExportUsageLogs exports usage logs to CSV or Excel. func (s *sAdmin) ExportUsageLogs(ctx context.Context, req *v1.AdminUsageLogExportReq) (*v1.AdminUsageLogExportRes, error) { - if err := common.ValidateDateParam(req.StartDate, "开始日期"); err != nil { + if err := common.ValidateDateTimeParam(req.StartDate, "开始时间"); err != nil { return nil, err } - if err := common.ValidateDateParam(req.EndDate, "结束日期"); err != nil { + if err := common.ValidateDateTimeParam(req.EndDate, "结束时间"); err != nil { return nil, err } @@ -834,14 +1214,30 @@ func (s *sAdmin) ExportUsageLogs(ctx context.Context, req *v1.AdminUsageLogExpor buildUsageWhere := func() (string, []any) { var conditions []string var args []any + if req.ID > 0 { + conditions = append(conditions, "u.id = ?") + args = append(args, req.ID) + } if req.TenantID > 0 { conditions = append(conditions, "u.tenant_id = ?") args = append(args, req.TenantID) } + if req.UserID > 0 { + conditions = append(conditions, "u.user_id = ?") + args = append(args, req.UserID) + } if req.Username != "" { conditions = append(conditions, "t.username LIKE ?") args = append(args, "%"+req.Username+"%") } + if req.ApiKeyID > 0 { + conditions = append(conditions, "u.api_key_id = ?") + args = append(args, req.ApiKeyID) + } + if req.ChannelID > 0 { + conditions = append(conditions, "u.channel_id = ?") + args = append(args, req.ChannelID) + } if req.Model != "" { conditions = append(conditions, "u.model_name = ?") args = append(args, req.Model) @@ -856,11 +1252,11 @@ func (s *sAdmin) ExportUsageLogs(ctx context.Context, req *v1.AdminUsageLogExpor } if req.StartDate != "" { conditions = append(conditions, "u.created_at >= ?") - args = append(args, req.StartDate+" 00:00:00") + args = append(args, common.StartOfRange(req.StartDate)) } if req.EndDate != "" { conditions = append(conditions, "u.created_at <= ?") - args = append(args, req.EndDate+" 23:59:59") + args = append(args, common.EndOfRange(req.EndDate)) } where := "" if len(conditions) > 0 { diff --git a/internal/logic/admin/order.go b/internal/logic/admin/order.go index 10be5dd..3aed35f 100644 --- a/internal/logic/admin/order.go +++ b/internal/logic/admin/order.go @@ -3,6 +3,7 @@ package admin import ( "context" "fmt" + "time" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" @@ -74,10 +75,10 @@ func (s *sAdmin) GetOrder(ctx context.Context, req *v1.OrderDetailReq) (*v1.Orde func (s *sAdmin) RefundOrder(ctx context.Context, req *v1.OrderRefundReq) (*v1.OrderRefundRes, error) { adminUserID := common.GetCtxUserID(ctx) - // 钱包缓存失效必须在事务提交后执行(与 FulfillOrder 相同的时序约束: - // 提交前 DEL 会被并发 GetWallet 读旧 DB 值回写污染)。 - var walletTouchedTenantID int64 - var walletTouched bool + // redisDeductedAmount 记录事务内已发生的 Redis 钱包扣款:Redis 是资金提交点、 + // 不受 DB 事务回滚,事务失败时必须按此补偿逆转。 + var redisDeductedAmount decimal.Decimal + var orderTenantID int64 res := &v1.OrderRefundRes{} err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { @@ -119,13 +120,14 @@ func (s *sAdmin) RefundOrder(ctx context.Context, req *v1.OrderRefundReq) (*v1.O case "recharge": // 已履约:钱包已入账 USD,必须原额扣回;未履约(paid)钱包未入账,无需扣减 if order.Status == "fulfilled" { + orderTenantID = order.TenantID deducted, err := deductWalletForRefundTx(ctx, req.Id, order.TenantID) + // 先记录已发生的 Redis 扣款(可能 >0 即使整体失败),供事务回滚后补偿逆转 + redisDeductedAmount = deducted if err != nil { return err } res.WalletDeductedUsd = billing.InexactFloat64(deducted) - walletTouchedTenantID = order.TenantID - walletTouched = true } case "new_plan", "renew", "upgrade": // 已履约:撤销该套餐当前生效的订阅。若订阅已被后续订单替换(active 行的 @@ -180,13 +182,19 @@ func (s *sAdmin) RefundOrder(ctx context.Context, req *v1.OrderRefundReq) (*v1.O return nil }) if err != nil { + // 事务回滚:补偿逆转已发生的 Redis 钱包扣款(退款未生效,钱包不得扣回)。 + // 补偿自身失败意味着钱包被多扣——打 CRITICAL 日志人工追回。 + if redisDeductedAmount.GreaterThan(billing.Zero) && orderTenantID > 0 { + compCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + if _, _, compErr := billing.CreditWalletRedis(compCtx, orderTenantID, redisDeductedAmount); compErr != nil { + g.Log().Errorf(ctx, + "CRITICAL: compensate refund redis debit failed: tenant=%d usd=%s order=%d: %v — wallet under-credited, manual fix required", + orderTenantID, redisDeductedAmount.String(), req.Id, compErr) + } + } return nil, err } - - // 事务提交后清除钱包两级缓存(进程内 + Redis),避免扣回后仍按旧余额预扣 - if walletTouched { - billing.InvalidateWallet(ctx, walletTouchedTenantID) - } return res, nil } @@ -229,50 +237,36 @@ func deductWalletForRefundTx(ctx context.Context, orderID int64, tenantID int64) } // 2. 扣回余额与累计充值(cumulative_recharge 同步回退,保持等级门槛口径真实)。 - // 条件要求【可用余额】(balance - frozen_balance)足够:不足说明充值额度已被消费, - // 继续退款平台将净亏,拒绝并提示人工协商处理。 - result, err := g.DB().Ctx(ctx).Exec(ctx, - "UPDATE bil_wallets SET balance = balance - ?, cumulative_recharge = cumulative_recharge - ?, updated_at = NOW() WHERE tenant_id = ? AND balance - frozen_balance >= ?", - credit.Amount, credit.Amount, tenantID, credit.Amount) + // Redis 权威化架构:可用余额门槛在 Redis 权威值上原子判断(balance - frozen >= amount, + // 不足说明充值额度已被消费,继续退款平台将净亏,拒绝并提示人工协商处理); + // bil_wallets.balance 由物化器随后从 Redis 覆盖,DB 侧只回退累计充值。 + balanceAfter, frozenAfter, ok, err := billing.DebitWalletRedis(ctx, tenantID, credit.Amount) if err != nil { return billing.Zero, err } - affected, err := result.RowsAffected() - if err != nil { - return billing.Zero, err - } - if affected == 0 { + if !ok { return billing.Zero, common.NewBadRequestError("钱包可用余额不足(充值额度可能已被消费),无法退款") } - // 3. 记录扣减流水(金额记负值,与 consume 类型的方向约定一致) - var balance *struct { - Balance decimal.Decimal `json:"balance"` - FrozenBalance decimal.Decimal `json:"frozen_balance"` - } - err = dao.BilWallets.Ctx(ctx). - Where("tenant_id", tenantID). - Fields("balance, frozen_balance"). - Scan(&balance) - if err != nil { - return billing.Zero, err - } - if balance == nil { - return billing.Zero, gerror.New("wallet not found after refund deduction") + if _, err = g.DB().Ctx(ctx).Exec(ctx, + "UPDATE bil_wallets SET cumulative_recharge = cumulative_recharge - ?, updated_at = NOW() WHERE tenant_id = ?", + credit.Amount, tenantID); err != nil { + return credit.Amount, err } + // 3. 记录扣减流水(金额记负值,与 consume 类型的方向约定一致;快照取 Redis 返回值) if _, err = dao.BilTransactions.Ctx(ctx).Insert(do.BilTransactions{ TenantId: tenantID, WalletId: credit.WalletId, Type: "refund", Amount: credit.Amount.Neg(), - BalanceAfter: balance.Balance, - FrozenAfter: balance.FrozenBalance, + BalanceAfter: balanceAfter, + FrozenAfter: frozenAfter, RelatedId: orderID, RelatedType: "order", Description: fmt.Sprintf("Refund: order #%d, deduct USD %.6f (original recharge credit)", orderID, credit.Amount.InexactFloat64()), }); err != nil { - return billing.Zero, err + return credit.Amount, err } return credit.Amount, nil diff --git a/internal/logic/admin/task_management.go b/internal/logic/admin/task_management.go index 9d9e991..39f9d00 100644 --- a/internal/logic/admin/task_management.go +++ b/internal/logic/admin/task_management.go @@ -17,6 +17,13 @@ import ( // TaskList 大模型异步任务列表 func (s *sAdmin) TaskList(ctx context.Context, req *v1.TaskListReq) (*v1.TaskListRes, error) { + if err := common.ValidateDateTimeParam(req.StartDate, "开始时间"); err != nil { + return nil, err + } + if err := common.ValidateDateTimeParam(req.EndDate, "结束时间"); err != nil { + return nil, err + } + m := dao.TskModelTasks.Ctx(ctx).OrderDesc("id") if req.Status != "" { m = m.Where("status", req.Status) @@ -27,6 +34,21 @@ func (s *sAdmin) TaskList(ctx context.Context, req *v1.TaskListReq) (*v1.TaskLis if req.PublicTaskID != "" { m = m.Where("public_task_id", req.PublicTaskID) } + if req.ModelName != "" { + m = m.Where("model_name", req.ModelName) + } + if req.TenantID > 0 { + m = m.Where("tenant_id", req.TenantID) + } + if req.UserID > 0 { + m = m.Where("user_id", req.UserID) + } + if req.StartDate != "" { + m = m.Where("created_at >= ?", common.StartOfRange(req.StartDate)) + } + if req.EndDate != "" { + m = m.Where("created_at <= ?", common.EndOfRange(req.EndDate)) + } var tasks []struct { Id int64 `json:"id"` diff --git a/internal/logic/admin/wallet_frozen_test.go b/internal/logic/admin/wallet_frozen_test.go new file mode 100644 index 0000000..10cd791 --- /dev/null +++ b/internal/logic/admin/wallet_frozen_test.go @@ -0,0 +1,97 @@ +package admin + +import ( + "testing" + "time" +) + +// TestFrozenItemGuard 覆盖手动释放冻结的护栏矩阵: +// 任务关联(非终态/终态未结算/终态已结算)×冻结时长(保护期内/外)。 +func TestFrozenItemGuard(t *testing.T) { + t.Parallel() + + oldAge := frozenReleaseMinAge + time.Minute + youngAge := frozenReleaseMinAge - time.Minute + + cases := []struct { + name string + task *frozenTaskInfo + age time.Duration + wantReleasable bool + wantNeedForce bool + wantBlocked bool + }{ + { + name: "无任务关联+超过保护期→直接可释放", + task: nil, age: oldAge, + wantReleasable: true, wantNeedForce: false, + }, + { + name: "无任务关联+保护期内→需强制", + task: nil, age: youngAge, + wantReleasable: true, wantNeedForce: true, + }, + { + name: "任务进行中→拦截(force 也不放行)", + task: &frozenTaskInfo{Status: "IN_PROGRESS"}, age: oldAge, + wantReleasable: false, wantBlocked: true, + }, + { + name: "任务已提交→拦截", + task: &frozenTaskInfo{Status: "SUBMITTED"}, age: youngAge, + wantReleasable: false, wantBlocked: true, + }, + { + name: "任务终态但未结算→拦截(轮询器将自动重试)", + task: &frozenTaskInfo{Status: "FAILURE", BillingSettled: false}, age: oldAge, + wantReleasable: false, wantBlocked: true, + }, + { + name: "任务终态且已结算+超过保护期→真孤儿,可释放", + task: &frozenTaskInfo{Status: "SUCCESS", BillingSettled: true}, age: oldAge, + wantReleasable: true, wantNeedForce: false, + }, + { + name: "任务终态且已结算+保护期内→需强制", + task: &frozenTaskInfo{Status: "SUCCESS", BillingSettled: true}, age: youngAge, + wantReleasable: true, wantNeedForce: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + releasable, needForce, blockReason := frozenItemGuard(tc.task, tc.age) + if releasable != tc.wantReleasable { + t.Errorf("releasable = %v, want %v", releasable, tc.wantReleasable) + } + if needForce != tc.wantNeedForce { + t.Errorf("needForce = %v, want %v", needForce, tc.wantNeedForce) + } + if tc.wantBlocked && blockReason == "" { + t.Errorf("blockReason 为空,预期给出拦截原因") + } + if !tc.wantBlocked && blockReason != "" { + t.Errorf("blockReason = %q,预期为空", blockReason) + } + }) + } +} + +// TestIsTaskTerminal 任务终态判断 +func TestIsTaskTerminal(t *testing.T) { + t.Parallel() + + terminal := []string{"SUCCESS", "FAILURE"} + nonTerminal := []string{"NOT_START", "SUBMITTED", "IN_PROGRESS", "QUEUED", ""} + + for _, s := range terminal { + if !isTaskTerminal(s) { + t.Errorf("isTaskTerminal(%q) = false, want true", s) + } + } + for _, s := range nonTerminal { + if isTaskTerminal(s) { + t.Errorf("isTaskTerminal(%q) = true, want false", s) + } + } +} diff --git a/internal/logic/billing/reconciliation.go b/internal/logic/billing/reconciliation.go index 7982538..330fa28 100644 --- a/internal/logic/billing/reconciliation.go +++ b/internal/logic/billing/reconciliation.go @@ -7,7 +7,8 @@ import ( "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" - "github.com/shopspring/decimal" + "github.com/gogf/gf/v2/os/gtime" + "github.com/gogf/gf/v2/util/gconv" "github.com/qianfree/team-api/internal/dao" ) @@ -22,6 +23,7 @@ type DailyReconciliationResult struct { RecordCount int64 MissingSettlementCount int64 // 交叉对账:成功用量中无计费记录的请求数(漏结算=免单) MissingSettlementCost float64 // 交叉对账:漏结算请求的用量记录费用合计(结算失败时通常为 0,条数才是信号) + OrphanRecordCount int64 // 崩溃窗口探测:有计费记录但无消费流水(结算第 2/3 步失败且补偿未覆盖的残留) } // RunDailyReconciliation 执行日对账 @@ -120,160 +122,95 @@ func RunDailyReconciliation(ctx context.Context) (*DailyReconciliationResult, er yesterday, miss.Cnt, miss.Cost) } - // 5. 冻结余额一致性校验 + // 4.6 崩溃窗口探测:昨日有计费记录但无对应 consume 流水。 + // 结算骨架是「闸门(records)→ Redis 扣款 → 流水」三步,第 2/3 步失败会触发补偿删除记录, + // 补偿自身失败时留下此类孤儿记录(有账单无账本),需要人工核查资金状态。 + type orphanRow struct { + Cnt int64 `json:"cnt"` + } + var orphan orphanRow + err = dao.BilRecords.Ctx(ctx). + As("r"). + LeftJoin("bil_transactions t", "t.related_id = r.id AND t.related_type = 'billing_record' AND t.type = 'consume'"). + Where("r.settled_at >= ?", yesterday+" 00:00:00"). + Where("r.settled_at < ?", today+" 00:00:00"). + Where("t.id IS NULL"). + Fields("COUNT(*) as cnt"). + Scan(&orphan) + if err != nil { + g.Log().Errorf(ctx, "[RECONCILIATION] orphan-record check failed: %v", err) + } else if orphan.Cnt > 0 { + result.OrphanRecordCount = orphan.Cnt + g.Log().Warningf(ctx, + "[RECONCILIATION WARNING] date=%s orphan billing records (no consume transaction): count=%d — settlement crash-window residue, needs manual fund check", + yesterday, orphan.Cnt) + } + + // 5. 冻结余额一致性校验(Redis 权威值 vs DB 物化值) reconcileFrozenBalance(ctx) return result, nil } -// reconcileFrozenBalance 校验所有租户的 frozen_balance 与追踪记录是否一致 +// reconcileFrozenBalance 校验 Redis 权威冻结值与 DB 物化冻结值是否一致。 +// 物化窗口(数秒)内的漂移属正常滞后;漂移持续超过 2 倍物化间隔才告警—— +// 意味着物化器停滞或某条资金路径没有正确标记脏租户。 func reconcileFrozenBalance(ctx context.Context) { - type frozenRow struct { - TenantID int64 `json:"tenant_id"` - FrozenBalance float64 `json:"frozen_balance"` - } - var wallets []frozenRow - dao.BilWallets.Ctx(ctx). - Where("frozen_balance > 0"). - Fields("tenant_id, frozen_balance"). - Scan(&wallets) + materializeLag := walletMaterializeInterval(ctx) * 2 - for _, w := range wallets { - type sumRow struct { - Total float64 `json:"total"` - } - var tracked sumRow - dao.BilPredeductTracks.Ctx(ctx). - Where("tenant_id", w.TenantID). - Where("status", "frozen"). - Fields("COALESCE(SUM(amount), 0) as total"). - Scan(&tracked) - - diff := w.FrozenBalance - tracked.Total - if diff > 0.000001 || diff < -0.000001 { - g.Log().Warningf(ctx, - "[RECONCILIATION WARNING] tenant=%d frozen_balance=%.6f tracked=%.6f diff=%.6f", - w.TenantID, w.FrozenBalance, tracked.Total, diff) - } - } -} - -// CleanSettledPreDeductTracks 清理已终态的预扣追踪记录 -// 删除 2 天前状态为 settled / released / expired 的记录,防止表无限增长 -func CleanSettledPreDeductTracks(ctx context.Context) { - const ( - retentionDays = 2 - batchSize = 5000 - ) - - cutoff := time.Now().AddDate(0, 0, -retentionDays) - - var totalDeleted int64 + cursor := 0 for { - result, err := g.DB().Ctx(ctx).Exec(ctx, - `DELETE FROM bil_prededuct_tracks WHERE id IN ( - SELECT id FROM bil_prededuct_tracks - WHERE status IN ('settled', 'released', 'expired') - AND created_at < ? - LIMIT ? - )`, cutoff, batchSize) + res, err := g.Redis().Do(ctx, "SCAN", cursor, "MATCH", "wallet:v2:*", "COUNT", 200) if err != nil { - g.Log().Errorf(ctx, "[PRE-DEDUCT] clean settled tracks: delete failed: %v", err) + g.Log().Warningf(ctx, "[RECONCILIATION] scan wallet hashes failed: %v", err) return } - rows, _ := result.RowsAffected() - if rows == 0 { - break + arr := res.Array() + if len(arr) != 2 { + return } - totalDeleted += rows - } - - if totalDeleted > 0 { - g.Log().Infof(ctx, "[PRE-DEDUCT] cleaned %d settled/released/expired tracks older than %d days", - totalDeleted, retentionDays) - } -} - -// CleanExpiredPreDeducts 清理过期的预扣记录(防止异常占用余额) -// 超过 PreDeductMaxAge 未结算的预扣应被清理 -func CleanExpiredPreDeducts(ctx context.Context) { - // 1. 查询所有超过 PreDeductMaxAge 仍未结算的冻结记录 - type trackRow struct { - RequestID string `json:"request_id"` - TenantID int64 `json:"tenant_id"` - Amount decimal.Decimal `json:"amount"` - } - var tracks []trackRow - - cutoff := time.Now().Add(-time.Duration(PreDeductMaxAge) * time.Second) - err := dao.BilPredeductTracks.Ctx(ctx). - Where("status", "frozen"). - Where("created_at < ?", cutoff). - Fields("request_id, tenant_id, amount"). - Scan(&tracks) - if err != nil { - g.Log().Errorf(ctx, "[PRE-DEDUCT] clean expired: query failed: %v", err) - return - } - - if len(tracks) == 0 { - return - } - - g.Log().Warningf(ctx, "[PRE-DEDUCT] found %d orphaned pre-deducts to clean", len(tracks)) - - // 2. 按 tenant_id 分组:记录每个租户名下待清理的 (request_id, amount) 明细。 - // 注意:金额累加会推迟到第 3 步「实际认领成功」之后,只对本实例真正认领到的 tracks 求和。 - tenantTracks := make(map[int64][]trackRow) - for _, t := range tracks { - tenantTracks[t.TenantID] = append(tenantTracks[t.TenantID], t) - } - - // 3. 逐租户释放冻结金额 - // 多实例部署时,N 个实例会同时扫到同一批过期 tracks。为避免重复/超额释放 frozen_balance, - // 采用「先 claim 再 release」:先用带 status='frozen' 谓词的条件更新把 track 标记为 expired, - // 仅对本实例真正从 frozen 翻成 expired(RowsAffected > 0)的 track 累加金额并释放。 - // status='frozen' 的条件更新是跨实例的原子闸门——同一 request_id 只有一个实例能命中, - // 因此各实例释放的金额之和恒等于实际冻结总额,不会重复或超额。 - for tenantID, tenantRows := range tenantTracks { - // 3a. 逐条 claim,仅累加本实例成功认领的金额 - claimedAmount := decimal.Zero - claimed := 0 - for _, tr := range tenantRows { - res, err := g.DB().Ctx(ctx).Exec(ctx, - "UPDATE bil_prededuct_tracks SET status = 'expired', expired_at = $1 WHERE tenant_id = $2 AND request_id = $3 AND status = 'frozen'", - time.Now(), tenantID, tr.RequestID) - if err != nil { - g.Log().Warningf(ctx, "[PRE-DEDUCT] clean expired: mark track expired failed: tenant=%d request=%s err=%v", tenantID, tr.RequestID, err) + cursor = gconv.Int(arr[0]) + for _, key := range gconv.Strings(arr[1]) { + var tenantID int64 + if _, err := fmt.Sscanf(key, "wallet:v2:%d", &tenantID); err != nil || tenantID <= 0 { continue } - if rows, _ := res.RowsAffected(); rows > 0 { - claimedAmount = claimedAmount.Add(tr.Amount) - claimed++ - } + checkOneWalletFrozenDrift(ctx, tenantID, materializeLag) } - - // 3b. 本实例未认领到任何 track(全部已被其它实例处理)→ 跳过释放,绝不重复入账 - if claimed == 0 { - g.Log().Infof(ctx, "[PRE-DEDUCT] clean expired: tenant=%d all %d tracks already claimed by other instances, skip release", tenantID, len(tenantRows)) - continue + if cursor == 0 { + return } - - // 3c. 释放 DB frozen_balance(金额仅来自本实例实际认领的 tracks,精确无重复) - _, err := g.DB().Ctx(ctx).Exec(ctx, - "UPDATE bil_wallets SET frozen_balance = GREATEST(frozen_balance - $1, 0), updated_at = $2 WHERE tenant_id = $3", - claimedAmount, time.Now(), tenantID) - if err != nil { - g.Log().Errorf(ctx, "[PRE-DEDUCT] clean expired: unfreeze failed: tenant=%d err=%v", tenantID, err) - continue - } - - // 清除缓存 - walletCache.Delete(ctx, fmt.Sprintf("%d", tenantID)) - InvalidateWalletRedis(ctx, tenantID) - - g.Log().Infof(ctx, - "[PRE-DEDUCT] cleaned orphaned: tenant=%d amount=%.6f count=%d", - tenantID, claimedAmount.InexactFloat64(), claimed) } } + +// checkOneWalletFrozenDrift 比对单个租户的 Redis/DB 冻结值,超窗漂移告警 +func checkOneWalletFrozenDrift(ctx context.Context, tenantID int64, materializeLag time.Duration) { + _, frozenMicro, exists, err := readWalletHash(ctx, tenantID) + if err != nil || !exists { + return + } + + var w *struct { + FrozenBalance float64 `json:"frozen_balance"` + UpdatedAt *gtime.Time `json:"updated_at"` + } + err = dao.BilWallets.Ctx(ctx). + Where("tenant_id", tenantID). + Fields("frozen_balance, updated_at"). + Scan(&w) + if err != nil || w == nil { + return + } + + diff := NewFromFloat(w.FrozenBalance).Sub(FromMicro(frozenMicro)).Abs() + if !diff.GreaterThan(NewFromFloat(0.000001)) { + return + } + // DB 在物化窗口内刚刷新过:漂移是正常滞后,下轮物化会收敛 + if w.UpdatedAt != nil && time.Since(w.UpdatedAt.Time) <= materializeLag { + return + } + g.Log().Warningf(ctx, + "[RECONCILIATION WARNING] tenant=%d frozen drift persists: db=%.6f redis=%.6f db_updated_at=%v (materializer stuck or dirty-mark missing?)", + tenantID, w.FrozenBalance, InexactFloat64(FromMicro(frozenMicro)), w.UpdatedAt) +} diff --git a/internal/logic/billing/settlement.go b/internal/logic/billing/settlement.go index 6a5bfbb..187dd0b 100644 --- a/internal/logic/billing/settlement.go +++ b/internal/logic/billing/settlement.go @@ -9,19 +9,17 @@ import ( do "github.com/qianfree/team-api/internal/model/do" - "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" - "github.com/shopspring/decimal" "github.com/qianfree/team-api/internal/dao" rcommon "github.com/qianfree/team-api/relay/common" ) // errAlreadySettled 结算幂等哨兵:当同一 request_id 的计费记录已存在(bil_records 唯一约束冲突)时, -// 从结算事务闭包返回该错误使整个事务回滚(钱包扣款一并撤销),调用方据此识别为幂等空操作, -// 不重复扣款、不重复写账单。必须原样返回(不可 gerror.Wrap),以保证 errors.Is 能识别。 +// 骨架在任何资金变动之前返回该错误,调用方据此识别为幂等空操作,不重复扣款、不重复写账单。 +// 必须原样返回(不可 gerror.Wrap),以保证 errors.Is 能识别。 var errAlreadySettled = errors.New("billing: request already settled (idempotent skip)") // isDuplicateKeyErr 判断 error 是否为 PostgreSQL 唯一约束冲突(SQLSTATE 23505)。 @@ -66,137 +64,83 @@ func calcSettlementDiff(preDeductAmount, actualCost float64) (refundAmt, supplem return } -// settlementTxParams 结算事务参数:三处结算共用的事务骨架的可变部分。 +// settlementTxParams 结算参数:三处结算共用的骨架的可变部分。 type settlementTxParams struct { tenantID int64 - walletID int64 // 钱包 ID - preDeductAmount float64 // 预扣冻结金额(事务内从 frozen_balance 释放) - actualCost float64 // 实际扣款金额(事务内从 balance 扣除) + walletID int64 // 钱包 ID(仅用于流水记录,骨架不再更新钱包行) + preDeductAmount float64 // 预扣金额(用于与 Redis 实际认领金额比对告警) + actualCost float64 // 实际扣款金额(SettleClaim 从 Redis 余额扣除) logPrefix string // 错误信息前缀(settle / settle_with_usage / settle task) - // createBillingRecord 在事务内创建计费记录并返回其 ID; - // 唯一冲突(同一 request_id 已结算)时返回的 error 由骨架识别为 errAlreadySettled 并回滚。 + // createBillingRecord 创建计费记录并返回其 ID(幂等闸门); + // 唯一冲突(同一 request_id 已结算)时返回的 error 由骨架识别为 errAlreadySettled。 createBillingRecord func(ctx context.Context) (int64, error) - // buildTransaction 根据事务内读到的准确余额与计费记录 ID 构造消费流水。 + // buildTransaction 根据计费记录 ID 与 Redis 返回的余额快照构造消费流水。 buildTransaction func(billingID int64, balanceAfter, frozenAfter float64) do.BilTransactions - // predeductRequestIDs 本次结算需置为 settled 的预扣追踪 request_id(task 结算含 "_adjust")。 + // predeductRequestIDs 本次结算需认领的预扣 request_id(task 结算含 "_adjust")。 predeductRequestIDs []string } -// executeSettlementTx 结算事务公共骨架:claim 预扣追踪 → 钱包扣款 → 事务内读准确余额 -// → 创建计费记录 → 记录消费流水,五步在同一事务内原子完成。 +// executeSettlement 结算公共骨架:幂等闸门 → Redis 认领扣款 → 记流水,三步顺序执行。 // Settle / SettleWithUsage / SettleTaskSuccess 三处共用,差异部分(计费记录构造、 -// 流水构造、预扣追踪 request_id 集合)由 params 注入。 +// 流水构造、预扣认领 request_id 集合)由 params 注入。 // -// claim 前置的两点意图: -// 1. 冻结释放额取「实际认领到的 track 金额之和」而非调用方传参——track 已被孤儿清理 -// 置 expired(长请求超龄)或已被解冻置 released 时认领不到,对应冻结已释放过, -// 此时只扣 balance 不再动 frozen,杜绝 GREATEST 吞掉其他在途请求冻结额的双重释放; -// 2. 锁序统一为 track→wallet(与 UnfreezePreDeduct、孤儿清理一致),消除死锁隐患。 +// Redis 权威化架构下结算不再触碰 bil_wallets 行(消除钱包热点行锁): +// 1. 幂等闸门先行:bil_records.request_id 唯一约束在任何资金变动之前拒绝重复结算; +// 2. SettleClaim 是资金提交点:Lua 原子完成「认领预扣 hash → 释放冻结 → 扣减余额」, +// 认领金额取实际删掉的预扣 hash 之和——预扣已被解冻/过期/丢失时认领不到(0), +// 对应冻结早已释放,只扣 balance 不再动 frozen,杜绝重复释放; +// 3. 流水 balance_after/frozen_after 取 Lua 返回值(Redis 权威快照),账本链连续。 // -// 幂等:计费记录命中 request_id 唯一约束时整个事务回滚(钱包扣款与 claim 一并撤销), -// 返回 errAlreadySettled,由调用方按幂等空操作处理。 -func executeSettlementTx(ctx context.Context, p settlementTxParams) (int64, error) { - var billingID int64 - err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - now := time.Now() - actualCostD := NewFromFloat(p.actualCost) +// 崩溃窗口补偿:第 2/3 步失败时按「无扣款即无账单」回滚——删除已创建的计费记录; +// 第 3 步失败另需逆转 Redis 扣款(余额退回、冻结不恢复:请求已结束,语义为本单免费)。 +// 补偿自身失败时由日对账「bil_records 无 consume 流水」探测项兜底发现。 +func executeSettlement(ctx context.Context, p settlementTxParams) (int64, error) { + actualCostD := NewFromFloat(p.actualCost) - // 语句顺序针对钱包热点行优化:bil_wallets 是同租户所有结算争抢的单行热点, - // 行锁从钱包 UPDATE 起持有直到 COMMIT,锁内往返次数直接决定单租户结算吞吐。 - // 故:幂等闸门(计费记录唯一约束)与预扣 claim 前置到钱包锁之外, - // 钱包 UPDATE 用 RETURNING 合并余额快照读,锁内只剩「UPDATE → 流水 INSERT → COMMIT」。 - - // a. 创建计费记录(幂等闸门:同 request_id 的重复结算在触碰钱包行之前即被拒绝回滚) - var err error - billingID, err = p.createBillingRecord(ctx) - if err != nil { - if isDuplicateKeyErr(err) { - // 同一 request_id 已结算:整个事务回滚,避免重复扣款/重复账单 - return errAlreadySettled - } - return gerror.Wrapf(err, "%s: create billing record", p.logPrefix) - } - - // b. claim 预扣追踪(frozen→settled,每请求独立行,无热点争抢) - claimedD, err := claimPredeductSettledTx(ctx, p.predeductRequestIDs) - if err != nil { - return gerror.Wrapf(err, "%s: claim prededuct settled", p.logPrefix) - } - if !claimedD.Equal(NewFromFloat(p.preDeductAmount)) { - g.Log().Warningf(ctx, "%s: claimed frozen %.6f != pre-deduct %.6f (track expired/released before settle?) requests=%v", - p.logPrefix, InexactFloat64(claimedD), p.preDeductAmount, p.predeductRequestIDs) - } - - // c. 更新钱包并原子读回余额快照(RETURNING 合并原「UPDATE + SELECT」两次锁内往返; - // decimal 直传 NUMERIC,避免 float64 精度损失) - var balanceAfter, frozenAfter float64 - row, err := g.DB().Ctx(ctx).GetOne(ctx, - "UPDATE bil_wallets SET frozen_balance = GREATEST(frozen_balance - ?, 0), balance = balance - ?, updated_at = ? WHERE id = ? RETURNING balance, frozen_balance", - claimedD, actualCostD, now, p.walletID) - if err != nil { - return gerror.Wrapf(err, "%s: update wallet", p.logPrefix) - } - // 余额快照 best-effort:解析失败保持 0/0(与原 readWalletBalanceTx 行为一致) - if row != nil { - if bd, e := decimal.NewFromString(row["balance"].String()); e == nil { - balanceAfter = InexactFloat64(bd) - } - if fd, e := decimal.NewFromString(row["frozen_balance"].String()); e == nil { - frozenAfter = InexactFloat64(fd) - } - } - - // d. 记录消费流水(事务内) - if _, err = dao.BilTransactions.Ctx(ctx).Data(p.buildTransaction(billingID, balanceAfter, frozenAfter)).Insert(); err != nil { - return gerror.Wrapf(err, "%s: record transaction", p.logPrefix) - } - - return nil - }) - if err != nil && !errors.Is(err, errAlreadySettled) { - // 事务回滚后 DB 钱包仍保留原冻结。立即按预扣追踪记录中的原始金额释放, - // 避免等待孤儿清理任务期间持续占用租户可用余额。 - releaseCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) - defer cancel() - for _, requestID := range p.predeductRequestIDs { - if releaseErr := UnfreezePreDeduct(releaseCtx, p.tenantID, requestID); releaseErr != nil { - g.Log().Errorf(ctx, "%s: release prededuct after rollback request=%s: %v", p.logPrefix, requestID, releaseErr) - } + // a. 创建计费记录(幂等闸门:同 request_id 的重复结算在触碰资金之前即被拒绝) + billingID, err := p.createBillingRecord(ctx) + if err != nil { + if isDuplicateKeyErr(err) { + return 0, errAlreadySettled } + return 0, gerror.Wrapf(err, "%s: create billing record", p.logPrefix) } - return billingID, err + + // b. Redis 认领预扣 + 扣款(资金提交点) + claimedD, balanceAfterD, frozenAfterD, err := SettleClaim(ctx, p.tenantID, actualCostD, p.predeductRequestIDs) + if err != nil { + if delErr := deleteBillingRecord(ctx, billingID); delErr != nil { + g.Log().Errorf(ctx, "%s: compensate delete billing record %d failed: %v", p.logPrefix, billingID, delErr) + } + return 0, gerror.Wrapf(err, "%s: settle claim", p.logPrefix) + } + if !claimedD.Equal(NewFromFloat(p.preDeductAmount)) { + g.Log().Warningf(ctx, "%s: claimed frozen %.6f != pre-deduct %.6f (prededuct released/expired before settle?) requests=%v", + p.logPrefix, InexactFloat64(claimedD), p.preDeductAmount, p.predeductRequestIDs) + } + + // c. 记录消费流水 + _, 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 { + 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) + } + if delErr := deleteBillingRecord(ctx, billingID); delErr != nil { + g.Log().Errorf(ctx, "%s: compensate delete billing record %d failed: %v", p.logPrefix, billingID, delErr) + } + return 0, gerror.Wrapf(err, "%s: record transaction", p.logPrefix) + } + + return billingID, nil } -// claimPredeductSettledTx 结算事务内 claim 预扣追踪:frozen→settled 条件更新并返回实际认领金额之和。 -// 只有仍处于 frozen 的 track 才会被认领;已被孤儿清理置 expired / 已被解冻置 released 的 track -// 认领不到(返回和中不含其金额),对应冻结不再重复释放。task 结算会传入 requestID 与 -// requestID+"_adjust" 两条(补扣调整产生),一并认领求和。 -func claimPredeductSettledTx(ctx context.Context, requestIDs []string) (decimal.Decimal, error) { - if len(requestIDs) == 0 { - return decimal.Zero, nil - } - placeholders := make([]string, len(requestIDs)) - args := make([]any, len(requestIDs)) - for i, rid := range requestIDs { - placeholders[i] = fmt.Sprintf("$%d", i+1) - args[i] = rid - } - rows, err := g.DB().Ctx(ctx).GetAll(ctx, - fmt.Sprintf("UPDATE bil_prededuct_tracks SET status = 'settled' WHERE request_id IN (%s) AND status = 'frozen' RETURNING amount", strings.Join(placeholders, ", ")), - args...) - if err != nil { - return decimal.Zero, err - } - sum := decimal.Zero - for _, row := range rows { - // NUMERIC(20,10) 以字符串取出,经 decimal 精确解析,不走 float64 - amt, convErr := decimal.NewFromString(row["amount"].String()) - if convErr != nil { - return decimal.Zero, gerror.Wrapf(convErr, "parse claimed prededuct amount %q", row["amount"].String()) - } - sum = AddMoney(sum, amt) - } - return sum, nil +// deleteBillingRecord 删除计费记录(结算补偿用:记录创建于数秒前、无任何引用,物理删除保持账表干净) +func deleteBillingRecord(ctx context.Context, billingID int64) error { + _, err := dao.BilRecords.Ctx(ctx).Where("id", billingID).Delete() + return err } // Settle 结算请求费用 @@ -237,8 +181,8 @@ func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, // 4. 获取定价信息(事务外只读) pricingResult, _ := GetModelPrice(ctx, tenantID, modelName) - // 5. 事务内执行结算(钱包扣款 + 计费记录 + 流水 + tracks 状态) - billingID, err := executeSettlementTx(ctx, settlementTxParams{ + // 5. 执行结算(幂等闸门 → Redis 认领扣款 → 流水) + billingID, err := executeSettlement(ctx, settlementTxParams{ tenantID: tenantID, walletID: walletID, preDeductAmount: preDeductAmount, @@ -298,12 +242,7 @@ func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, return nil, err } - // 6. 清除缓存(事务提交后) - walletCache.Delete(ctx, fmt.Sprintf("%d", tenantID)) - InvalidateWalletRedis(ctx, tenantID) - CleanupPreDeduct(ctx, tenantID, requestID) - - // 7. 异步检查余额预警 + // 6. 异步检查余额预警(GetWallet 读 Redis 权威值,实时准确) go CheckBalanceWarning(context.Background(), tenantID) return &SettlementResult{ @@ -352,8 +291,8 @@ func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID // 4. 获取定价信息(事务外只读) pricingResult, _ := GetModelPrice(ctx, tenantID, modelName) - // 5. 事务内执行结算(钱包扣款 + 计费记录 + 流水 + tracks 状态) - billingID, err := executeSettlementTx(ctx, settlementTxParams{ + // 5. 执行结算(幂等闸门 → Redis 认领扣款 → 流水) + billingID, err := executeSettlement(ctx, settlementTxParams{ tenantID: tenantID, walletID: walletID, preDeductAmount: preDeductAmount, @@ -417,12 +356,7 @@ func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID return nil, err } - // 6. 清除缓存(事务提交后) - walletCache.Delete(ctx, fmt.Sprintf("%d", tenantID)) - InvalidateWalletRedis(ctx, tenantID) - CleanupPreDeduct(ctx, tenantID, requestID) - - // 7. 异步检查余额预警 + // 6. 异步检查余额预警(GetWallet 读 Redis 权威值,实时准确) go CheckBalanceWarning(context.Background(), tenantID) settlementResult := &SettlementResult{ @@ -459,13 +393,9 @@ func SettleFailed(ctx context.Context, tenantID int64, requestID string, preDedu return nil } - // 解冻金额由预扣追踪记录决定,不信任调用方传入的估算值。 - if err := UnfreezePreDeduct(ctx, tenantID, requestID); err != nil { - return err - } - - // 无需额外操作,预扣金额原路退回 - return nil + // 解冻金额由 Redis 预扣 hash 决定(认领即删,恰好一次),不信任调用方传入的估算值 + _, err := UnfreezePreDeduct(ctx, tenantID, requestID) + return err } // SettleStreamInterrupted 流式中断结算:按已确认 usage 走完整 Usage 结算(含 cache token 计费 + 快照)。 diff --git a/internal/logic/billing/sweeper.go b/internal/logic/billing/sweeper.go new file mode 100644 index 0000000..c8b360f --- /dev/null +++ b/internal/logic/billing/sweeper.go @@ -0,0 +1,202 @@ +package billing + +import ( + "context" + "fmt" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" + + lcommon "github.com/qianfree/team-api/internal/logic/common" +) + +const ( + // defaultWalletMaterializeInterval 钱包物化默认间隔:把 Redis 权威状态刷回 DB 的周期。 + // bil_wallets 是纯物化视图(展示/报表/灾难恢复源),数秒滞后不影响任何实时判断。 + defaultWalletMaterializeInterval = 5 * time.Second + // walletMaterializeBatchSize 每 tick 最多物化的租户数(防爆量,超出部分下 tick 继续) + walletMaterializeBatchSize = 200 + // predeductSweepFenceRetries 预扣清扫 fenced 写回的有界重试次数 + predeductSweepFenceRetries = 3 +) + +// walletMaterializeInterval 物化间隔(配置项 billing_wallet_materialize_interval_ms,默认 5000ms) +func walletMaterializeInterval(ctx context.Context) time.Duration { + ms := lcommon.Config().GetInt(ctx, "billing_wallet_materialize_interval_ms") + if ms <= 0 { + return defaultWalletMaterializeInterval + } + return time.Duration(ms) * time.Millisecond +} + +// StartWalletMaterializer 启动钱包物化器(boot 时调用一次)。 +// 把 Redis 权威钱包状态周期全量覆盖到 DB bil_wallets(物化视图)。 +// 不走 cron(gcron 5 字段最小粒度 1 分钟,无法满足秒级间隔),boot goroutine + ticker 驱动。 +// +// 并发与崩溃安全: +// - peek(SRANDMEMBER)→ 物化 → SREM:崩溃时成员残留,下 tick 幂等重写; +// - 多实例并发物化同一租户是同值覆盖,无冲突; +// - 覆盖期间产生的新变动会重新 SADD 脏标记,下个 tick 收敛(最终一致)。 +func StartWalletMaterializer(ctx context.Context) { + interval := walletMaterializeInterval(ctx) + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + g.Log().Infof(ctx, "[WALLET MATERIALIZER] started, interval=%s", interval) + for { + select { + case <-ctx.Done(): + g.Log().Infof(ctx, "[WALLET MATERIALIZER] stopped") + return + case <-ticker.C: + materializeDirtyWallets(ctx) + } + } + }() +} + +// materializeDirtyWallets 单轮物化:取脏租户样本 → 读 Redis 权威值 → 覆盖 DB → 清脏标记 +func materializeDirtyWallets(ctx context.Context) { + members, err := g.Redis().Do(ctx, "SRANDMEMBER", walletDirtyTenantsKey, walletMaterializeBatchSize) + if err != nil { + g.Log().Warningf(ctx, "[WALLET MATERIALIZER] read dirty tenants failed: %v", err) + return + } + if members.IsNil() || members.IsEmpty() { + return + } + + for _, m := range members.Strings() { + tenantID := gconv.Int64(m) + if tenantID <= 0 { + g.Redis().Do(ctx, "SREM", walletDirtyTenantsKey, m) + continue + } + materializeOneWallet(ctx, tenantID) + } +} + +// materializeOneWallet 物化单个租户钱包;成功或 hash 缺失时清除脏标记 +func materializeOneWallet(ctx context.Context, tenantID int64) { + walletRedisKey := walletHashKey(tenantID) + balanceMicro, frozenMicro, 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) + return + } + if !exists { + // hash 不存在(灾难丢失后尚无资金操作触发重建):无需物化,清脏标记 + g.Redis().Do(ctx, "SREM", walletDirtyTenantsKey, tenantID) + return + } + + // decimal 直传 NUMERIC(driver.Valuer 精确字符串),balance/frozen 全量覆盖 + _, 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) + if err != nil { + // DB 故障:保留脏标记,下 tick 重试 + g.Log().Warningf(ctx, "[WALLET MATERIALIZER] update db wallet failed: tenant=%d: %v", tenantID, err) + return + } + + // 旧版钱包 hash 带 600s TTL(缓存时代残留):顺手摘除,权威 hash 不得过期重建 + g.Redis().Do(ctx, "PERSIST", walletRedisKey) + g.Redis().Do(ctx, "SREM", walletDirtyTenantsKey, tenantID) +} + +// PredeductSweep 预扣清扫(cron 每 2 分钟,替代原 DB tracks 孤儿清理)。 +// 重算式自愈:逐租户重算 frozen = Σ(幸存预扣 hash amount), fenced 写回钱包 hash—— +// 覆盖一切 frozen 漂移来源:预扣 hash TTL 过期未释放、进程崩溃残留、异常路径遗漏。 +// 相比 DB 版状态翻转清理,重算对「金额不可考」的丢失场景也能收敛到正确值。 +func PredeductSweep(ctx context.Context) { + cursor := 0 + for { + res, err := g.Redis().Do(ctx, "SCAN", cursor, "MATCH", "prededuct_active:*", "COUNT", 100) + if err != nil { + g.Log().Warningf(ctx, "[PREDEDUCT SWEEP] scan active sets failed: %v", err) + return + } + arr := res.Array() + if len(arr) != 2 { + return + } + cursor = gconv.Int(arr[0]) + for _, key := range gconv.Strings(arr[1]) { + sweepOneTenantPrededucts(ctx, key) + } + if cursor == 0 { + return + } + } +} + +// sweepOneTenantPrededucts 重算单个租户的 frozen 并 fenced 写回,顺带清理活跃集合死亡成员 +func sweepOneTenantPrededucts(ctx context.Context, activeSetKey string) { + var tenantID int64 + if _, err := fmt.Sscanf(activeSetKey, "prededuct_active:%d", &tenantID); err != nil || tenantID <= 0 { + return + } + + for attempt := 0; attempt < predeductSweepFenceRetries; attempt++ { + // 1. 读钱包版本号(hash 不存在:灾后未重建,跳过——由重建流程负责) + walletKey := walletHashKey(tenantID) + verRes, err := g.Redis().Do(ctx, "HGET", walletKey, "ver") + if err != nil { + g.Log().Warningf(ctx, "[PREDEDUCT SWEEP] read ver failed: tenant=%d: %v", tenantID, err) + return + } + if verRes.IsNil() { + return + } + ver := verRes.Int64() + + // 2. 重算 frozen = Σ(幸存预扣 hash amount),死亡成员顺手 SREM + members, err := g.Redis().Do(ctx, "SMEMBERS", activeSetKey) + if err != nil { + g.Log().Warningf(ctx, "[PREDEDUCT SWEEP] read active set failed: tenant=%d: %v", tenantID, err) + return + } + var frozenMicro int64 + var staleIDs []any + for _, rid := range members.Strings() { + amt, err := g.Redis().Do(ctx, "HGET", PreDeductRedisKeyPrefix+rid, "amount") + if err != nil || amt.IsNil() { + staleIDs = append(staleIDs, rid) + continue + } + frozenMicro += amt.Int64() + } + + // 3. fenced 写回:读取版本号期间有任何资金变动(ver 变化)则放弃,下轮重算 + fencedLua := ` +local ver = tonumber(redis.call("HGET", KEYS[1], "ver") or "0") +if ver ~= tonumber(ARGV[1]) then + return 0 +end +redis.call("HSET", KEYS[1], "frozen_balance", ARGV[2]) +redis.call("SADD", "wallet_dirty_tenants", ARGV[3]) +return 1 +` + ret, err := g.Redis().Do(ctx, "EVAL", fencedLua, 1, walletKey, ver, frozenMicro, tenantID) + if err != nil { + g.Log().Warningf(ctx, "[PREDEDUCT SWEEP] fenced write failed: tenant=%d: %v", tenantID, err) + return + } + if ret.Int64() == 0 { + continue // 有并发变动,重算后重试 + } + + if len(staleIDs) > 0 { + args := append([]any{activeSetKey}, staleIDs...) + g.Redis().Do(ctx, "SREM", args...) + } + g.Log().Debugf(ctx, "[PREDEDUCT SWEEP] tenant=%d frozen recomputed: %d micro (stale members: %d)", + tenantID, frozenMicro, len(staleIDs)) + return + } + g.Log().Warningf(ctx, "[PREDEDUCT SWEEP] tenant=%d frozen recompute aborted after %d fenced retries (hot wallet)", + tenantID, predeductSweepFenceRetries) +} diff --git a/internal/logic/billing/task_billing.go b/internal/logic/billing/task_billing.go index f080208..27fd7bc 100644 --- a/internal/logic/billing/task_billing.go +++ b/internal/logic/billing/task_billing.go @@ -174,11 +174,11 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI effectiveOutputPrice = pricing.OutputPrice } - // 3. 事务内执行结算(钱包扣款 + 计费记录 + 流水 + tracks 状态) - // 预扣追踪同时覆盖 requestID 与 requestID+"_adjust"(AdjustTaskBilling 补扣产生): - // 结算骨架按 claim 模式认领仍处于 frozen 的追踪并只释放实际认领到的金额, - // _adjust 已被解冻/孤儿清理处理过时自动少释放,不会二次释放。 - _, err = executeSettlementTx(ctx, settlementTxParams{ + // 3. 执行结算(幂等闸门 → Redis 认领扣款 → 流水) + // 预扣认领同时覆盖 requestID 与 requestID+"_adjust"(AdjustTaskBilling 补扣产生): + // 骨架按认领模式只释放实际认领到的预扣 hash 金额, + // _adjust 已被解冻/过期处理过时自动少释放,不会二次释放。 + _, err = executeSettlement(ctx, settlementTxParams{ tenantID: tenantID, walletID: walletID, preDeductAmount: InexactFloat64(preDeductAmount), @@ -263,23 +263,17 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI return nil, err } - // 4. 清除缓存(事务提交后) - walletCache.Delete(ctx, fmt.Sprintf("%d", tenantID)) - InvalidateWalletRedis(ctx, tenantID) - CleanupPreDeduct(ctx, tenantID, requestID) - CleanupPreDeduct(ctx, tenantID, requestID+"_adjust") - - // 5. 异步检查余额预警 + // 4. 异步检查余额预警 go CheckBalanceWarning(context.Background(), tenantID) - // 6. 差额已在步骤 3a 一次性结清,此处不得再做任何解冻/退款: - // 步骤 3a 已 frozen_balance -= preDeductAmount(释放全部预扣冻结)、balance -= actualCost + // 5. 差额已在步骤 3 一次性结清,此处不得再做任何解冻/退款: + // SettleClaim 已 frozen -= 全部预扣认领金额(释放冻结)、balance -= actualCost // (只扣真实成本),无论 actualCost 大于还是小于预扣,可用余额都已精确调整到位 // (available 变化量恰为 preDeductAmount - actualCost)。 - // 切勿再对 requestID+"_adjust" 调 UnfreezePreDeduct/SettleFailed——那会在步骤 3a 之外 - // 二次释放从未单独冻结过的金额,导致 frozen_balance 被过度释放(Redis 侧无下限时甚至为负)。 + // 切勿再对 requestID+"_adjust" 调 UnfreezePreDeduct/SettleFailed——那会在步骤 3 之外 + // 二次释放从未单独冻结过的金额,导致 frozen_balance 被过度释放。 - // 7. 生成计费快照 + 摘要 + // 6. 生成计费快照 + 摘要 result := &common.SettlementResult{ PreDeductAmount: InexactFloat64(preDeductAmount), ActualCost: InexactFloat64(actualCost), diff --git a/internal/logic/billing/wallet.go b/internal/logic/billing/wallet.go index 3e99752..c219282 100644 --- a/internal/logic/billing/wallet.go +++ b/internal/logic/billing/wallet.go @@ -7,7 +7,6 @@ import ( "sync" "time" - "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/util/gconv" @@ -16,34 +15,40 @@ import ( "github.com/qianfree/team-api/internal/dao" lcommon "github.com/qianfree/team-api/internal/logic/common" - do "github.com/qianfree/team-api/internal/model/do" ) const ( // PreDeductRedisKeyPrefix 预扣 Redis key 前缀。 - // v2(Phase 3):预扣 amount 改为整数微单位(micro-USD)存储,与旧版 float 值不兼容, + // v2:预扣 amount 为整数微单位(micro-USD)存储,与旧版 float 值不兼容, // 故 bump 版本号;旧 key 自然按 TTL 过期,杜绝新代码把旧 float 值误读成 micro。 PreDeductRedisKeyPrefix = "prededuct:v2:" // PreDeductMaxAge 预扣记录最大存活时间(秒),防止异常未结算的预扣占用余额 - PreDeductMaxAge = 7200 // 2 小时(长流式/realtime 会话防误杀:孤儿清理与 Redis TTL 共用此阈值) + PreDeductMaxAge = 7200 // 2 小时(长流式/realtime 会话防误杀:孤儿清扫与 Redis TTL 共用此阈值) - // preDeductDBTimeout 预扣资金临界区(冻结落库 + 追踪写入事务)的兜底超时。 - // 临界区运行在 context.WithoutCancel 的独立 ctx 上——客户端断开不得中途取消资金写入—— - // 因此必须自带超时,防止 DB 异常时长期占用连接。 - preDeductDBTimeout = 10 * time.Second + // walletDirtyTenantsKey 钱包脏租户集合:每次资金变动(预扣/结算/解冻/充值等)都会 + // SADD 租户 ID,物化器按此集合把 Redis 权威状态周期性刷回 DB(bil_wallets 物化视图)。 + walletDirtyTenantsKey = "wallet_dirty_tenants" ) // walletHashKey 钱包 Redis hash key。 -// v2(Phase 3):balance / frozen_balance 以整数 micro-USD 存储;旧版 float key 随 TTL 过期。 +// v2:balance / frozen_balance 以整数 micro-USD 存储;旧版 float key 随 TTL 过期。 +// +// 钱包 hash 是资金状态的【唯一实时权威】(Redis 权威化架构): +// - 所有资金变动(预扣冻结、结算扣款、解冻、充值/退款/调账)全部通过 Lua 原子作用于它; +// - DB bil_wallets 只是滞后物化视图(物化器每数秒从 Redis 全量覆盖),不得反向重建钱包 hash—— +// 从滞后的 DB 重建会回滚未物化的增量,造成余额"复活"双花; +// - 因此钱包 hash【不设 TTL】,仅在 Redis 重启/故障转移等灾难场景丢失后, +// 才走 rebuildWalletFromDB 灾难恢复(接受窗口期超扣风险)。 func walletHashKey(tenantID int64) string { return fmt.Sprintf("wallet:v2:%d", tenantID) } -// walletCache 钱包缓存(TTL 300s) +// walletCache 钱包静态字段缓存(TTL 300s):id/预警阈值/币种/低余额标记。 +// 注意:balance/frozen_balance 不进此缓存——权威值在 Redis,每次 GetWallet 实时读取。 var walletCache = lcommon.NewCache("wallet", 300*time.Second) -// walletSyncGroup 合并同一租户的并发 syncWalletToRedis DB 读取 -var walletSyncGroup singleflight.Group +// walletRebuildGroup 合并同一租户的并发 rebuildWalletFromDB 恢复 +var walletRebuildGroup singleflight.Group // WalletInfo 钱包信息 type WalletInfo struct { @@ -56,48 +61,173 @@ type WalletInfo struct { LowBalanceNotified bool } -// GetWallet 获取租户钱包 -func GetWallet(ctx context.Context, tenantID int64) (*WalletInfo, error) { +// walletStaticFields 钱包静态字段(低频变更,走 DB + 进程内缓存) +type walletStaticFields struct { + ID int64 `json:"id"` + WarningThreshold float64 `json:"warning_threshold"` + Currency string `json:"currency"` + LowBalanceNotified bool `json:"low_balance_notified"` +} + +// loadWalletStatic 读取钱包静态字段(进程内缓存 300s,回源 DB) +func loadWalletStatic(ctx context.Context, tenantID int64) (*walletStaticFields, error) { cacheKey := fmt.Sprintf("%d", tenantID) - var cached WalletInfo + var cached walletStaticFields if walletCache.GetJSON(ctx, cacheKey, &cached) { return &cached, nil } - type walletRow struct { - ID int64 `json:"id"` - TenantId int64 `json:"tenant_id"` - Balance float64 `json:"balance"` - FrozenBalance float64 `json:"frozen_balance"` - WarningThreshold float64 `json:"warning_threshold"` - Currency string `json:"currency"` - LowBalanceNotified bool `json:"low_balance_notified"` - } - - var w *walletRow + var row *walletStaticFields err := dao.BilWallets.Ctx(ctx). Where("tenant_id", tenantID). - Fields("id, tenant_id, balance, frozen_balance, warning_threshold, currency, low_balance_notified"). - Scan(&w) + Fields("id, warning_threshold, currency, low_balance_notified"). + Scan(&row) if err != nil { - return nil, gerror.Wrapf(err, "query wallet") + return nil, gerror.Wrapf(err, "query wallet static fields") } - if w == nil { + if row == nil { return nil, gerror.New("wallet not found") } - info := &WalletInfo{ - ID: w.ID, - TenantID: w.TenantId, - Balance: w.Balance, - FrozenBalance: w.FrozenBalance, - WarningThreshold: w.WarningThreshold, - Currency: w.Currency, - LowBalanceNotified: w.LowBalanceNotified, + walletCache.Set(ctx, cacheKey, row) + return row, nil +} + +// GetWallet 获取租户钱包:静态字段走 DB 缓存,balance/frozen_balance 读 Redis 权威值。 +// Redis 中钱包 hash 缺失时触发灾难恢复重建后再读。 +func GetWallet(ctx context.Context, tenantID int64) (*WalletInfo, error) { + static, err := loadWalletStatic(ctx, tenantID) + if err != nil { + return nil, err } - walletCache.Set(ctx, cacheKey, info) - return info, nil + balance, frozen, err := getWalletRedisState(ctx, tenantID) + if err != nil { + return nil, err + } + + return &WalletInfo{ + ID: static.ID, + TenantID: tenantID, + Balance: balance, + FrozenBalance: frozen, + 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) + if err != nil { + return 0, 0, err + } + if !exists { + if err = rebuildWalletFromDB(ctx, tenantID); err != nil { + return 0, 0, err + } + balanceMicro, frozenMicro, _, err = readWalletHash(ctx, tenantID) + if err != nil { + return 0, 0, err + } + } + return InexactFloat64(FromMicro(balanceMicro)), InexactFloat64(FromMicro(frozenMicro)), nil +} + +// readWalletHash 读取钱包 hash 的 balance/frozen_balance(micro-USD)。 +// exists=false 表示 hash 不存在(灾难恢复场景)。 +func readWalletHash(ctx context.Context, tenantID int64) (balanceMicro, frozenMicro int64, 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") + } + if res.IsNil() || res.IsEmpty() { + return 0, 0, false, nil + } + m := res.Map() + balanceMicro = gconv.Int64(m["balance"]) + frozenMicro = gconv.Int64(m["frozen_balance"]) + return balanceMicro, frozenMicro, true, nil +} + +// rebuildWalletFromDB 灾难恢复:钱包 hash 缺失时从 DB 物化值重建。 +// frozen 不取 DB 值,而是按该租户【幸存的预扣 hash】重算——Redis 全丢时在途预扣已不可考, +// frozen 归零(接受窗口期超扣风险);部分丢失(如 hash 被误删)时幸存明细仍能提供精确冻结。 +func rebuildWalletFromDB(ctx context.Context, tenantID int64) error { + _, err, _ := walletRebuildGroup.Do(strconv.FormatInt(tenantID, 10), func() (interface{}, error) { + return nil, doRebuildWalletFromDB(context.Background(), tenantID) + }) + return err +} + +func doRebuildWalletFromDB(ctx context.Context, tenantID int64) error { + walletRedisKey := walletHashKey(tenantID) + + // 已存在则无需重建(并发场景下其他请求已完成) + exists, err := g.Redis().Do(ctx, "EXISTS", walletRedisKey) + if err != nil { + return gerror.Wrapf(err, "rebuild wallet: check exists") + } + if exists.Int64() == 1 { + return nil + } + + var w *struct { + Balance decimal.Decimal `json:"balance"` + } + err = dao.BilWallets.Ctx(ctx). + Where("tenant_id", tenantID). + Fields("balance"). + Scan(&w) + if err != nil { + return gerror.Wrapf(err, "rebuild wallet: query db") + } + if w == nil { + return gerror.New("wallet not found") + } + + // 幸存预扣明细重算 frozen(不取 DB frozen_balance:其中可能含已丢失预扣的残留) + frozenMicro := sumSurvivingPrededucts(ctx, tenantID) + + // 「检查不存在 + 写入」原子完成,避免并发重建互相覆盖 + 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]) +return 1 +` + ret, err := g.Redis().Do(ctx, "EVAL", rebuildLua, 1, walletRedisKey, + ToMicro(w.Balance), frozenMicro, tenantID) + if err != nil { + return gerror.Wrapf(err, "rebuild wallet: redis") + } + if ret.Int64() == 1 { + g.Log().Warningf(ctx, + "[WALLET REBUILD] wallet hash rebuilt from DB (disaster recovery): tenant=%d balance=%s frozen_micro=%d — in-flight pre-deducts may be lost", + tenantID, w.Balance.String(), frozenMicro) + } + return nil +} + +// sumSurvivingPrededucts 求该租户幸存预扣 hash 的金额总和(micro-USD),用于灾后 frozen 重算 +func sumSurvivingPrededucts(ctx context.Context, tenantID int64) int64 { + activeSetKey := fmt.Sprintf("prededuct_active:%d", tenantID) + members, err := g.Redis().Do(ctx, "SMEMBERS", activeSetKey) + if err != nil || members.IsNil() { + return 0 + } + var sum int64 + for _, rid := range members.Strings() { + amt, err := g.Redis().Do(ctx, "HGET", PreDeductRedisKeyPrefix+rid, "amount") + if err == nil && !amt.IsNil() { + sum += amt.Int64() + } + } + return sum } // walletIDCache 租户ID→钱包ID 进程内映射缓存。 @@ -105,19 +235,17 @@ func GetWallet(ctx context.Context, tenantID int64) (*WalletInfo, error) { var walletIDCache sync.Map // tenantID(int64) → walletID(int64) // GetWalletID 获取租户钱包 ID(进程内缓存,键值不变、永不失效)。 -// 结算热路径只需要 walletID:不应因 walletCache 被每次结算失效而反复回源查 bil_wallets—— -// 高并发下结算事务在钱包热点行上串行化、连接池吃紧时,这次"无谓的读"会排队到超时, -// 把本已成功的请求拖成结算失败(实测报错 settle_with_usage: get wallet: context deadline exceeded)。 +// 走静态字段缓存而非 GetWallet:结算热路径不应为取 ID 额外访问 Redis。 func GetWalletID(ctx context.Context, tenantID int64) (int64, error) { if v, ok := walletIDCache.Load(tenantID); ok { return v.(int64), nil } - w, err := GetWallet(ctx, tenantID) + static, err := loadWalletStatic(ctx, tenantID) if err != nil { return 0, err } - walletIDCache.Store(tenantID, w.ID) - return w.ID, nil + walletIDCache.Store(tenantID, static.ID) + return static.ID, nil } // EnsureWallet 确保租户有钱包,没有则创建 @@ -139,30 +267,17 @@ func AvailableBalance(wallet *WalletInfo) float64 { return wallet.Balance - wallet.FrozenBalance } -// PreDeduct 预扣费用(Redis Lua 原子操作) -// 冻结指定金额,返回预扣记录 ID 用于后续结算 -func PreDeduct(ctx context.Context, tenantID int64, amount float64, requestID string, modelName string) (bool, error) { - if amount <= 0 { - return true, nil - } - - // 先将钱包数据同步到 Redis(确保 balance 字段存在) - if err := syncWalletToRedis(ctx, tenantID); err != nil { - // Redis 不可用,降级到 DB 直接扣减 - return preDeductDB(ctx, tenantID, amount, requestID) - } - - // Redis Lua 脚本:原子检查+冻结(v2:金额全部为整数 micro-USD,整数运算无浮点漂移) - // KEYS[1] = wallet:v2:{tenant_id} (hash: balance, frozen_balance —— 均为整数 micro) - // KEYS[2] = prededuct:v2:{request_id} - // ARGV[1] = amount_micro(整数微单位) - // ARGV[2] = request_id - // ARGV[3] = ttl (PreDeductMaxAge) - // ARGV[4] = tenant_id - // ARGV[5] = model_name - // ARGV[6] = created_at (unix timestamp) - // 返回值:1 冻结成功 / 0 可用余额不足 / 2 已预扣(幂等) / 3 钱包 hash 缺失需重建 - luaScript := ` +// 预扣 Lua:原子检查+冻结(金额全部为整数 micro-USD,整数运算无浮点漂移) +// KEYS[1] = wallet:v2:{tenant_id} (hash: balance, frozen_balance, ver —— 金额均为整数 micro) +// KEYS[2] = prededuct:v2:{request_id} +// ARGV[1] = amount_micro(整数微单位) +// ARGV[2] = request_id +// ARGV[3] = ttl (PreDeductMaxAge) +// ARGV[4] = tenant_id +// ARGV[5] = model_name +// ARGV[6] = created_at (unix timestamp) +// 返回值:1 冻结成功 / 0 可用余额不足 / 2 已预扣(幂等) / 3 钱包 hash 缺失需重建 +const preDeductLua = ` local wallet_key = KEYS[1] local prededuct_key = KEYS[2] local amount = tonumber(ARGV[1]) @@ -175,8 +290,7 @@ if exists == 1 then return 2 end --- 钱包 hash 缺失/缺 balance 字段(sync 与本脚本之间被结算/解冻 DEL): --- 不得把缺失字段当 0 参与计算,返回 3 由调用方重新同步后重试 +-- 钱包 hash 缺失:不得把缺失字段当 0 参与计算,返回 3 由调用方重建后重试 if redis.call("HEXISTS", wallet_key, "balance") == 0 then return 3 end @@ -191,8 +305,10 @@ if available < amount then return 0 end --- 冻结金额(整数自增,无浮点漂移) +-- 冻结金额(整数自增,无浮点漂移),递增版本号并标记脏租户(驱动物化) redis.call("HINCRBY", wallet_key, "frozen_balance", amount) +redis.call("HINCRBY", wallet_key, "ver", 1) +redis.call("SADD", "wallet_dirty_tenants", ARGV[4]) redis.call("HSET", prededuct_key, "amount", amount) redis.call("HSET", prededuct_key, "tenant_id", ARGV[4]) redis.call("HSET", prededuct_key, "model_name", ARGV[5]) @@ -204,183 +320,233 @@ redis.call("EXPIRE", prededuct_key, ttl) return 1 ` - walletRedisKey := walletHashKey(tenantID) - predeductRedisKey := fmt.Sprintf("%s%s", PreDeductRedisKeyPrefix, requestID) - - amountMicro := ToMicro(NewFromFloat(amount)) - result, err := g.Redis().Do(ctx, "EVAL", luaScript, 2, - walletRedisKey, predeductRedisKey, - amountMicro, requestID, PreDeductMaxAge, tenantID, modelName, time.Now().Unix()) - if err != nil { - // Redis 不可用,降级到 DB 直接扣减 - return preDeductDB(ctx, tenantID, amount, requestID) - } - - code := result.Int64() - - if code == 3 { - // 钱包 hash 在 sync 与 EVAL 之间被结算/解冻 DEL:重建后重试一次 - if err := syncWalletToRedis(ctx, tenantID); err != nil { - return preDeductDB(ctx, tenantID, amount, requestID) - } - result, err = g.Redis().Do(ctx, "EVAL", luaScript, 2, - walletRedisKey, predeductRedisKey, - amountMicro, requestID, PreDeductMaxAge, tenantID, modelName, time.Now().Unix()) - if err != nil { - return preDeductDB(ctx, tenantID, amount, requestID) - } - code = result.Int64() - if code == 3 { - // 极端竞态下仍缺失:降级到 DB 单语句原子预扣 - return preDeductDB(ctx, tenantID, amount, requestID) - } - } - - if code == 0 { - return false, gerror.New("insufficient balance") - } - if code == 2 { +// PreDeduct 预扣费用(Redis Lua 原子操作,资金提交点在 Redis,无 DB 写)。 +// Redis 不可用时 fail-closed 返回错误(用户确认:故障期间拒绝请求,不放行无额度保护的流量)。 +func PreDeduct(ctx context.Context, tenantID int64, amount float64, requestID string, modelName string) (bool, error) { + if amount <= 0 { return true, nil } - // 同步到 DB(确保 DB frozen_balance 在返回前已更新)。任一步失败都不能把 - // 仅存在于 Redis 的冻结状态暴露给后续请求。 - // 冻结 UPDATE 与追踪 INSERT 必须在同一事务内原子完成,并脱离请求 ctx 的取消 - //(WithoutCancel + 独立超时):客户端在两步之间断开会把资金状态撕裂——冻结已加、 - // 追踪未写,且回滚补偿若复用已取消的 ctx 也必然失败。追踪缺失的冻结不在 - // bil_prededuct_tracks 中,孤儿清理与对账永远发现不了,frozen_balance 将永久泄漏。 - dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), preDeductDBTimeout) - defer cancel() - err = g.DB().Transaction(dbCtx, func(txCtx context.Context, tx gdb.TX) error { - if err := preDeductSyncDB(txCtx, tenantID, amount); err != nil { - return err - } - return trackPreDeduct(txCtx, tenantID, amount, requestID, modelName) - }) + amountMicro := ToMicro(NewFromFloat(amount)) + code, err := evalPreDeduct(ctx, tenantID, requestID, modelName, amountMicro) if err != nil { - // 事务已整体回滚(DB 无残留),仅需撤销 Redis 侧冻结 - rollbackRedisPreDeduct(ctx, tenantID, requestID) - return false, err + // fail-closed:预扣无法完成时拒绝请求,不做无冻结放行 + return false, gerror.Wrapf(err, "pre-deduct: redis unavailable") } + if code == 3 { + // 钱包 hash 缺失:灾难恢复重建后重试一次 + if err = rebuildWalletFromDB(ctx, tenantID); err != nil { + return false, gerror.Wrapf(err, "pre-deduct: rebuild wallet") + } + code, err = evalPreDeduct(ctx, tenantID, requestID, modelName, amountMicro) + if err != nil { + return false, gerror.Wrapf(err, "pre-deduct: redis unavailable after rebuild") + } + if code == 3 { + return false, gerror.Newf("pre-deduct: wallet rebuild ineffective for tenant %d", tenantID) + } + } + + switch code { + case 0: + return false, gerror.New("insufficient balance") + case 2: + // 幂等:同一 request_id 已预扣过 + return true, nil + } return true, nil } -// preDeductDB DB 降级预扣(Redis 不可用时) -func preDeductDB(ctx context.Context, tenantID int64, amount float64, requestID string) (bool, error) { - // 请求 ctx 已取消(客户端断开/超时)时直接终止:Redis 操作报 context canceled 会被 - // 上层误判为 "Redis 不可用" 而降级到此处,此时预扣既无意义(请求即将失败), - // 又会在资金临界区中途被取消造成状态撕裂,还平白放大 DB 热点行压力。 - if err := ctx.Err(); err != nil { - return false, gerror.Wrapf(err, "pre-deduct aborted: request context canceled") - } - - // A9 修复:用单条原子条件更新替代「先 SELECT ... FOR UPDATE,再独立 UPDATE」。 - // 原实现两条语句在 autocommit 下各自成一个事务,FOR UPDATE 的行锁在 SELECT 语句提交后即释放, - // 两个并发降级预扣可都通过 available 检查、再各自 frozen += amount → 超额冻结(可用余额被冻成负)。 - // 单条 "WHERE tenant_id=? AND balance - frozen_balance >= amount" 的 UPDATE 在语句执行期间持有 - // 行锁并原子重算谓词:RowsAffected==1 表示冻结成功,==0 表示可用余额不足(或钱包不存在)。 - // - // 冻结 UPDATE 与追踪 INSERT 在同一事务内原子完成(任一失败整体回滚,杜绝「冻结已加、 - // 追踪缺失」的泄漏窗口),并脱离请求 ctx 的取消(WithoutCancel + 独立超时): - // 追踪缺失的冻结对孤儿清理与对账不可见,frozen_balance 会永久泄漏。 - dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), preDeductDBTimeout) - defer cancel() - err := g.DB().Transaction(dbCtx, func(txCtx context.Context, tx gdb.TX) error { - res, err := g.DB().Ctx(txCtx).Exec(txCtx, - "UPDATE bil_wallets SET frozen_balance = frozen_balance + ?, updated_at = ? WHERE tenant_id = ? AND balance - frozen_balance >= ?", - amount, time.Now(), tenantID, amount) - if err != nil { - return gerror.Wrapf(err, "pre-deduct db update") - } - affected, err := res.RowsAffected() - if err != nil { - return gerror.Wrapf(err, "pre-deduct db update result") - } - if affected == 0 { - return gerror.New("insufficient balance") - } - // 追踪记录是后续精确解冻的金额来源,与冻结同事务落库 - return trackPreDeduct(txCtx, tenantID, amount, requestID, "") - }) +// evalPreDeduct 执行预扣 Lua,返回脚本结果码 +func evalPreDeduct(ctx context.Context, tenantID int64, requestID, modelName string, amountMicro int64) (int64, error) { + result, err := g.Redis().Do(ctx, "EVAL", preDeductLua, 2, + walletHashKey(tenantID), PreDeductRedisKeyPrefix+requestID, + amountMicro, requestID, PreDeductMaxAge, tenantID, modelName, time.Now().Unix()) if err != nil { - return false, err + return -1, err } - - // 清除缓存(事务提交后) - walletCache.Delete(ctx, fmt.Sprintf("%d", tenantID)) - return true, nil + return result.Int64(), nil } -// preDeductSyncDB sync pre-deduct to DB(同步调用,确保 DB frozen_balance 在返回前已更新)。 -// 通过 g.DB().Ctx(ctx) 传播 ctx 携带的事务:在 PreDeduct 的资金临界区事务内调用时与追踪写入同事务。 -func preDeductSyncDB(ctx context.Context, tenantID int64, amount float64) error { - result, err := g.DB().Ctx(ctx).Exec(ctx, - "UPDATE bil_wallets SET frozen_balance = frozen_balance + ?, updated_at = ? WHERE tenant_id = ?", - amount, time.Now(), tenantID) +// 解冻 Lua:认领预扣 hash 并释放冻结(认领即删,天然幂等——重复调用认领不到返回 0)。 +// KEYS[1] = wallet:v2:{tenant_id} +// KEYS[2] = prededuct:v2:{request_id} +// ARGV[1] = request_id +// ARGV[2] = tenant_id +// 返回值:实际释放的金额(micro),0 表示预扣不存在(已释放/已结算/已过期) +const unfreezeLua = ` +local wallet_key = KEYS[1] +local prededuct_key = KEYS[2] +local request_id = ARGV[1] +local tenant_id = ARGV[2] + +local amount = redis.call("HGET", prededuct_key, "amount") +if not amount then + return 0 +end +amount = tonumber(amount) + +redis.call("DEL", prededuct_key) +redis.call("SREM", "prededuct_active:" .. tenant_id, request_id) + +local frozen = tonumber(redis.call("HINCRBY", wallet_key, "frozen_balance", -amount)) +if frozen < 0 then + redis.call("HSET", wallet_key, "frozen_balance", 0) +end +redis.call("HINCRBY", wallet_key, "ver", 1) +redis.call("SADD", "wallet_dirty_tenants", tenant_id) +return amount +` + +// UnfreezePreDeduct 解冻预扣金额(请求失败/管理端释放时调用)。 +// 金额由 Redis 预扣 hash 决定,不信任调用方传入的估算值;认领即删保证恰好一次。 +// 返回实际解冻金额(0 表示预扣已不存在,幂等空操作)。 +func UnfreezePreDeduct(ctx context.Context, tenantID int64, requestID string) (decimal.Decimal, error) { + result, err := g.Redis().Do(ctx, "EVAL", unfreezeLua, 2, + walletHashKey(tenantID), PreDeductRedisKeyPrefix+requestID, + requestID, tenantID) if err != nil { - return gerror.Wrapf(err, "pre-deduct sync db") + return Zero, gerror.Wrapf(err, "unfreeze: redis") } - affected, err := result.RowsAffected() - if err != nil { - return gerror.Wrapf(err, "pre-deduct sync db result") - } - if affected == 0 { - return gerror.Newf("pre-deduct sync db: wallet not found for tenant %d", tenantID) - } - return nil + return FromMicro(result.Int64()), nil } -// UnfreezePreDeduct 解冻预扣金额(请求失败时调用) -func UnfreezePreDeduct(ctx context.Context, tenantID int64, requestID string) error { - // 调用方不再提供金额。事务内锁定 frozen 追踪行并读取预扣时保存的金额, - // 既避免错误金额解冻,也让重复调用只有一个事务能实际释放冻结。 - err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - var track *struct { - Amount decimal.Decimal `json:"amount"` - } - if err := dao.BilPredeductTracks.Ctx(ctx). - Where("tenant_id", tenantID). - Where("request_id", requestID). - Where("status", "frozen"). - Fields("amount"). - LockUpdate(). - Scan(&track); err != nil { - return gerror.Wrapf(err, "load prededuct for unfreeze") - } - if track == nil || track.Amount.LessThanOrEqual(decimal.Zero) { - return nil - } +// 结算认领 Lua:认领预扣(可多 key,task 结算含 "_adjust")→ 释放冻结 → 扣减余额。 +// KEYS[1] = wallet:v2:{tenant_id} +// KEYS[2..n] = prededuct:v2:{request_id}(与 ARGV[3..] 的 request_id 一一对应) +// ARGV[1] = cost_micro(实际扣款金额) +// ARGV[2] = tenant_id +// ARGV[3..] = request_id +// 返回值:{claimed_micro, balance_after_micro, frozen_after_micro} +// 预扣 hash 不存在(已释放/过期/丢失)时按 claimed=0 处理:只扣 balance 不动 frozen, +// 与既有「track 丢失只扣 balance」语义一致(调用方据此打告警)。 +const settleClaimLua = ` +local wallet_key = KEYS[1] +local cost = tonumber(ARGV[1]) +local tenant_id = ARGV[2] +local active_set = "prededuct_active:" .. tenant_id - if _, err := g.DB().Ctx(ctx).Exec(ctx, - "UPDATE bil_wallets SET frozen_balance = GREATEST(frozen_balance - ?, 0), updated_at = ? WHERE tenant_id = ?", - track.Amount, time.Now(), tenantID); err != nil { - return gerror.Wrapf(err, "unfreeze wallet") - } - if _, err := dao.BilPredeductTracks.Ctx(ctx). - Where("tenant_id", tenantID). - Where("request_id", requestID). - Where("status", "frozen"). - Data(do.BilPredeductTracks{Status: "released"}). - Update(); err != nil { - return gerror.Wrapf(err, "mark prededuct released") - } - return nil - }) - if err != nil { - InvalidateWalletRedis(ctx, tenantID) - return err +local claimed = 0 +for i = 2, #KEYS do + local rid = ARGV[i + 1] + local amt = redis.call("HGET", KEYS[i], "amount") + if amt then + claimed = claimed + tonumber(amt) + redis.call("DEL", KEYS[i]) + redis.call("SREM", active_set, rid) + end +end + +local frozen = tonumber(redis.call("HINCRBY", wallet_key, "frozen_balance", -claimed)) +if frozen < 0 then + redis.call("HSET", wallet_key, "frozen_balance", 0) + frozen = 0 +end +local balance = tonumber(redis.call("HINCRBY", wallet_key, "balance", -cost)) +redis.call("HINCRBY", wallet_key, "ver", 1) +redis.call("SADD", "wallet_dirty_tenants", tenant_id) +return {claimed, balance, frozen} +` + +// SettleClaim 结算认领(Redis 提交点):原子完成「认领预扣 → 释放冻结 → 扣减余额」。 +// 返回实际认领金额与扣款后的余额/冻结快照(micro 精度,供流水 balance_after/frozen_after)。 +func SettleClaim(ctx context.Context, tenantID int64, cost decimal.Decimal, requestIDs []string) (claimed, balanceAfter, frozenAfter decimal.Decimal, err error) { + evalArgs := make([]any, 0, 4+2*len(requestIDs)) + evalArgs = append(evalArgs, settleClaimLua, 1+len(requestIDs), walletHashKey(tenantID)) + for _, rid := range requestIDs { + evalArgs = append(evalArgs, PreDeductRedisKeyPrefix+rid) + } + evalArgs = append(evalArgs, ToMicro(cost), tenantID) + for _, rid := range requestIDs { + evalArgs = append(evalArgs, rid) } + result, err := g.Redis().Do(ctx, "EVAL", evalArgs...) + if err != nil { + return Zero, Zero, Zero, gerror.Wrapf(err, "settle claim: redis") + } + vals := result.Int64s() + if len(vals) != 3 { + return Zero, Zero, Zero, gerror.Newf("settle claim: unexpected redis reply %v", vals) + } + return FromMicro(vals[0]), FromMicro(vals[1]), FromMicro(vals[2]), nil +} + +// 充值/调账加款 Lua:原子增加余额并返回 {加后余额, 当前冻结}(micro)。 +// KEYS[1] = wallet:v2:{tenant_id} +// ARGV[1] = amount_micro(正数;补偿逆转场景传负数等价于无门槛扣款) +// ARGV[2] = tenant_id +const creditWalletLua = ` +local balance = redis.call("HINCRBY", KEYS[1], "balance", ARGV[1]) +local frozen = tonumber(redis.call("HGET", KEYS[1], "frozen_balance") or "0") +redis.call("HINCRBY", KEYS[1], "ver", 1) +redis.call("SADD", "wallet_dirty_tenants", ARGV[2]) +return {balance, frozen} +` + +// 扣款 Lua(带可用余额门槛):balance - frozen >= amount 才扣,否则返回 {-1, 0}。 +// KEYS[1] = wallet:v2:{tenant_id} +// ARGV[1] = amount_micro(正数) +// ARGV[2] = tenant_id +const debitWalletLua = ` +local amount = tonumber(ARGV[1]) +local balance = tonumber(redis.call("HGET", KEYS[1], "balance") or "0") +local frozen = tonumber(redis.call("HGET", KEYS[1], "frozen_balance") or "0") +if balance - frozen < amount then + return {-1, 0} +end +balance = redis.call("HINCRBY", KEYS[1], "balance", -amount) +redis.call("HINCRBY", KEYS[1], "ver", 1) +redis.call("SADD", "wallet_dirty_tenants", ARGV[2]) +return {balance, frozen} +` + +// CreditWalletRedis 钱包加款(Redis 提交点):充值/兑换/人工充值/结算补偿逆转等场景。 +// 返回加款后余额与当前冻结快照(供流水 balance_after/frozen_after)。 +// hash 缺失时 HINCRBY 会新建仅含 balance 的 hash——仅应发生在「新租户首充」等合法场景; +// 其他场景说明发生了灾难丢失,由后续 rebuild/物化流程收敛。 +func CreditWalletRedis(ctx context.Context, tenantID int64, amount decimal.Decimal) (balanceAfter, frozenAfter decimal.Decimal, err error) { + result, err := g.Redis().Do(ctx, "EVAL", creditWalletLua, 1, + walletHashKey(tenantID), ToMicro(amount), tenantID) + if err != nil { + return Zero, Zero, gerror.Wrapf(err, "credit wallet: redis") + } + vals := result.Int64s() + if len(vals) != 2 { + return Zero, Zero, gerror.Newf("credit wallet: unexpected redis reply %v", vals) + } + return FromMicro(vals[0]), FromMicro(vals[1]), nil +} + +// DebitWalletRedis 钱包扣款(Redis 提交点,带可用余额门槛):退款/管理端扣减等场景。 +// ok=false 表示可用余额不足(balance - frozen < amount),未发生任何变更。 +func DebitWalletRedis(ctx context.Context, tenantID int64, amount decimal.Decimal) (balanceAfter, frozenAfter decimal.Decimal, ok bool, err error) { + result, err := g.Redis().Do(ctx, "EVAL", debitWalletLua, 1, + walletHashKey(tenantID), ToMicro(amount), tenantID) + if err != nil { + return Zero, Zero, false, gerror.Wrapf(err, "debit wallet: redis") + } + vals := result.Int64s() + if len(vals) != 2 { + return Zero, Zero, false, gerror.Newf("debit wallet: unexpected redis reply %v", vals) + } + if vals[0] == -1 { + return Zero, Zero, false, nil + } + return FromMicro(vals[0]), FromMicro(vals[1]), true, nil +} + +// InvalidateWalletStaticCache 清除钱包静态字段缓存(预警阈值、低余额标记等低频字段变更后调用)。 +// 注意:只清进程内静态缓存,【绝不】清 Redis 钱包 hash——hash 是权威余额(非可失效缓存), +// 删除会导致下次从滞后的 DB 重建而回滚未物化增量。 +func InvalidateWalletStaticCache(ctx context.Context, tenantID int64) { walletCache.Delete(ctx, fmt.Sprintf("%d", tenantID)) - InvalidateWalletRedis(ctx, tenantID) - CleanupPreDeduct(ctx, tenantID, requestID) - return nil } // GetPreDeductAmount 获取预扣金额 func GetPreDeductAmount(ctx context.Context, requestID string) (float64, bool) { - predeductRedisKey := fmt.Sprintf("%s%s", PreDeductRedisKeyPrefix, requestID) + predeductRedisKey := PreDeductRedisKeyPrefix + requestID result, err := g.Redis().Do(ctx, "HGET", predeductRedisKey, "amount") if err == nil && !result.IsNil() { // v2:amount 以整数 micro 存储,换算回 USD @@ -389,93 +555,9 @@ func GetPreDeductAmount(ctx context.Context, requestID string) (float64, bool) { return 0, false } -// syncWalletToRedis 将钱包余额从 DB 同步到 Redis Hash -// 使用 singleflight 合并同一租户的并发请求,避免 N 个并发预扣打出 N 次相同的 DB 读 -func syncWalletToRedis(ctx context.Context, tenantID int64) error { - _, err, _ := walletSyncGroup.Do(strconv.FormatInt(tenantID, 10), func() (interface{}, error) { - return nil, doSyncWalletToRedis(context.Background(), tenantID) - }) - return err -} - -// doSyncWalletToRedis 将钱包余额从 DB 同步到 Redis Hash -// 每次预扣前调用,确保 Redis 中的 balance 与 DB 一致 -// frozen_balance 由预扣 Lua 脚本管理,仅在 key 首次创建时从 DB 初始化 -func doSyncWalletToRedis(ctx context.Context, tenantID int64) error { - walletRedisKey := walletHashKey(tenantID) - - // 从 DB 读取钱包数据(跳过内存缓存,直接查库确保最新) - type walletRow struct { - Balance decimal.Decimal `json:"balance"` - FrozenBalance decimal.Decimal `json:"frozen_balance"` - } - var w *walletRow - err := dao.BilWallets.Ctx(ctx). - Where("tenant_id", tenantID). - Fields("balance, frozen_balance"). - Scan(&w) - if err != nil { - return gerror.Wrapf(err, "sync wallet to redis") - } - if w == nil { - return gerror.New("wallet not found") - } - - // 「检查是否存在 + 写入」必须原子完成:拆成 EXISTS 后再 HSET 两步时,结算/解冻的 - // DEL 可插入两步之间,重建出只有 balance、缺 frozen_balance 字段的 hash(预扣 Lua - // 会把缺失的 frozen 当 0,导致按零冻结计算可用余额而超额放行)。 - // 返回 1 = 本次新建(balance + frozen_balance 完整初始化),0 = 已存在仅刷新 balance。 - syncLua := ` -local key = KEYS[1] -if redis.call("EXISTS", key) == 1 then - redis.call("HSET", key, "balance", ARGV[1]) - redis.call("EXPIRE", key, tonumber(ARGV[3])) - return 0 -end -redis.call("HSET", key, "balance", ARGV[1], "frozen_balance", ARGV[2]) -redis.call("EXPIRE", key, tonumber(ARGV[3])) -return 1 -` - // 过期时间 600s,过期后下次预扣会重新初始化 - ret, err := g.Redis().Do(ctx, "EVAL", syncLua, 1, walletRedisKey, - ToMicro(w.Balance), ToMicro(w.FrozenBalance), 600) - if err != nil { - return gerror.Wrapf(err, "sync wallet to redis") - } - if ret.Int64() == 1 { - // 本次新建:从 DB 恢复活跃预扣明细到 Redis - rebuildPredeductFromDB(ctx, tenantID) - } - - return nil -} - -// InvalidateWalletRedis 清除 Redis 中的钱包缓存(余额变更后调用) -func InvalidateWalletRedis(ctx context.Context, tenantID int64) { - g.Redis().Do(ctx, "DEL", walletHashKey(tenantID)) -} - -func rollbackRedisPreDeduct(ctx context.Context, tenantID int64, requestID string) { - // DB 同步失败后 DB 仍是权威状态。删除钱包 hash 强制下次从 DB 重建, - // 同时清理本次尚未持久化成功的 Redis 预扣明细。 - rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) - defer cancel() - InvalidateWalletRedis(rollbackCtx, tenantID) - CleanupPreDeduct(rollbackCtx, tenantID, requestID) -} - -// InvalidateWallet 清除租户钱包的两级缓存(进程内 walletCache + Redis hash)。 -// 供 billing 包外(如充值履约 payment.creditWalletTx)在钱包余额变更后调用: -// walletCache 是 billing 包私有变量,跨包无法直接 Delete,仅清 Redis 会导致 -// GetWallet 在 300s TTL 内继续命中进程内旧余额。余额变更后应统一调用本函数。 -func InvalidateWallet(ctx context.Context, tenantID int64) { - walletCache.Delete(ctx, fmt.Sprintf("%d", tenantID)) - InvalidateWalletRedis(ctx, tenantID) -} - -// CleanupPreDeduct 清理预扣记录(结算成功后调用) +// CleanupPreDeduct 清理预扣记录(结算/终态后调用,幂等) func CleanupPreDeduct(ctx context.Context, tenantID int64, requestID string) { - predeductRedisKey := fmt.Sprintf("%s%s", PreDeductRedisKeyPrefix, requestID) + predeductRedisKey := PreDeductRedisKeyPrefix + requestID activeSetKey := fmt.Sprintf("prededuct_active:%d", tenantID) g.Redis().Do(ctx, "DEL", predeductRedisKey) g.Redis().Do(ctx, "SREM", activeSetKey, requestID) @@ -504,7 +586,7 @@ func GetFrozenItems(ctx context.Context, tenantID int64) ([]FrozenItem, error) { var staleIDs []string for _, reqID := range requestIDs { - predeductKey := fmt.Sprintf("%s%s", PreDeductRedisKeyPrefix, reqID) + predeductKey := PreDeductRedisKeyPrefix + reqID exists, _ := g.Redis().Do(ctx, "EXISTS", predeductKey) if exists.Int64() == 0 { @@ -563,59 +645,3 @@ func GetFrozenItems(ctx context.Context, tenantID int64) ([]FrozenItem, error) { return items, nil } - -// trackPreDeduct 写入预扣追踪记录到 DB(用于精确解冻、孤儿清理和 Redis 重建)。 -func trackPreDeduct(ctx context.Context, tenantID int64, amount float64, requestID string, modelName string) error { - _, err := g.DB().Ctx(ctx).Exec(ctx, - `INSERT INTO bil_prededuct_tracks (tenant_id, request_id, amount, model_name, status) - VALUES ($1, $2, $3, $4, 'frozen') - ON CONFLICT (request_id) DO NOTHING`, - tenantID, requestID, amount, modelName) - if err != nil { - return gerror.Wrapf(err, "track prededuct: request=%s", requestID) - } - return nil -} - -// rebuildPredeductFromDB 从 DB 恢复活跃预扣明细到 Redis(Redis 重启后调用) -func rebuildPredeductFromDB(ctx context.Context, tenantID int64) { - type trackRow struct { - RequestID string `json:"request_id"` - Amount decimal.Decimal `json:"amount"` - ModelName string `json:"model_name"` - CreatedAt int64 `json:"created_at"` - } - - cutoff := time.Now().Add(-time.Duration(PreDeductMaxAge) * time.Second) - var tracks []trackRow - err := dao.BilPredeductTracks.Ctx(ctx). - Where("tenant_id", tenantID). - Where("status", "frozen"). - Where("created_at > ?", cutoff). - Fields("request_id, amount, model_name, EXTRACT(EPOCH FROM created_at)::bigint as created_at"). - Scan(&tracks) - if err != nil || len(tracks) == 0 { - return - } - - activeSetKey := fmt.Sprintf("prededuct_active:%d", tenantID) - for _, t := range tracks { - age := time.Now().Unix() - t.CreatedAt - remainingTTL := int64(PreDeductMaxAge) - age - if remainingTTL <= 0 { - continue - } - - predeductKey := fmt.Sprintf("%s%s", PreDeductRedisKeyPrefix, t.RequestID) - g.Redis().Do(ctx, "HMSET", predeductKey, - "amount", ToMicro(t.Amount), - "tenant_id", tenantID, - "model_name", t.ModelName, - "created_at", t.CreatedAt, - ) - g.Redis().Do(ctx, "EXPIRE", predeductKey, remainingTTL) - g.Redis().Do(ctx, "SADD", activeSetKey, t.RequestID) - } - // 确保 active SET 有 TTL(30 天),过期后下次预扣时自动重建 - g.Redis().Do(ctx, "EXPIRE", activeSetKey, 30*86400) -} diff --git a/internal/logic/billing/wallet_lua_test.go b/internal/logic/billing/wallet_lua_test.go new file mode 100644 index 0000000..eee53fd --- /dev/null +++ b/internal/logic/billing/wallet_lua_test.go @@ -0,0 +1,299 @@ +package billing + +import ( + "context" + "os" + "strconv" + "sync" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/gogf/gf/v2/database/gredis" + "github.com/shopspring/decimal" + + // 注册 GoFrame redis 适配器(生产代码在 main.go 空导入;单测需在此显式注册) + _ "github.com/gogf/gf/contrib/nosql/redis/v2" +) + +// miniredis 全局实例:g.Redis() 经 gredis.SetConfig 指向内存 Redis, +// 验证钱包权威化架构下的全部 Lua 脚本(预扣/结算认领/解冻/加款/扣款)。 +// miniredis 单线程串行执行命令,脚本内 check-then-act 等价原子,可验证资金守恒。 +var mr *miniredis.Miniredis + +func TestMain(m *testing.M) { + var err error + mr, err = miniredis.Run() + if err != nil { + panic(err) + } + if err := gredis.SetConfigByMap(map[string]any{ + "address": mr.Addr(), + "db": 0, + }); err != nil { + panic(err) + } + code := m.Run() + mr.Close() + os.Exit(code) +} + +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) + if err != nil { + t.Fatalf("read wallet hash: %v", err) + } + if !exists { + t.Fatalf("wallet hash missing for tenant %d", tenantID) + } + return FromMicro(b), FromMicro(f) +} + +// seedWallet 用加款创建指定余额的钱包 hash(顺带校验 CreditWalletRedis 自身) +func seedWallet(t *testing.T, tenantID int64, balance decimal.Decimal) { + t.Helper() + ba, _, err := CreditWalletRedis(testCtx(), tenantID, balance) + if err != nil { + t.Fatalf("seed wallet: %v", err) + } + if !ba.Equal(balance) { + t.Fatalf("seed wallet balance=%s want %s", ba.String(), balance.String()) + } +} + +// assertDecimalEqual 断言两个 decimal 相等(金额精度 6 位) +func assertDecimalEqual(t *testing.T, got, want decimal.Decimal, msg string) { + t.Helper() + if !got.Equal(want) { + t.Fatalf("%s: got=%s want=%s", msg, got.String(), want.String()) + } +} + +func TestPreDeduct_Basic(t *testing.T) { + tenant := int64(1) + seedWallet(t, tenant, NewFromFloat(10)) + + ok, err := PreDeduct(testCtx(), tenant, 4, "req-p1", "gpt-4o") + if err != nil || !ok { + t.Fatalf("pre-deduct failed: ok=%v err=%v", ok, err) + } + bal, frozen := readWallet(t, tenant) + assertDecimalEqual(t, bal, NewFromFloat(10), "balance unchanged by freeze") + assertDecimalEqual(t, frozen, NewFromFloat(4), "frozen after prededuct") + + // 幂等:同一 request_id 重复预扣 → 成功且不重复冻结 + ok, err = PreDeduct(testCtx(), tenant, 4, "req-p1", "gpt-4o") + if err != nil || !ok { + t.Fatalf("idempotent pre-deduct failed: ok=%v err=%v", ok, err) + } + _, frozen = readWallet(t, tenant) + assertDecimalEqual(t, frozen, NewFromFloat(4), "no double freeze on idempotent") + + // 余额不足:可用 6(10-4)< 7 → 拒绝且不冻结 + ok, err = PreDeduct(testCtx(), tenant, 7, "req-p2", "gpt-4o") + if ok || err == nil { + t.Fatalf("insufficient pre-deduct should fail: ok=%v err=%v", ok, err) + } + _, frozen = readWallet(t, tenant) + assertDecimalEqual(t, frozen, NewFromFloat(4), "no freeze on insufficient") +} + +func TestSettleClaim_Normal(t *testing.T) { + tenant := int64(2) + seedWallet(t, tenant, NewFromFloat(10)) + if ok, _ := PreDeduct(testCtx(), tenant, 4, "req-s1", "gpt-4o"); !ok { + t.Fatal("prededuct failed") + } + + claimed, bal, frozen, err := SettleClaim(testCtx(), tenant, NewFromFloat(1.5), []string{"req-s1"}) + if err != nil { + t.Fatalf("settle claim: %v", err) + } + assertDecimalEqual(t, claimed, NewFromFloat(4), "claimed == prededucted") + assertDecimalEqual(t, bal, NewFromFloat(8.5), "balance after deduct") + assertDecimalEqual(t, frozen, decimal.Zero, "frozen fully released") +} + +func TestSettleClaim_Duplicate(t *testing.T) { + tenant := int64(3) + seedWallet(t, tenant, NewFromFloat(10)) + if ok, _ := PreDeduct(testCtx(), tenant, 4, "req-s2", "gpt-4o"); !ok { + t.Fatal("prededuct failed") + } + if _, _, _, err := SettleClaim(testCtx(), tenant, NewFromFloat(1.5), []string{"req-s2"}); err != nil { + t.Fatalf("first settle: %v", err) + } + + // 重复结算:预扣 hash 已随认领删除 → claimed=0,只按补扣继续扣 balance + claimed, bal, frozen, err := SettleClaim(testCtx(), tenant, NewFromFloat(0.5), []string{"req-s2"}) + if err != nil { + t.Fatalf("duplicate settle claim: %v", err) + } + assertDecimalEqual(t, claimed, decimal.Zero, "duplicate claims nothing") + assertDecimalEqual(t, bal, NewFromFloat(8), "only supplement deducted") + assertDecimalEqual(t, frozen, decimal.Zero, "frozen untouched") +} + +func TestSettleClaim_MultiKeyAdjust(t *testing.T) { + tenant := int64(4) + seedWallet(t, tenant, NewFromFloat(10)) + if ok, _ := PreDeduct(testCtx(), tenant, 3, "req-t1", "midjourney"); !ok { + t.Fatal("prededuct failed") + } + if ok, _ := PreDeduct(testCtx(), tenant, 1, "req-t1_adjust", "midjourney"); !ok { + t.Fatal("adjust prededuct failed") + } + + claimed, bal, frozen, err := SettleClaim(testCtx(), tenant, NewFromFloat(2), []string{"req-t1", "req-t1_adjust"}) + if err != nil { + t.Fatalf("multi-key settle claim: %v", err) + } + assertDecimalEqual(t, claimed, NewFromFloat(4), "sum of both prededucts claimed") + assertDecimalEqual(t, bal, NewFromFloat(8), "balance after cost") + assertDecimalEqual(t, frozen, decimal.Zero, "all frozen released") +} + +func TestUnfreeze_Idempotent(t *testing.T) { + tenant := int64(5) + seedWallet(t, tenant, NewFromFloat(10)) + if ok, _ := PreDeduct(testCtx(), tenant, 2, "req-u1", "gpt-4o"); !ok { + t.Fatal("prededuct failed") + } + + amt, err := UnfreezePreDeduct(testCtx(), tenant, "req-u1") + if err != nil { + t.Fatalf("unfreeze: %v", err) + } + assertDecimalEqual(t, amt, NewFromFloat(2), "unfreeze releases prededucted amount") + _, frozen := readWallet(t, tenant) + assertDecimalEqual(t, frozen, decimal.Zero, "frozen released") + + // 幂等:第二次认领不到 → 0,不二次释放 + amt2, err := UnfreezePreDeduct(testCtx(), tenant, "req-u1") + if err != nil { + t.Fatalf("second unfreeze: %v", err) + } + assertDecimalEqual(t, amt2, decimal.Zero, "idempotent second unfreeze") + + // 不存在的预扣 → 0 + amt3, err := UnfreezePreDeduct(testCtx(), tenant, "req-missing") + if err != nil { + t.Fatalf("missing unfreeze: %v", err) + } + assertDecimalEqual(t, amt3, decimal.Zero, "missing prededuct no-op") +} + +func TestCreditDebitWallet(t *testing.T) { + tenant := int64(6) + seedWallet(t, tenant, NewFromFloat(10)) + + // 扣款门槛:可用 10 < 12 → 拒绝且不变更 + _, _, ok, err := DebitWalletRedis(testCtx(), tenant, NewFromFloat(12)) + if err != nil { + t.Fatalf("debit err: %v", err) + } + if ok { + t.Fatal("debit beyond available must fail") + } + bal, _ := readWallet(t, tenant) + assertDecimalEqual(t, bal, NewFromFloat(10), "no change on failed debit") + + // 正常扣款 + after, _, ok, err := DebitWalletRedis(testCtx(), tenant, NewFromFloat(3)) + if err != nil || !ok { + t.Fatalf("debit failed: ok=%v err=%v", ok, err) + } + assertDecimalEqual(t, after, NewFromFloat(7), "debit reduces balance") + + // 加款(负数=扣回补偿) + after, _, err = CreditWalletRedis(testCtx(), tenant, NewFromFloat(-3)) + if err != nil { + t.Fatalf("credit: %v", err) + } + assertDecimalEqual(t, after, NewFromFloat(4), "negative credit reduces balance") +} + +// TestWalletConcurrency 并发守恒:N 并发预扣+结算后,frozen 归零、balance 精确扣减 +func TestWalletConcurrency(t *testing.T) { + tenant := int64(10) + seedWallet(t, tenant, NewFromFloat(100)) + + const n = 50 + var wg sync.WaitGroup + errCh := make(chan error, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + ctx := testCtx() + rid := "req-c" + strconv.Itoa(idx) + ok, err := PreDeduct(ctx, tenant, 1, rid, "gpt-4o") + if err != nil || !ok { + errCh <- err + return + } + if _, _, _, err := SettleClaim(ctx, tenant, NewFromFloat(0.5), []string{rid}); err != nil { + errCh <- err + } + }(i) + } + wg.Wait() + close(errCh) + for err := range errCh { + t.Fatalf("concurrent wallet op failed: %v", err) + } + + 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") +} + +// TestPredeductSweep_RecomputesFrozen 孤儿清扫重算:某预扣 hash 丢失(TTL 过期/崩溃残留)后, +// frozen 仍含其金额 → sweep 重算为幸存 hash 之和并清理活跃集合死亡成员。 +func TestPredeductSweep_RecomputesFrozen(t *testing.T) { + tenant := int64(8) + seedWallet(t, tenant, NewFromFloat(10)) + if ok, _ := PreDeduct(testCtx(), tenant, 2, "req-sw1", "gpt-4o"); !ok { + t.Fatal("prededuct 1 failed") + } + if ok, _ := PreDeduct(testCtx(), tenant, 3, "req-sw2", "gpt-4o"); !ok { + t.Fatal("prededuct 2 failed") + } + _, frozen := readWallet(t, tenant) + assertDecimalEqual(t, frozen, NewFromFloat(5), "frozen after two prededucts") + + // 模拟孤儿:直接删除 req-sw2 的预扣 hash(不释放冻结) + if !mr.Del(PreDeductRedisKeyPrefix + "req-sw2") { + t.Fatal("simulate orphan: prededuct hash not found") + } + + PredeductSweep(testCtx()) + + _, frozen = readWallet(t, tenant) + assertDecimalEqual(t, frozen, NewFromFloat(2), "frozen recomputed to surviving prededucts") + + // 孤儿 request 移出活跃集合 + activeSetKey := "prededuct_active:8" + members, err := mr.SMembers(activeSetKey) + if err != nil { + t.Fatalf("read active set: %v", err) + } + if len(members) != 1 || members[0] != "req-sw1" { + t.Fatalf("active set after sweep = %v, want [req-sw1]", members) + } +} + +func TestCleanupPreDeduct_RemovesHash(t *testing.T) { + tenant := int64(7) + seedWallet(t, tenant, NewFromFloat(10)) + if ok, _ := PreDeduct(testCtx(), tenant, 1, "req-cl1", "gpt-4o"); !ok { + t.Fatal("prededuct failed") + } + CleanupPreDeduct(testCtx(), tenant, "req-cl1") + if _, exists := GetPreDeductAmount(testCtx(), "req-cl1"); exists { + t.Fatal("prededuct hash should be cleaned") + } +} diff --git a/internal/logic/common/settings_registry.go b/internal/logic/common/settings_registry.go index 76ad4f0..673c249 100644 --- a/internal/logic/common/settings_registry.go +++ b/internal/logic/common/settings_registry.go @@ -158,6 +158,8 @@ var Registry = []SettingDef{ Label: "支付回调基础URL", Description: "为空则使用请求 Host"}, {Key: "payment_exchange_rate_cny_to_usd", Type: SettingTypeFloat, Default: "0.14", Category: "payment", Label: "CNY → USD 兑换比例", Description: "1 人民币兑换多少美元(USD → CNY 自动取倒数,确保往返闭合)", Validation: "min:0.001,max:100"}, + {Key: "payment_notice", Type: SettingTypeString, Default: "", Category: "payment", + Label: "支付说明", Description: "展示在租户端钱包充值页的说明文案,支持纯文本或 HTML 代码(如提示充值后不支持退款、指定渠道折扣活动等)。纯文本换行需用
,富文本请直接粘贴 HTML"}, // ── Performance ── {Key: "global_qps_limit", Type: SettingTypeInt, Default: "10000", Category: "performance", diff --git a/internal/logic/common/validation.go b/internal/logic/common/validation.go index a3913da..3c43059 100644 --- a/internal/logic/common/validation.go +++ b/internal/logic/common/validation.go @@ -81,6 +81,36 @@ func ValidateDateParam(date string, fieldName string) error { return nil } +// ValidateDateTimeParam 校验时间筛选参数,兼容 YYYY-MM-DD 与 YYYY-MM-DD HH:mm:ss +// (前端时间范围选择器精确到分钟时传完整时间戳,仅选日期时传日期)。 +func ValidateDateTimeParam(value string, fieldName string) error { + if value == "" { + return nil + } + for _, layout := range []string{"2006-01-02", "2006-01-02 15:04:05"} { + if _, err := time.Parse(layout, value); err == nil { + return nil + } + } + return gerror.NewCode(gcode.New(consts.CodeBadRequest, "", nil), "%s格式无效,应为 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss", fieldName) +} + +// StartOfRange 返回时间范围过滤起点:仅日期(YYYY-MM-DD)时补齐当天 00:00:00,带时间则原样使用 +func StartOfRange(value string) string { + if len(value) == 10 { + return value + " 00:00:00" + } + return value +} + +// EndOfRange 返回时间范围过滤终点:仅日期(YYYY-MM-DD)时补齐当天 23:59:59,带时间则原样使用 +func EndOfRange(value string) string { + if len(value) == 10 { + return value + " 23:59:59" + } + return value +} + // ValidateForbiddenWords 校验名称是否包含系统禁用词 // 从系统配置 register_forbidden_words 读取逗号分隔的禁用词列表,对 value 做大小写不敏感的包含检查 func ValidateForbiddenWords(ctx context.Context, value, fieldName string) error { diff --git a/internal/logic/payment/fulfill.go b/internal/logic/payment/fulfill.go index a0f4a80..ea19e71 100644 --- a/internal/logic/payment/fulfill.go +++ b/internal/logic/payment/fulfill.go @@ -3,6 +3,7 @@ package payment import ( "context" "fmt" + "time" do "github.com/qianfree/team-api/internal/model/do" @@ -18,10 +19,10 @@ import ( // FulfillOrder 履约订单(事务内完成:履约+更新订单状态) func FulfillOrder(ctx context.Context, orderID int64) error { - // walletTouched 标记本次履约是否变更了钱包余额;钱包缓存失效必须在事务提交后执行, - // 否则并发 GetWallet 可能在提交前读到旧 DB 值并回写 Redis,污染缓存。 - var walletTouchedTenantID int64 - var walletTouched bool + // redisCredited* 记录事务内已发生的 Redis 钱包加款:Redis 是资金提交点、不受 DB 事务回滚, + // 事务失败时必须按此补偿逆转,防止「订单未履约但钱包已入账」。 + var redisCreditedTenantID int64 + var redisCreditedAmount decimal.Decimal err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { var order *struct { @@ -69,7 +70,11 @@ func FulfillOrder(ctx context.Context, orderID int64) error { // 汇率只取一次,保证入账换算与快照使用同一汇率。 rate := billing.GetExchangeRateCNYToUSD(ctx) usdAmount := billing.CeilUSD(billing.NewFromFloat(order.Amount).Mul(billing.NewFromFloat(rate))) - if err = creditWalletTx(ctx, order.TenantID, usdAmount, orderID, fmt.Sprintf("Recharge: order #%d (CNY %.2f, paid %.2f → USD %.6f)", orderID, order.Amount, order.FinalAmount, usdAmount.InexactFloat64())); err != nil { + credited, err := creditWalletTx(ctx, order.TenantID, usdAmount, orderID, fmt.Sprintf("Recharge: order #%d (CNY %.2f, paid %.2f → USD %.6f)", orderID, order.Amount, order.FinalAmount, usdAmount.InexactFloat64())) + // 先记录已发生的 Redis 加款(可能 >0 即使整体失败),供事务回滚后补偿逆转 + redisCreditedTenantID = order.TenantID + redisCreditedAmount = credited + if err != nil { return gerror.Wrapf(err, "credit wallet failed") } // 汇率结构化快照:持久化「原始 CNY(订单列)+ 当时汇率 + 入账 USD」, @@ -82,8 +87,6 @@ func FulfillOrder(ctx context.Context, orderID int64) error { _ = billing.CheckAndUpgradeLevel(ctx, order.TenantID) // 充值后检查是否需要重置低余额预警标记 billing.ResetLowBalanceNotified(ctx, order.TenantID) - walletTouchedTenantID = order.TenantID - walletTouched = true default: return gerror.Newf("unsupported order type for fulfillment: %s", order.OrderType) @@ -110,14 +113,19 @@ func FulfillOrder(ctx context.Context, orderID int64) error { return nil }) if err != nil { + // 事务回滚:补偿逆转已发生的 Redis 钱包加款(订单未履约,钱包不得入账)。 + // 补偿自身失败意味着钱包多入账——打 CRITICAL 日志人工追回。 + if redisCreditedAmount.GreaterThan(billing.Zero) { + compCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + if _, _, compErr := billing.CreditWalletRedis(compCtx, redisCreditedTenantID, redisCreditedAmount.Neg()); compErr != nil { + g.Log().Errorf(ctx, + "CRITICAL: compensate recharge redis credit failed: tenant=%d usd=%s order=%d: %v — wallet over-credited, manual fix required", + redisCreditedTenantID, redisCreditedAmount.String(), orderID, compErr) + } + } return err } - - // 事务提交后再清除钱包两级缓存(进程内 walletCache + Redis),避免充值后 GetWallet 在 300s 内仍返回旧余额。 - // 放在提交后执行,避免「DEL → 并发 GetWallet 读到旧 DB 值回写」的缓存污染窗口。 - if walletTouched { - billing.InvalidateWallet(ctx, walletTouchedTenantID) - } return nil } @@ -192,11 +200,17 @@ func SubscribePlan(ctx context.Context, tenantID int64, planID int64, months int }) } -// creditWalletTx 在事务内钱包入账(依赖调用方传入携带事务的 ctx) -// amount 为 USD(bil_ 层永远 USD);用 decimal 直传原生 SQL(shopspring decimal 实现 -// driver.Valuer → 精确 NUMERIC 字符串),避免 float64 入账在 balance+? 累加时产生漂移。 -// orderID 写入流水 related_id:退款时靠它精确找回履约当时入账的 USD(禁止按当前汇率反算)。 -func creditWalletTx(ctx context.Context, tenantID int64, amount decimal.Decimal, orderID int64, description string) error { +// creditWalletTx 钱包入账(依赖调用方传入携带事务的 ctx,且处于订单行锁保护内)。 +// amount 为 USD(bil_ 层永远 USD);orderID 写入流水 related_id:退款时靠它精确找回 +// 履约当时入账的 USD(禁止按当前汇率反算)。 +// +// Redis 权威化架构下的职责拆分: +// - Redis 加款是资金提交点(实时余额立即生效),在行锁保护内执行,并发回调串行化、天然幂等; +// - DB 侧只维护「累计充值 + 账本流水」——bil_wallets.balance 由物化器从 Redis 覆盖, +// 不再在事务内更新,避免与滞后物化值互相回滚; +// - 返回值为【已发生的 Redis 加款金额】(>0 即使后续 DB 步骤失败),供调用方在事务 +// 回滚后补偿逆转。 +func creditWalletTx(ctx context.Context, tenantID int64, amount decimal.Decimal, orderID int64, description string) (decimal.Decimal, error) { var w *struct { ID int64 `json:"id"` } @@ -205,32 +219,24 @@ func creditWalletTx(ctx context.Context, tenantID int64, amount decimal.Decimal, Fields("id"). Scan(&w) if err != nil { - return err + return billing.Zero, err } if w == nil { - return gerror.Newf("wallet not found for tenant %d", tenantID) + return billing.Zero, gerror.Newf("wallet not found for tenant %d", tenantID) } + // Redis 加款(资金提交点);流水快照取 Redis 返回值,账本链连续可信 + balanceAfter, frozenAfter, err := billing.CreditWalletRedis(ctx, tenantID, amount) + if err != nil { + return billing.Zero, err + } + + // DB 仅累加累计充值(decimal 直传 NUMERIC,driver.Valuer 精确字符串无漂移) _, err = g.DB().Ctx(ctx).Exec(ctx, - "UPDATE bil_wallets SET balance = balance + ?, cumulative_recharge = cumulative_recharge + ?, updated_at = NOW() WHERE id = ?", - amount, amount, w.ID) + "UPDATE bil_wallets SET cumulative_recharge = cumulative_recharge + ?, updated_at = NOW() WHERE id = ?", + amount, w.ID) if err != nil { - return err - } - - var balance *struct { - Balance decimal.Decimal `json:"balance"` - FrozenBalance decimal.Decimal `json:"frozen_balance"` - } - err = dao.BilWallets.Ctx(ctx). - Where("id", w.ID). - Fields("balance, frozen_balance"). - Scan(&balance) - if err != nil { - return err - } - if balance == nil { - return gerror.New("wallet not found after update") + return amount, err } _, err = dao.BilTransactions.Ctx(ctx).Insert(do.BilTransactions{ @@ -238,18 +244,15 @@ func creditWalletTx(ctx context.Context, tenantID int64, amount decimal.Decimal, WalletId: w.ID, Type: "recharge", Amount: amount, - BalanceAfter: balance.Balance, - FrozenAfter: balance.FrozenBalance, + BalanceAfter: balanceAfter, + FrozenAfter: frozenAfter, RelatedId: orderID, RelatedType: "order", Description: description, }) if err != nil { - return err + return amount, err } - // 注意:钱包缓存失效(billing.InvalidateWallet)不在事务内调用——事务尚未提交时 DEL Redis, - // 并发 GetWallet 可能从 DB 读到旧余额并回写 Redis,导致缓存被旧值重新污染。 - // 失效由调用方 FulfillOrder 在事务提交后统一执行。 - return nil + return amount, nil } diff --git a/internal/logic/relay/provider.go b/internal/logic/relay/provider.go index 7e23bd5..8e137dc 100644 --- a/internal/logic/relay/provider.go +++ b/internal/logic/relay/provider.go @@ -809,8 +809,8 @@ func (p *DataProviderImpl) GetAvailableModels(ctx context.Context, tenantID, api } // GetModelDetail 实现 DataProvider.GetModelDetail -// 获取单个模型的详细信息,同时校验租户权限 -func (p *DataProviderImpl) GetModelDetail(ctx context.Context, tenantID int64, modelName string) (*common.ModelDetail, error) { +// 获取单个模型的详细信息,同时校验租户 / API Key / 成员的模型范围权限 +func (p *DataProviderImpl) GetModelDetail(ctx context.Context, tenantID, apiKeyID, userID int64, modelName string) (*common.ModelDetail, error) { type modelRow struct { ID int64 `json:"id"` ModelId string `json:"model_id"` @@ -847,6 +847,24 @@ func (p *DataProviderImpl) GetModelDetail(ctx context.Context, tenantID int64, m } } + // 校验 API Key 模型范围(与 /v1/models 列表口径一致,仅当 apiKeyID > 0 时) + if apiKeyID > 0 { + if allowed, err := p.CheckApiKeyModelAccess(ctx, apiKeyID, modelName); err != nil { + return nil, err + } else if !allowed { + return nil, common.ErrApiKeyModelNotAllowed + } + } + + // 校验成员模型范围(与 /v1/models 列表口径一致,仅当 userID > 0 时) + if userID > 0 { + if allowed, err := p.CheckMemberModelAccess(ctx, tenantID, userID, modelName); err != nil { + return nil, err + } else if !allowed { + return nil, common.ErrMemberModelNotAllowed + } + } + // 解析 created_at 为 Unix timestamp var created int64 if model.CreatedAt != "" { @@ -1405,3 +1423,9 @@ func (p *DataProviderImpl) CheckApiKeyModelAccess(ctx context.Context, apiKeyID } return false, nil } + +// InvalidateApiKeyModelCache 清除指定 API Key 的模型权限缓存 +func (p *DataProviderImpl) InvalidateApiKeyModelCache(ctx context.Context, apiKeyID int64) { + cacheKey := strconv.FormatInt(apiKeyID, 10) + apiKeyModelCache.Delete(ctx, cacheKey) +} diff --git a/internal/logic/tenant/api_key.go b/internal/logic/tenant/api_key.go index 57d4190..5096f6c 100644 --- a/internal/logic/tenant/api_key.go +++ b/internal/logic/tenant/api_key.go @@ -14,6 +14,7 @@ import ( "github.com/qianfree/team-api/internal/logic/billing" lcommon "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/logic/relay" + lrelay "github.com/qianfree/team-api/internal/logic/relay" "github.com/qianfree/team-api/internal/middleware" do "github.com/qianfree/team-api/internal/model/do" @@ -472,6 +473,8 @@ func (s *sTenant) ApiKeyUpdate(ctx context.Context, req *v1.TenantApiKeyUpdateRe if hasUpdate || req.ModelNames != nil { relay.InvalidateApiKey(ctx, info.KeyPrefix) + // 失效模型权限缓存(修复:更新模型范围后缓存未失效的问题) + lrelay.NewDataProvider().InvalidateApiKeyModelCache(ctx, keyID) } return &v1.TenantApiKeyUpdateRes{}, nil @@ -527,6 +530,8 @@ func (s *sTenant) ApiKeyUpdateScopes(ctx context.Context, req *v1.TenantApiKeyUp } relay.InvalidateApiKey(ctx, info.KeyPrefix) + // 失效模型权限缓存(修复:更新模型范围后缓存未失效的问题) + lrelay.NewDataProvider().InvalidateApiKeyModelCache(ctx, keyID) return &v1.TenantApiKeyUpdateScopesRes{}, nil } diff --git a/internal/logic/tenant/audit_config.go b/internal/logic/tenant/audit_config.go index 6632a74..600ea0b 100644 --- a/internal/logic/tenant/audit_config.go +++ b/internal/logic/tenant/audit_config.go @@ -150,10 +150,10 @@ func isValidAuditLevel(level string) bool { // TenantRequestAuditLogs 分页查询租户的请求审计日志(不含 body,性能优先) func (s *sTenant) TenantRequestAuditLogs(ctx context.Context, req *v1.TenantRequestAuditLogsReq) (*v1.TenantRequestAuditLogsRes, error) { - if err := common.ValidateDateParam(req.StartDate, "开始日期"); err != nil { + if err := common.ValidateDateTimeParam(req.StartDate, "开始时间"); err != nil { return nil, err } - if err := common.ValidateDateParam(req.EndDate, "结束日期"); err != nil { + if err := common.ValidateDateTimeParam(req.EndDate, "结束时间"); err != nil { return nil, err } @@ -214,11 +214,11 @@ func (s *sTenant) TenantRequestAuditLogs(ctx context.Context, req *v1.TenantRequ } if req.StartDate != "" { conditions = append(conditions, "created_at >= ?") - args = append(args, req.StartDate+" 00:00:00") + args = append(args, common.StartOfRange(req.StartDate)) } if req.EndDate != "" { conditions = append(conditions, "created_at <= ?") - args = append(args, req.EndDate+" 23:59:59") + args = append(args, common.EndOfRange(req.EndDate)) } where := strings.Join(conditions, " AND ") diff --git a/internal/logic/tenant/billing.go b/internal/logic/tenant/billing.go index 31a7f7b..00def1d 100644 --- a/internal/logic/tenant/billing.go +++ b/internal/logic/tenant/billing.go @@ -163,10 +163,10 @@ func (s *sTenant) WalletTransactions(ctx context.Context, req *v1.TenantWalletTr // UsageLogs 获取租户用量日志 func (s *sTenant) UsageLogs(ctx context.Context, req *v1.TenantUsageLogsReq) (*v1.TenantUsageLogsRes, error) { - if err := common.ValidateDateParam(req.StartDate, "开始日期"); err != nil { + if err := common.ValidateDateTimeParam(req.StartDate, "开始时间"); err != nil { return nil, err } - if err := common.ValidateDateParam(req.EndDate, "结束日期"); err != nil { + if err := common.ValidateDateTimeParam(req.EndDate, "结束时间"); err != nil { return nil, err } @@ -203,11 +203,11 @@ func (s *sTenant) UsageLogs(ctx context.Context, req *v1.TenantUsageLogsReq) (*v } if req.StartDate != "" { conditions = append(conditions, "u.created_at >= ?") - args = append(args, req.StartDate+" 00:00:00") + args = append(args, common.StartOfRange(req.StartDate)) } if req.EndDate != "" { conditions = append(conditions, "u.created_at <= ?") - args = append(args, req.EndDate+" 23:59:59") + args = append(args, common.EndOfRange(req.EndDate)) } where := strings.Join(conditions, " AND ") @@ -273,10 +273,10 @@ func (s *sTenant) UsageLogs(ctx context.Context, req *v1.TenantUsageLogsReq) (*v // ExportUsageLogs exports the tenant usage logs as CSV or Excel. func (s *sTenant) ExportUsageLogs(ctx context.Context, req *v1.TenantUsageLogsExportReq) (*v1.TenantUsageLogsExportRes, error) { - if err := common.ValidateDateParam(req.StartDate, "开始日期"); err != nil { + if err := common.ValidateDateTimeParam(req.StartDate, "开始时间"); err != nil { return nil, err } - if err := common.ValidateDateParam(req.EndDate, "结束日期"); err != nil { + if err := common.ValidateDateTimeParam(req.EndDate, "结束时间"); err != nil { return nil, err } @@ -331,11 +331,11 @@ func (s *sTenant) ExportUsageLogs(ctx context.Context, req *v1.TenantUsageLogsEx } if req.StartDate != "" { conditions = append(conditions, "u.created_at >= ?") - args = append(args, req.StartDate+" 00:00:00") + args = append(args, common.StartOfRange(req.StartDate)) } if req.EndDate != "" { conditions = append(conditions, "u.created_at <= ?") - args = append(args, req.EndDate+" 23:59:59") + args = append(args, common.EndOfRange(req.EndDate)) } where := strings.Join(conditions, " AND ") @@ -518,8 +518,8 @@ func (s *sTenant) UpdateWarningThreshold(ctx context.Context, req *v1.TenantWall return nil, err } - // 清除钱包缓存 - billing.InvalidateWalletRedis(ctx, tenantID) + // 清除钱包静态字段缓存(Redis 钱包 hash 是权威余额,不失效) + billing.InvalidateWalletStaticCache(ctx, tenantID) // 阈值变更后重置预警标记,使新阈值能触发新的预警 billing.ResetLowBalanceNotified(ctx, tenantID) diff --git a/internal/logic/tenant/model.go b/internal/logic/tenant/model.go index 9c28272..6c7e82f 100644 --- a/internal/logic/tenant/model.go +++ b/internal/logic/tenant/model.go @@ -35,6 +35,7 @@ type tenantModelPriceRow struct { BaseCacheReadPrice float64 `json:"base_cache_read_price"` BaseCacheCreationPrice float64 `json:"base_cache_creation_price"` BaseBillingMode string `json:"base_billing_mode"` + BasePerRequestPrice *float64 `json:"base_per_request_price"` CustomInputPrice *float64 `json:"custom_input_price"` CustomOutputPrice *float64 `json:"custom_output_price"` CustomCacheReadPrice *float64 `json:"custom_cache_read_price"` @@ -44,10 +45,11 @@ type tenantModelPriceRow struct { // groupPriceRow 分组模型的 base 价格查询结果 type groupPriceRow struct { - ModelID int64 `json:"model_id"` - BaseBillingMode string `json:"base_billing_mode"` - BaseInputPrice float64 `json:"base_input_price"` - BaseOutputPrice float64 `json:"base_output_price"` + ModelID int64 `json:"model_id"` + BaseBillingMode string `json:"base_billing_mode"` + BaseInputPrice float64 `json:"base_input_price"` + BaseOutputPrice float64 `json:"base_output_price"` + BasePerRequestPrice *float64 `json:"base_per_request_price"` } // baseTierRow 阶梯定价查询结果 @@ -77,6 +79,7 @@ type priceInfo struct { BaseCacheReadPrice float64 BaseCacheCreationPrice float64 BaseBillingMode string + BasePerRequestPrice *float64 CustomInputPrice *float64 CustomOutputPrice *float64 CustomCacheReadPrice *float64 @@ -86,9 +89,10 @@ type priceInfo struct { // groupPriceInfo 分组模型的价格信息 type groupPriceInfo struct { - BaseBillingMode string - BaseInputPrice float64 - BaseOutputPrice float64 + BaseBillingMode string + BaseInputPrice float64 + BaseOutputPrice float64 + BasePerRequestPrice *float64 } // ListAvailableModels 获取租户可用的模型列表 @@ -119,7 +123,7 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab LeftJoin("mdl_pricing p ON p.model_id = mdl_tenant_models.model_id AND p.min_tokens = 0"). Where("mdl_tenant_models.tenant_id", tenantID). WhereIn("mdl_tenant_models.model_id", explicitDBIDs). - Fields("mdl_tenant_models.model_id AS model_db_id, mdl_tenant_models.id, mdl_tenant_models.billing_mode, mdl_tenant_models.per_request_price, mdl_tenant_models.discount_ratio, mdl_tenant_models.max_concurrency, p.input_price AS base_input_price, p.output_price AS base_output_price, p.cache_read_price AS base_cache_read_price, p.cache_creation_price AS base_cache_creation_price, p.billing_mode AS base_billing_mode, mdl_tenant_models.custom_input_price, mdl_tenant_models.custom_output_price, mdl_tenant_models.custom_cache_read_price, mdl_tenant_models.custom_cache_creation_price, mdl_tenant_models.custom_pricing_tiers"). + Fields("mdl_tenant_models.model_id AS model_db_id, mdl_tenant_models.id, mdl_tenant_models.billing_mode, mdl_tenant_models.per_request_price, mdl_tenant_models.discount_ratio, mdl_tenant_models.max_concurrency, p.input_price AS base_input_price, p.output_price AS base_output_price, p.cache_read_price AS base_cache_read_price, p.cache_creation_price AS base_cache_creation_price, p.billing_mode AS base_billing_mode, p.per_request_price AS base_per_request_price, mdl_tenant_models.custom_input_price, mdl_tenant_models.custom_output_price, mdl_tenant_models.custom_cache_read_price, mdl_tenant_models.custom_cache_creation_price, mdl_tenant_models.custom_pricing_tiers"). Scan(&priceResults) if err != nil { return nil, err @@ -140,6 +144,7 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab BaseCacheReadPrice: r.BaseCacheReadPrice, BaseCacheCreationPrice: r.BaseCacheCreationPrice, BaseBillingMode: r.BaseBillingMode, + BasePerRequestPrice: r.BasePerRequestPrice, CustomInputPrice: r.CustomInputPrice, CustomOutputPrice: r.CustomOutputPrice, CustomCacheReadPrice: r.CustomCacheReadPrice, @@ -162,7 +167,7 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab err = dao.MdlPricing.Ctx(ctx). WhereIn("model_id", groupDBIDs). Where("min_tokens", 0). - Fields("model_id, billing_mode AS base_billing_mode, input_price AS base_input_price, output_price AS base_output_price"). + Fields("model_id, billing_mode AS base_billing_mode, input_price AS base_input_price, output_price AS base_output_price, per_request_price AS base_per_request_price"). Scan(&groupPrices) if err != nil { return nil, err @@ -170,14 +175,15 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab for _, gp := range groupPrices { groupPriceMap[gp.ModelID] = &groupPriceInfo{ - BaseBillingMode: gp.BaseBillingMode, - BaseInputPrice: gp.BaseInputPrice, - BaseOutputPrice: gp.BaseOutputPrice, + BaseBillingMode: gp.BaseBillingMode, + BaseInputPrice: gp.BaseInputPrice, + BaseOutputPrice: gp.BaseOutputPrice, + BasePerRequestPrice: gp.BasePerRequestPrice, } } } - // 收集需要查询阶梯定价的模型 + // 收集需要查询阶梯定价的模型:显式模型(无自定义阶梯)与分组模型(走 base 阶梯)都纳入 tieredModelDBIDs := make([]int64, 0) for _, m := range models { if m.Source == "explicit" { @@ -187,6 +193,11 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab tieredModelDBIDs = append(tieredModelDBIDs, m.ModelDBID) } } + } else { + // 分组模型使用 base 定价,tiered 时同样需要返回阶梯明细 + if gp, ok := groupPriceMap[m.ModelDBID]; ok && gp.BaseBillingMode == "tiered" { + tieredModelDBIDs = append(tieredModelDBIDs, m.ModelDBID) + } } } @@ -225,6 +236,11 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab } effectiveBillingMode := resolveBillingMode(pi.BillingMode, pi.BaseBillingMode) + // 按次计费:租户未设置覆盖单价时回退 base 单价 + perRequestPrice := pi.PerRequestPrice + if effectiveBillingMode == "per_request" && (perRequestPrice == nil || *perRequestPrice <= 0) { + perRequestPrice = pi.BasePerRequestPrice + } inputPrice := effectivePrice(pi.CustomInputPrice, pi.BaseInputPrice) outputPrice := effectivePrice(pi.CustomOutputPrice, pi.BaseOutputPrice) cacheReadPrice := effectivePrice(pi.CustomCacheReadPrice, pi.BaseCacheReadPrice) @@ -241,7 +257,7 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab Tags: m.Tags, Capabilities: m.Capabilities, BillingMode: &effectiveBillingMode, - PerRequestPrice: pi.PerRequestPrice, + PerRequestPrice: perRequestPrice, DiscountRatio: pi.DiscountRatio, MaxConcurrency: pi.MaxConcurrency, InputPrice: inputPrice, @@ -251,66 +267,45 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab } if effectiveBillingMode == "tiered" { - var tiers []v1.PricingTierItem - if pi.CustomPricingTiers != "" && pi.CustomPricingTiers != "null" && pi.CustomPricingTiers != "[]" { - var raw []pricingTierRow - if json.Unmarshal([]byte(pi.CustomPricingTiers), &raw) == nil && len(raw) > 0 { - for _, t := range raw { - tiers = append(tiers, v1.PricingTierItem{ - MinTokens: t.MinTokens, - MaxTokens: t.MaxTokens, - InputPrice: t.InputPrice, - OutputPrice: t.OutputPrice, - }) - } - } - } - if len(tiers) == 0 { - if pi.BaseInputPrice > 0 || pi.BaseOutputPrice > 0 { - tiers = append(tiers, v1.PricingTierItem{ - MinTokens: 0, - MaxTokens: nil, - InputPrice: pi.BaseInputPrice, - OutputPrice: pi.BaseOutputPrice, - }) - } - if rest, ok := baseTiersMap[m.ModelDBID]; ok { - if len(tiers) > 0 && len(rest) > 0 { - tiers[0].MaxTokens = &rest[0].MinTokens - } - tiers = append(tiers, rest...) - } - } - item.PricingTiers = tiers + item.PricingTiers = buildTiers(pi.CustomPricingTiers, pi.BaseInputPrice, pi.BaseOutputPrice, baseTiersMap[m.ModelDBID]) } list = append(list, item) } else { - // group 来源的模型:使用 base 价格 + // group 来源的模型:使用 base 定价,与显式模型展示一致(含按次单价 / 阶梯明细) billingMode := "token" - var inputPrice, outputPrice *float64 - if gp, ok := groupPriceMap[m.ModelDBID]; ok { + var inputPrice, outputPrice, perRequestPrice *float64 + var baseInputPrice, baseOutputPrice float64 + gp, ok := groupPriceMap[m.ModelDBID] + if ok { billingMode = gp.BaseBillingMode if billingMode == "" { billingMode = "token" } - inputPrice = effectivePrice(nil, gp.BaseInputPrice) - outputPrice = effectivePrice(nil, gp.BaseOutputPrice) + baseInputPrice = gp.BaseInputPrice + baseOutputPrice = gp.BaseOutputPrice + inputPrice = effectivePrice(nil, baseInputPrice) + outputPrice = effectivePrice(nil, baseOutputPrice) + perRequestPrice = gp.BasePerRequestPrice } item := v1.TenantAvailableModelItem{ - ID: m.ModelDBID, - ModelId: m.ModelId, - ModelName: m.ModelName, - Category: m.Category, - MaxContext: m.MaxContextTokens, - MaxOutput: m.MaxOutputTokens, - Description: m.Description, - Tags: m.Tags, - Capabilities: m.Capabilities, - BillingMode: &billingMode, - InputPrice: inputPrice, - OutputPrice: outputPrice, + ID: m.ModelDBID, + ModelId: m.ModelId, + ModelName: m.ModelName, + Category: m.Category, + MaxContext: m.MaxContextTokens, + MaxOutput: m.MaxOutputTokens, + Description: m.Description, + Tags: m.Tags, + Capabilities: m.Capabilities, + BillingMode: &billingMode, + PerRequestPrice: perRequestPrice, + InputPrice: inputPrice, + OutputPrice: outputPrice, + } + if billingMode == "tiered" && ok { + item.PricingTiers = buildTiers("", baseInputPrice, baseOutputPrice, baseTiersMap[m.ModelDBID]) } list = append(list, item) } @@ -362,6 +357,38 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab } } + // 按 API Key 模型范围过滤(与 relay /v1/models 列表口径一致:Key 配置了范围才过滤,无记录不限)。 + // 通过 JOIN api_keys 校验 Key 归属:仅当 Key 属于当前租户时才应用其模型范围, + // 避免传入他人 Key ID 时泄露其模型范围。 + if req.ApiKeyID > 0 { + var keyScopes []struct { + ModelName string `json:"model_name"` + } + err = dao.ApiKeyModelScopes.Ctx(ctx).As("sc"). + InnerJoin("api_keys k ON k.id = sc.api_key_id"). + Where("sc.api_key_id", req.ApiKeyID). + Where("k.tenant_id", tenantID). + Fields("sc.model_name"). + Scan(&keyScopes) + if err != nil { + return nil, err + } + + if len(keyScopes) > 0 { + allowed := make(map[string]bool, len(keyScopes)) + for _, s := range keyScopes { + allowed[s.ModelName] = true + } + filtered := make([]v1.TenantAvailableModelItem, 0, len(list)) + for _, item := range list { + if allowed[item.ModelId] { + filtered = append(filtered, item) + } + } + list = filtered + } + } + // 标记图片模型可用的调用模式(同步端点 / 异步端点),供在线体验决定同步/异步开关。 // 判定与同步端点拦截 gate 同源(constant.IsAsyncImageModel),保证在线体验示例与后端实际 // 行为一致。仅对图片分类模型跑一次批量查询。 @@ -413,3 +440,41 @@ func effectivePrice(custom *float64, base float64) *float64 { } return nil } + +// buildTiers 组装阶梯定价明细:自定义阶梯优先,否则用 base 首档价格(min_tokens=0)+ 基础阶梯其余档。 +// 显式模型与分组模型共用;baseTiers 为 min_tokens>0 的其余档,为空时仅返回首档。 +func buildTiers(customTiersJSON string, baseInputPrice, baseOutputPrice float64, baseTiers []v1.PricingTierItem) []v1.PricingTierItem { + if customTiersJSON != "" && customTiersJSON != "null" && customTiersJSON != "[]" { + var raw []pricingTierRow + if json.Unmarshal([]byte(customTiersJSON), &raw) == nil && len(raw) > 0 { + tiers := make([]v1.PricingTierItem, 0, len(raw)) + for _, t := range raw { + tiers = append(tiers, v1.PricingTierItem{ + MinTokens: t.MinTokens, + MaxTokens: t.MaxTokens, + InputPrice: t.InputPrice, + OutputPrice: t.OutputPrice, + }) + } + return tiers + } + } + + tiers := make([]v1.PricingTierItem, 0, len(baseTiers)+1) + if baseInputPrice > 0 || baseOutputPrice > 0 { + tiers = append(tiers, v1.PricingTierItem{ + MinTokens: 0, + MaxTokens: nil, + InputPrice: baseInputPrice, + OutputPrice: baseOutputPrice, + }) + } + if len(baseTiers) > 0 { + if len(tiers) > 0 { + // 首档与第二档衔接:首档 max_tokens = 第二档 min_tokens + tiers[0].MaxTokens = &baseTiers[0].MinTokens + } + tiers = append(tiers, baseTiers...) + } + return tiers +} diff --git a/internal/logic/tenant/order.go b/internal/logic/tenant/order.go index e94a9d1..16903cf 100644 --- a/internal/logic/tenant/order.go +++ b/internal/logic/tenant/order.go @@ -309,6 +309,8 @@ func (s *sTenant) PaymentInfo(ctx context.Context, req *v1.TenantPaymentInfoReq) res.MinTopUp = int(settings.MinTopUp) res.Currency = settings.Currency } + // 支付说明由管理员在「系统设置-支付配置」配置,支持纯文本或 HTML,原样下发由前端渲染 + res.PaymentNotice = lcommon.Config().GetString(ctx, "payment_notice") return res, nil } diff --git a/internal/logic/tenant/redemption.go b/internal/logic/tenant/redemption.go index a9b1559..96f733b 100644 --- a/internal/logic/tenant/redemption.go +++ b/internal/logic/tenant/redemption.go @@ -36,6 +36,9 @@ func (s *sTenant) RedeemCode(ctx context.Context, req *v1.TenantRedeemCodeReq) ( // 过期兑换码的标记不能在事务内做:事务因「兑换码已过期」业务错误回滚时, // 标记也会一并回滚、永远落不了库。记下 ID,事务返回后在事务外单独更新。 var expiredRedemptionID int64 + // redisCreditedAmount 记录事务内已发生的 Redis 钱包加款:Redis 是资金提交点、 + // 不受 DB 事务回滚,事务失败时必须按此补偿逆转。 + var redisCreditedAmount decimal.Decimal err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { // 加行锁防止并发超发 @@ -77,7 +80,10 @@ func (s *sTenant) RedeemCode(ctx context.Context, req *v1.TenantRedeemCodeReq) ( switch redemption.Type { case "quota": - txID, err = creditWalletForRedemptionTx(ctx, tenantID, redemption.Value, redemption.ID) + var credited decimal.Decimal + txID, credited, err = creditWalletForRedemptionTx(ctx, tenantID, redemption.Value, redemption.ID) + // 先记录已发生的 Redis 加款(可能 >0 即使整体失败),供事务回滚后补偿逆转 + redisCreditedAmount = credited if err != nil { return err } @@ -155,24 +161,36 @@ func (s *sTenant) RedeemCode(ctx context.Context, req *v1.TenantRedeemCodeReq) ( g.Log().Warningf(ctx, "mark redemption %d expired failed: %v", expiredRedemptionID, updateErr) } } + // 事务回滚:补偿逆转已发生的 Redis 钱包加款(兑换未生效,钱包不得入账)。 + // 补偿自身失败意味着钱包多入账——打 CRITICAL 日志人工追回。 + if redisCreditedAmount.GreaterThan(billing.Zero) { + compCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + if _, _, compErr := billing.CreditWalletRedis(compCtx, tenantID, redisCreditedAmount.Neg()); compErr != nil { + g.Log().Errorf(ctx, + "CRITICAL: compensate redemption redis credit failed: tenant=%d usd=%s: %v — wallet over-credited, manual fix required", + tenantID, redisCreditedAmount.String(), compErr) + } + } return nil, err } - // 事务提交后清除钱包两级缓存(进程内 walletCache + Redis,与 FulfillOrder 一致): - // 仅清 Redis 时本实例进程内缓存在 300s TTL 内仍返回旧余额,兑换入账延迟可见。 - billing.InvalidateWallet(ctx, tenantID) - // 兑换码充值后,重置低余额预警标记(余额可能已恢复到阈值以上) billing.ResetLowBalanceNotified(ctx, tenantID) return res, nil } -// creditWalletForRedemptionTx 在事务内为租户钱包充值(依赖调用方传入携带事务的 ctx)。 -// amount 为 USD(bil_ 层永远 USD),decimal 直传 SQL 参数与流水字段,全程无 float64 中间运算。 +// creditWalletForRedemptionTx 在事务内为租户钱包充值(依赖调用方传入携带事务的 ctx, +// 且处于兑换码行锁保护内)。 +// amount 为 USD(bil_ 层永远 USD),decimal 直传流水字段,全程无 float64 中间运算。 // 流水类型用独立的 "redemption":与真实充值(recharge)区分,现金对账(对支付渠道流水) // 时不会把兑换码入账误计入充值;兑换也不推动 cumulative_recharge / 租户等级。 -func creditWalletForRedemptionTx(ctx context.Context, tenantID int64, amount decimal.Decimal, redemptionID int64) (int64, error) { +// +// Redis 权威化架构下:Redis 加款为资金提交点(行锁保护内执行,天然幂等); +// bil_wallets.balance 由物化器从 Redis 覆盖,DB 侧仅写账本流水。 +// 返回的 credited 为【已发生的 Redis 加款金额】(>0 即使后续失败),供事务回滚后补偿逆转。 +func creditWalletForRedemptionTx(ctx context.Context, tenantID int64, amount decimal.Decimal, redemptionID int64) (int64, decimal.Decimal, error) { type walletRow struct { ID int64 `json:"id"` } @@ -182,37 +200,19 @@ func creditWalletForRedemptionTx(ctx context.Context, tenantID int64, amount dec Fields("id"). Scan(&w) if err != nil { - return 0, err + return 0, billing.Zero, err } if w == nil { // 钱包在租户注册/管理后台建租户时初始化,正常不应缺失。这里必须报错回滚整个 // 兑换事务:若静默返回成功,外层会照常写使用记录、递增 used_count 并向用户 // 报告"兑换成功",但钱包分文未入账——兑换码被无声作废,用户权益丢失且无法追溯。 - return 0, gerror.Newf("租户 %d 钱包不存在,无法入账兑换额度", tenantID) + return 0, billing.Zero, gerror.Newf("租户 %d 钱包不存在,无法入账兑换额度", tenantID) } - // 钱包自增用参数化原生 SQL(decimal 实现 driver.Valuer,写出精确 NUMERIC 字符串); - // updated_at 手动置为 NOW(),补回原 dao.Update() 自动填充的时间字段。 - _, err = g.DB().Ctx(ctx).Exec(ctx, - "UPDATE bil_wallets SET balance = balance + ?, updated_at = NOW() WHERE id = ?", - amount, w.ID) + // Redis 加款(资金提交点);流水快照取 Redis 返回值,账本链连续可信 + balanceAfter, frozenAfter, err := billing.CreditWalletRedis(ctx, tenantID, amount) if err != nil { - return 0, err - } - - var balance *struct { - Balance decimal.Decimal `json:"balance"` - FrozenBalance decimal.Decimal `json:"frozen_balance"` - } - err = dao.BilWallets.Ctx(ctx). - Where("id", w.ID). - Fields("balance, frozen_balance"). - Scan(&balance) - if err != nil { - return 0, err - } - if balance == nil { - return 0, gerror.New("wallet not found after update") + return 0, billing.Zero, err } id, err := dao.BilTransactions.Ctx(ctx).InsertAndGetId(do.BilTransactions{ @@ -220,17 +220,17 @@ func creditWalletForRedemptionTx(ctx context.Context, tenantID int64, amount dec WalletId: w.ID, Type: "redemption", Amount: amount, - BalanceAfter: balance.Balance, - FrozenAfter: balance.FrozenBalance, + BalanceAfter: balanceAfter, + FrozenAfter: frozenAfter, RelatedId: redemptionID, RelatedType: "redemption", Description: "兑换码充值", }) if err != nil { - return 0, err + return 0, amount, err } - return id, nil + return id, amount, nil } // extendPlanDurationTx 在事务内延长套餐时长(依赖调用方传入携带事务的 ctx) diff --git a/internal/logic/tenant/task.go b/internal/logic/tenant/task.go index c32ba0e..0f40bf7 100644 --- a/internal/logic/tenant/task.go +++ b/internal/logic/tenant/task.go @@ -22,6 +22,13 @@ func (s *sTenant) TenantTaskList(ctx context.Context, req *v1.TenantTaskListReq) role := middleware.GetUserRole(ctx) userID := middleware.GetUserID(ctx) + if err := common.ValidateDateTimeParam(req.StartDate, "开始时间"); err != nil { + return nil, err + } + if err := common.ValidateDateTimeParam(req.EndDate, "结束时间"); err != nil { + return nil, err + } + m := dao.TskModelTasks.Ctx(ctx). LeftJoin("tnt_users u", "u.id = tsk_model_tasks.user_id AND u.tenant_id = tsk_model_tasks.tenant_id"). Fields("tsk_model_tasks.*, COALESCE(u.username, '') as username"). @@ -42,6 +49,12 @@ func (s *sTenant) TenantTaskList(ctx context.Context, req *v1.TenantTaskListReq) if req.PublicTaskID != "" { m = m.Where("tsk_model_tasks.public_task_id", req.PublicTaskID) } + if req.StartDate != "" { + m = m.Where("tsk_model_tasks.created_at >= ?", common.StartOfRange(req.StartDate)) + } + if req.EndDate != "" { + m = m.Where("tsk_model_tasks.created_at <= ?", common.EndOfRange(req.EndDate)) + } var tasks []struct { Id int64 `json:"id"` @@ -191,6 +204,13 @@ func (s *sTenant) ExportTasks(ctx context.Context, req *v1.TenantTaskExportReq) userID := middleware.GetUserID(ctx) role := middleware.GetUserRole(ctx) + if err := common.ValidateDateTimeParam(req.StartDate, "开始时间"); err != nil { + return nil, err + } + if err := common.ValidateDateTimeParam(req.EndDate, "结束时间"); err != nil { + return nil, err + } + statusLabel := map[string]string{ "NOT_START": "未开始", "SUBMITTED": "已提交", @@ -253,6 +273,14 @@ func (s *sTenant) ExportTasks(ctx context.Context, req *v1.TenantTaskExportReq) conditions = append(conditions, "t.public_task_id = ?") args = append(args, req.PublicTaskID) } + if req.StartDate != "" { + conditions = append(conditions, "t.created_at >= ?") + args = append(args, common.StartOfRange(req.StartDate)) + } + if req.EndDate != "" { + conditions = append(conditions, "t.created_at <= ?") + args = append(args, common.EndOfRange(req.EndDate)) + } where := "" for i, c := range conditions { diff --git a/internal/middleware/rbac.go b/internal/middleware/rbac.go index 8ee0fdc..881f4fc 100644 --- a/internal/middleware/rbac.go +++ b/internal/middleware/rbac.go @@ -134,6 +134,9 @@ var adminPermissionRules = []permissionRule{ {method: "GET", path: "/api/admin/wallets", perm: "billing:view"}, {method: "GET", prefix: "/api/admin/wallets/", perm: "billing:view"}, {method: "POST", prefix: "/api/admin/wallets/", suffix: "/adjust", perm: "billing:refund"}, + {method: "POST", prefix: "/api/admin/wallets/", suffix: "/offline-recharge", perm: "billing:refund"}, + {method: "POST", prefix: "/api/admin/wallets/", suffix: "/frozen-items/release", perm: "billing:refund"}, + {method: "POST", prefix: "/api/admin/wallets/", suffix: "/frozen-items/release-all", perm: "billing:refund"}, {method: "PUT", prefix: "/api/admin/wallets/", suffix: "/warning-threshold", perm: "billing:view"}, // ── plan 套餐管理 ── diff --git a/internal/packed/landing.html b/internal/packed/landing.html new file mode 100644 index 0000000..d7f8662 --- /dev/null +++ b/internal/packed/landing.html @@ -0,0 +1,150 @@ + + + + + + Team-API · 企业级大模型 API 网关 + + + + + + + + + + +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Team-API

+

企业级大模型 API 网关

+
+ + +

+ 统一接入 OpenAI / Claude / Gemini 等主流大模型,为多租户提供 API Key 管理与计费能力 +

+ + + + + +

+ 开发环境请通过 Vite 访问 + 管理后台 :4001 + · + 租户控制台 :4002 +

+ +

+ Team-API v__VERSION__ +

+
+
+ + + + diff --git a/internal/packed/packed.go b/internal/packed/packed.go index fdd3c30..bf31e4f 100644 --- a/internal/packed/packed.go +++ b/internal/packed/packed.go @@ -4,3 +4,6 @@ import _ "embed" //go:embed setup.html var SetupHTML []byte + +//go:embed landing.html +var LandingHTML []byte diff --git a/internal/response/response.go b/internal/response/response.go index 09a1e08..9f1d8b6 100644 --- a/internal/response/response.go +++ b/internal/response/response.go @@ -2,6 +2,7 @@ package response import ( "errors" + "net/http" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" @@ -128,6 +129,9 @@ func ErrorMsg(r *ghttp.Request, code int, message string) { // ErrorWithCode writes an error with separate HTTP status and business code. // Use this when HTTP status differs from the business code (e.g., HTTP 401 with code 10020). func ErrorWithCode(r *ghttp.Request, httpStatus int, code int, message string) { + if httpStatus < 100 || httpStatus > 599 { + httpStatus = http.StatusInternalServerError + } requestID := getRequestID(r) r.Response.WriteHeader(httpStatus) r.Response.WriteJson(jsonResp{ diff --git a/internal/response/response_test.go b/internal/response/response_test.go new file mode 100644 index 0000000..31468de --- /dev/null +++ b/internal/response/response_test.go @@ -0,0 +1,81 @@ +package response_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/util/guid" + + apiresponse "github.com/qianfree/team-api/internal/response" +) + +func TestErrorResponseStatusAndCode(t *testing.T) { + s := g.Server(guid.S()) + s.SetDumpRouterMap(false) + s.BindHandler("/standard", func(r *ghttp.Request) { + apiresponse.ErrorMsg(r, http.StatusBadRequest, "bad request") + }) + s.BindHandler("/business", func(r *ghttp.Request) { + apiresponse.ErrorWithCode(r, http.StatusUnprocessableEntity, 10079, "invalid username") + }) + s.BindHandler("/invalid-status", func(r *ghttp.Request) { + apiresponse.ErrorWithCode(r, 10079, 10079, "invalid username") + }) + + server := httptest.NewServer(s) + defer server.Close() + + tests := []struct { + name string + path string + wantStatus int + wantCode int + }{ + { + name: "standard HTTP error", + path: "/standard", + wantStatus: http.StatusBadRequest, + wantCode: http.StatusBadRequest, + }, + { + name: "business error uses separate HTTP status", + path: "/business", + wantStatus: http.StatusUnprocessableEntity, + wantCode: 10079, + }, + { + name: "invalid HTTP status falls back to internal error", + path: "/invalid-status", + wantStatus: http.StatusInternalServerError, + wantCode: 10079, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := server.Client().Get(server.URL + tt.path) + if err != nil { + t.Fatalf("GET %s: %v", tt.path, err) + } + defer res.Body.Close() + + if res.StatusCode != tt.wantStatus { + t.Errorf("status = %d, want %d", res.StatusCode, tt.wantStatus) + } + + var body struct { + Code int `json:"code"` + } + if err := json.NewDecoder(res.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body.Code != tt.wantCode { + t.Errorf("code = %d, want %d", body.Code, tt.wantCode) + } + }) + } +} diff --git a/internal/service/admin.go b/internal/service/admin.go index 12fc491..33fcf9f 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -119,6 +119,9 @@ type ( GetProviderDefaultURLs(ctx context.Context, _ *v1.ProviderDefaultURLReq) (*v1.ProviderDefaultURLRes, error) // GetChannelHealthTrend 获取渠道健康趋势数据 GetChannelHealthTrend(ctx context.Context, req *v1.ChannelHealthTrendReq) (*v1.ChannelHealthTrendRes, error) + // ResetChannelHealth 重置渠道健康度(渠道可能已修复、重新可用):落库健康分 80(展示层), + // 并复位熔断 + 成功率 EWMA(调度层),使渠道立即恢复被调度选择的能力。 + ResetChannelHealth(ctx context.Context, req *v1.ChannelResetHealthReq) (*v1.ChannelResetHealthRes, error) // ExportChannels exports channel list to CSV or Excel. ExportChannels(ctx context.Context, req *v1.ChannelExportReq) (*v1.ChannelExportRes, error) // ChannelErrorEventList 渠道错误事件列表 @@ -160,14 +163,37 @@ type ( GetAllBillingRecords(ctx context.Context, req *v1.AdminBillingRecordListReq) (*v1.AdminBillingRecordListRes, error) // GetTenantWallets 获取所有租户钱包(管理后台) GetTenantWallets(ctx context.Context, req *v1.AdminWalletListReq) (*v1.AdminWalletListRes, error) - // AdjustBalance 调整租户余额(管理后台) + // AdjustBalance 调整租户余额(管理后台)。 + // Redis 权威化架构:余额变动以 Redis Lua 为资金提交点(扣减在权威可用余额上原子判断, + // 穿透冻结会破坏预扣一致性),DB 侧仅记流水;流水失败时逆转 Redis 已发生的变动。 AdjustBalance(ctx context.Context, req *v1.AdminWalletAdjustReq) (*v1.AdminWalletAdjustRes, error) + // OfflineRecharge 线下充值入账(管理后台) + // 场景:用户线下银行转账(人民币 CNY),运营确认到账后按平台汇率换算为 USD 入账。 + // 与 AdjustBalance 的区别:走正规充值链路——余额与累计充值同步累加、触发等级检查、 + // 流水类型为 recharge,并在描述中携带 CNY 快照(原始人民币 + 汇率 + 入账 USD + 转账流水号), + // 供现金对账与开票追溯。 + OfflineRecharge(ctx context.Context, req *v1.AdminWalletOfflineRechargeReq) (*v1.AdminWalletOfflineRechargeRes, error) // GetWalletInfo 获取租户钱包信息(管理后台) GetWalletInfo(ctx context.Context, req *v1.AdminWalletInfoReq) (*v1.AdminWalletInfoRes, error) // GetWalletTransactions 获取租户钱包交易流水(管理后台) GetWalletTransactions(ctx context.Context, req *v1.AdminWalletTransactionListReq) (*v1.AdminWalletTransactionListRes, error) // SetWarningThreshold 设置租户钱包预警阈值(管理后台) SetWarningThreshold(ctx context.Context, req *v1.AdminWalletSetWarningThresholdReq) (*v1.AdminWalletSetWarningThresholdRes, error) + // GetWalletFrozenItems 获取租户钱包冻结明细(管理后台)。 + // 数据源为 Redis 预扣明细(权威),DB 预扣追踪表已废弃。 + GetWalletFrozenItems(ctx context.Context, req *v1.AdminWalletFrozenItemListReq) (*v1.AdminWalletFrozenItemListRes, error) + // ReleaseWalletFrozenItem 按笔释放冻结(管理后台运维逃生舱)。 + // 释放走 billing.UnfreezePreDeduct 的 Redis 认领路径:认领即删预扣 hash、幂等、逐笔精确, + // 绝不直接改写钱包 hash 的 frozen_balance 汇总值。 + ReleaseWalletFrozenItem(ctx context.Context, req *v1.AdminWalletFrozenReleaseReq) (*v1.AdminWalletFrozenReleaseRes, error) + // ReleaseAllWalletFrozenItems 一键释放租户全部冻结(管理后台运维逃生舱)。 + // 遍历 Redis 活跃冻结明细,复用 frozenItemGuard 护栏逐项筛选: + // - 任务关联在途/待结算项自动跳过(由任务结算流程负责,禁止手动释放); + // - 保护期内(冻结不足 frozenReleaseMinAge)项默认跳过,Force=true 时才释放; + // - 其余项逐个 UnfreezePreDeduct(Lua 认领即删,幂等,与并发结算竞争安全)。 + // + // 返回实际释放笔数/金额与跳过原因摘要(去重)。 + ReleaseAllWalletFrozenItems(ctx context.Context, req *v1.AdminWalletFrozenReleaseAllReq) (*v1.AdminWalletFrozenReleaseAllRes, error) // GetAllTransactions 获取所有租户交易流水(管理后台) GetAllTransactions(ctx context.Context, req *v1.AdminTransactionListReq) (*v1.AdminTransactionListRes, error) // GetDashboardChannelHealth 获取渠道健康概览(最不健康的5个活跃渠道) diff --git a/manifest/config/config.example.yaml b/manifest/config/config.example.yaml index 61ebaf8..42923b8 100644 --- a/manifest/config/config.example.yaml +++ b/manifest/config/config.example.yaml @@ -54,6 +54,15 @@ cache: default: adapter: "redis" +# Billing +# 钱包物化间隔(毫秒,sys_options 键 billing_wallet_materialize_interval_ms,可选): +# Redis 权威钱包状态刷回 DB bil_wallets 的周期。Redis 权威化架构下该值决定 DB 余额展示 +# 的滞后上限;过小增加 DB 写频,过大展示滞后明显。默认 5000(未配置即用默认值)。 +# billing_wallet_materialize_interval_ms: 5000 + +# 预扣孤儿清扫说明:由 cron `prededuct_sweep`(每 2 分钟)基于 Redis 预扣明细重算 +# frozen_balance 自愈,不再依赖 DB 预扣追踪表(bil_prededuct_tracks 已废弃)。 + # JWT Authentication jwt: secret: "" diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go index 9d7bbfc..3b47fbb 100644 --- a/relay/channel/ali/adaptor.go +++ b/relay/channel/ali/adaptor.go @@ -28,7 +28,7 @@ func (a *Adaptor) Init(info *common.RelayInfo) { // GetRequestURL 构建上游请求 URL。 // Claude 协议走 Anthropic 兼容端点,OpenAI 走 compatible-mode 端点,图像生成使用独立端点。 func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { - baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") + baseURL := normalizeBaseURL(info.ChannelMeta.BaseURL) switch constant.RelayMode(info.RelayMode) { case constant.RelayModeClaudeMessages: @@ -161,6 +161,30 @@ func (a *Adaptor) GetChannelName() string { var _ common.Adaptor = (*Adaptor)(nil) +// normalizeBaseURL 归一化渠道 base URL 为 DashScope 裸域名。 +// +// adaptor 各端点自行拼接完整路径(compatible-mode / apps/anthropic / api/v1/services), +// 因此 base 必须是裸域名。但用户手填时有三种常见写法都很自然: +// +// https://dashscope.aliyuncs.com +// https://dashscope.aliyuncs.com/compatible-mode (旧版默认值、控制台文档) +// https://dashscope.aliyuncs.com/compatible-mode/v1 (阿里 OpenAI SDK 文档的 base_url) +// +// 后两种若直接拼接会产生 /compatible-mode/compatible-mode/v1/... 这类重复路径, +// DashScope 网关在路由层直接返回 404 且响应体为空,极难定位。 +// 这里统一剥离已知后缀,让三种写法都能正常工作。 +func normalizeBaseURL(raw string) string { + base := strings.TrimSuffix(strings.TrimSpace(raw), "/") + // 从长到短依次剥离,避免先匹配短后缀导致残留 + for _, suffix := range []string{"/compatible-mode/v1", "/compatible-mode"} { + if strings.HasSuffix(base, suffix) { + base = strings.TrimSuffix(base, suffix) + break + } + } + return strings.TrimSuffix(base, "/") +} + // convertClaudeRequest 处理 Claude 入站请求的模型映射。 func convertClaudeRequest(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { if !info.ChannelMeta.IsModelMapped { diff --git a/relay/channel/ali/adaptor_test.go b/relay/channel/ali/adaptor_test.go new file mode 100644 index 0000000..16815b5 --- /dev/null +++ b/relay/channel/ali/adaptor_test.go @@ -0,0 +1,49 @@ +package ali + +import "testing" + +// TestNormalizeBaseURL 验证三种常见 base URL 写法都归一化为同一个裸域名, +// 防止再次出现 /compatible-mode/compatible-mode/v1/... 重复路径导致上游 404。 +func TestNormalizeBaseURL(t *testing.T) { + const want = "https://dashscope.aliyuncs.com" + + cases := []struct { + name string + in string + want string + }{ + {"裸域名", "https://dashscope.aliyuncs.com", want}, + {"裸域名带尾斜杠", "https://dashscope.aliyuncs.com/", want}, + {"带 compatible-mode", "https://dashscope.aliyuncs.com/compatible-mode", want}, + {"带 compatible-mode 尾斜杠", "https://dashscope.aliyuncs.com/compatible-mode/", want}, + {"带 compatible-mode/v1(阿里 SDK 文档写法)", "https://dashscope.aliyuncs.com/compatible-mode/v1", want}, + {"带 compatible-mode/v1 尾斜杠", "https://dashscope.aliyuncs.com/compatible-mode/v1/", want}, + {"首尾空格", " https://dashscope.aliyuncs.com/compatible-mode/v1 ", want}, + {"自定义代理域名保持不变", "https://proxy.example.com/dashscope", "https://proxy.example.com/dashscope"}, + {"自定义代理域名剥离 compatible-mode", "https://proxy.example.com/compatible-mode/v1", "https://proxy.example.com"}, + {"空串", "", ""}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := normalizeBaseURL(c.in); got != c.want { + t.Errorf("normalizeBaseURL(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +// TestNormalizeBaseURLIdempotent 归一化必须幂等,重复调用不改变结果。 +func TestNormalizeBaseURLIdempotent(t *testing.T) { + inputs := []string{ + "https://dashscope.aliyuncs.com/compatible-mode/v1", + "https://dashscope.aliyuncs.com/compatible-mode", + "https://dashscope.aliyuncs.com", + } + for _, in := range inputs { + once := normalizeBaseURL(in) + if twice := normalizeBaseURL(once); twice != once { + t.Errorf("非幂等: %q -> %q -> %q", in, once, twice) + } + } +} diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index 97c475e..26c2cff 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -308,7 +308,8 @@ func (a *Adaptor) handleChatNonStreamResponse(ctx context.Context, resp *http.Re upstreamErr.ResponseWritten = true return &common.Usage{}, upstreamErr } - return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header)) + // 响应体不是标准 OpenAI 错误格式(含空响应体):附带上游 URL 供日志定位 + return nil, constant.NewUpstreamErrorFromResponse(resp, body) } if info.ChannelMeta.IsModelMapped { @@ -347,7 +348,8 @@ func (a *Adaptor) handleChatStreamResponse(ctx context.Context, resp *http.Respo upstreamErr.ResponseWritten = true return &common.Usage{}, upstreamErr } - return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header)) + // 响应体不是标准 OpenAI 错误格式(含空响应体):附带上游 URL 供日志定位 + return nil, constant.NewUpstreamErrorFromResponse(resp, body) } return StreamHandler(ctx, resp, info, writer) diff --git a/relay/common/provider.go b/relay/common/provider.go index d539931..0b5b463 100644 --- a/relay/common/provider.go +++ b/relay/common/provider.go @@ -21,6 +21,9 @@ var ErrTenantModelNotEnabled = errors.New("model not enabled for this tenant") // ErrMemberModelNotAllowed 成员无权使用该模型 var ErrMemberModelNotAllowed = errors.New("model not allowed for this member") +// ErrApiKeyModelNotAllowed API Key 无权使用该模型 +var ErrApiKeyModelNotAllowed = errors.New("model not allowed for this API key") + // DeprecationInfo 模型弃用信息 type DeprecationInfo struct { Deprecated bool @@ -60,7 +63,8 @@ type DataProvider interface { // GetModelDetail 获取单个模型的详细信息。 // tenantID 用于权限校验(检查租户是否有权使用该模型)。 - GetModelDetail(ctx context.Context, tenantID int64, modelName string) (*ModelDetail, error) + // apiKeyID / userID 大于 0 时进一步校验 API Key 与成员的模型范围(与 GetAvailableModels 口径一致)。 + GetModelDetail(ctx context.Context, tenantID, apiKeyID, userID int64, modelName string) (*ModelDetail, error) // CheckTenantModelAccess 检查租户是否有权使用指定模型。 // 返回是否启用和渠道范围(nil/空表示不限渠道)。 @@ -84,6 +88,9 @@ type DataProvider interface { // InvalidateMemberModelCache 清除指定成员的模型范围缓存。 InvalidateMemberModelCache(ctx context.Context, tenantID, userID int64) + // InvalidateApiKeyModelCache 清除指定 API Key 的模型权限缓存。 + InvalidateApiKeyModelCache(ctx context.Context, apiKeyID int64) + // MaterializeSelection 由新调度引擎的决策构造 ChannelSelection: // 从目录快照取转发元数据,按 keyID 解密渠道 Key(keyID=0 时取渠道首个 active Key)。 MaterializeSelection(ctx context.Context, channelID, keyID int64, modelName string) (*ChannelSelection, error) diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 1048169..d118eac 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -23,7 +23,10 @@ type ChannelMeta struct { } const ( - DefaultTimeoutSeconds = 60 + // DefaultTimeoutSeconds 非流式请求的默认总超时(http.Client.Timeout)。 + // 推理模型(GLM-4.5/4.6 thinking、Claude thinking 等)非流式调用需跑完整段生成才回响应头, + // 60s 会误杀;放宽到 180s。个别仍超时的模型建议在渠道级 settings.timeout_seconds 配 >180(自动切 longRun 传输层)。 + DefaultTimeoutSeconds = 180 ImagesGenerationTimeoutSecs = 600 ) diff --git a/relay/constant/errors.go b/relay/constant/errors.go index c33ae93..58b1095 100644 --- a/relay/constant/errors.go +++ b/relay/constant/errors.go @@ -30,21 +30,32 @@ type RelayError struct { // RetryAfter 上游 Retry-After 头解析结果(0 = 未携带)。 // 调度重试 FSM 据此决定 429 时「原地等待重试」还是立即 failover。 RetryAfter time.Duration + // Detail 服务端诊断上下文(如上游请求 URL),仅出现在 Error() 输出即日志中。 + // 客户端响应只取 Message 字段(见 WriteRelayError),故此字段不会泄露上游地址。 + Detail string } func (e *RelayError) Error() string { - if e.Cause != nil { - return e.Message + ": " + e.Cause.Error() + msg := e.Message + if e.Detail != "" { + msg += " [" + e.Detail + "]" } - return e.Message + if e.Cause != nil { + return msg + ": " + e.Cause.Error() + } + return msg } func (e *RelayError) Unwrap() error { return e.Cause } -// NewUpstreamError 创建上游错误 +// NewUpstreamError 创建上游错误。 +// message 为空时(上游返回空响应体)用状态码兜底,避免日志里出现 error=[] 这类无信息量的记录。 func NewUpstreamError(statusCode int, message string, cause error) *RelayError { + if strings.TrimSpace(message) == "" && cause == nil { + message = "upstream returned HTTP " + strconv.Itoa(statusCode) + " with empty response body" + } return &RelayError{ StatusCode: statusCode, Message: message, @@ -53,6 +64,17 @@ func NewUpstreamError(statusCode int, message string, cause error) *RelayError { } } +// NewUpstreamErrorFromResponse 由上游响应构造错误,并把请求 URL 记入 Detail 供日志定位。 +// 空响应体的 4xx 通常是 URL 路径拼错(被上游网关在路由层拒绝),此时 URL 是唯一有效线索。 +func NewUpstreamErrorFromResponse(resp *http.Response, body []byte) *RelayError { + e := NewUpstreamError(resp.StatusCode, string(body), nil). + WithRetryAfter(RetryAfterFromHeader(resp.Header)) + if resp.Request != nil && resp.Request.URL != nil { + e.Detail = "upstream_url=" + resp.Request.URL.String() + } + return e +} + // WithRetryAfter 附着上游 Retry-After 解析结果(链式调用,d<=0 时不生效)。 func (e *RelayError) WithRetryAfter(d time.Duration) *RelayError { if e != nil && d > 0 { diff --git a/relay/handler/chat_handler.go b/relay/handler/chat_handler.go index 43041cc..cc344db 100644 --- a/relay/handler/chat_handler.go +++ b/relay/handler/chat_handler.go @@ -73,8 +73,9 @@ func HandleModels(ctx context.Context, tenantID, apiKeyID, userID int64, provide } // HandleModelDetail 处理 /v1/models/{model_id} 请求(获取单个模型详情) -func HandleModelDetail(ctx context.Context, tenantID int64, modelName string, provider common.DataProvider) (*dto.ModelDetailResponse, error) { - detail, err := provider.GetModelDetail(ctx, tenantID, modelName) +// apiKeyID / userID 用于进一步按 API Key / 成员的模型范围过滤 +func HandleModelDetail(ctx context.Context, tenantID, apiKeyID, userID int64, modelName string, provider common.DataProvider) (*dto.ModelDetailResponse, error) { + detail, err := provider.GetModelDetail(ctx, tenantID, apiKeyID, userID, modelName) if err != nil { return nil, err } diff --git a/relay/handler/gemini_handler.go b/relay/handler/gemini_handler.go index ce80c79..898296a 100644 --- a/relay/handler/gemini_handler.go +++ b/relay/handler/gemini_handler.go @@ -43,8 +43,9 @@ func HandleGeminiModels(ctx context.Context, tenantID, apiKeyID, userID int64, p } // HandleGeminiModelDetail 处理 GET /v1beta/models/{model} 请求(Gemini 原生格式模型详情) -func HandleGeminiModelDetail(ctx context.Context, tenantID int64, modelName string, provider common.DataProvider) (*dto.GeminiModel, error) { - detail, err := provider.GetModelDetail(ctx, tenantID, modelName) +// apiKeyID / userID 用于进一步按 API Key / 成员的模型范围过滤 +func HandleGeminiModelDetail(ctx context.Context, tenantID, apiKeyID, userID int64, modelName string, provider common.DataProvider) (*dto.GeminiModel, error) { + detail, err := provider.GetModelDetail(ctx, tenantID, apiKeyID, userID, modelName) if err != nil { return nil, err } diff --git a/relay/handler/relay_handler.go b/relay/handler/relay_handler.go index 57d32db..5ff6f4a 100644 --- a/relay/handler/relay_handler.go +++ b/relay/handler/relay_handler.go @@ -67,6 +67,32 @@ type relayValidation struct { sessionSignals dispatch.SessionSignals // 请求体中的会话信号(影子模式/新调度用) } +type modelMappingProvider interface { + GetModelMapping(ctx context.Context, modelName string) (standardName string, category string, err error) +} + +// resolveRelayModel gives a literal catalog model precedence over the optional +// thinking/effort suffix syntax. This avoids treating names such as +// "qwen3.8-max" as the virtual model "qwen3.8" with effort=max. +func resolveRelayModel(ctx context.Context, provider modelMappingProvider, modelName string) (string, *helper.ThinkingInfo, error) { + parsed := helper.ParseThinkingSuffix(modelName) + literal := &helper.ThinkingInfo{BaseModel: modelName} + + _, _, err := provider.GetModelMapping(ctx, modelName) + if err == nil { + return modelName, literal, nil + } + if parsed.BaseModel == modelName { + return "", nil, err + } + + _, _, err = provider.GetModelMapping(ctx, parsed.BaseModel) + if err != nil { + return "", nil, err + } + return parsed.BaseModel, &parsed, nil +} + // validateRelayRequest 校验请求合法性:relay mode、QPS 限流(前置)、模型存在性、弃用状态、成员/API Key 模型范围。 // 纯校验逻辑,无 defer 副作用。 func validateRelayRequest( @@ -134,23 +160,9 @@ func validateRelayRequest( return nil, constant.NewRequestError("model is required", nil) } - // 2.5 解析 thinking/effort 后缀 - parsed := helper.ParseThinkingSuffix(modelName) - thinkingInfo := &parsed - lookupModel := modelName - if thinkingInfo.BaseModel != modelName { - lookupModel = thinkingInfo.BaseModel - } - - // 3. 验证模型存在且活跃 - _, _, modelErr := provider.GetModelMapping(ctx, modelName) - if modelErr != nil && thinkingInfo.BaseModel != modelName { - // 完整模型名查找失败,尝试基础模型名 - _, _, modelErr = provider.GetModelMapping(ctx, thinkingInfo.BaseModel) - if modelErr == nil { - lookupModel = thinkingInfo.BaseModel - } - } + // 2.5 解析 thinking/effort 后缀并验证模型。完整模型名存在时按字面模型处理; + // 仅在完整名称不存在时,才尝试将后缀解析为虚拟 thinking/effort 参数。 + lookupModel, thinkingInfo, modelErr := resolveRelayModel(ctx, provider, modelName) if modelErr != nil { if modelErr == common.ErrModelNotFound { return nil, constant.NewRequestError("model not found: "+modelName, modelErr) @@ -604,7 +616,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He if billing != nil && preDeductAmount > 0 { _ = billing.SettleFailed(settleCtx, rc.TenantID, rc.RequestID, preDeductAmount) } - recordFailedUsage(provider, rc, selection.ChannelID, v.modelName, v.relayMode, v.isStream, err) + recordFailedUsage(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err) recordChannelError(rc, selection, v.modelName, attempt, true, err, info.LatencyMs()) finalizeTrace(trace, rc, hop, false, attempt, selection, err.Error(), info.LatencyMs()) return nil, v.billingResult, helper.RemapStatusCode(constant.NewUpstreamError(502, "upstream request failed", err), info.ChannelMeta.Settings.StatusCodeMapping) @@ -646,7 +658,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He billing.IncrApiKeyQuotaUsed(settleCtx, rc.ApiKeyID, settleResult.ActualCost) } } - recordFailedUsage(provider, rc, selection.ChannelID, v.modelName, v.relayMode, v.isStream, err) + recordFailedUsage(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err) finalizeTrace(trace, rc, hop, false, attempt, selection, err.Error(), info.LatencyMs()) return usage, v.billingResult, err } @@ -692,7 +704,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He if billing != nil && preDeductAmount > 0 { _ = billing.SettleFailed(settleCtx, rc.TenantID, rc.RequestID, preDeductAmount) } - recordFailedUsage(provider, rc, selection.ChannelID, v.modelName, v.relayMode, v.isStream, err) + recordFailedUsage(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err) recordChannelError(rc, selection, v.modelName, attempt, true, err, info.LatencyMs()) finalizeTrace(trace, rc, hop, false, attempt, selection, err.Error(), info.LatencyMs()) return nil, v.billingResult, err @@ -845,7 +857,7 @@ func handleChannelUnavailable( fmt.Sprintf("all %d channels failed for model: %s", len(channelErrors), v.modelName), constant.ErrAllChannelsFailed, ) - recordFailedUsage(provider, rc, 0, v.modelName, v.relayMode, v.isStream, allFailedErr) + recordFailedUsage(provider, rc, nil, v.modelName, v.relayMode, v.isStream, allFailedErr) return &channelUnavailableResult{nil, v.billingResult, allFailedErr} } @@ -857,7 +869,9 @@ func handleChannelUnavailable( g.Log().Stack(false).Warningf(ctx, "[RelayHandler] 无可用渠道: model=%s tenant=%d user=%d request=%s%s", v.modelName, rc.TenantID, rc.UserID, rc.RequestID, diagSuffix) noChErr := constant.NewChannelError("no available channel for model: "+v.modelName, err) - recordFailedUsage(provider, rc, 0, v.modelName, v.relayMode, v.isStream, noChErr) + // 无可用渠道时请求未转发到任何上游(与 QPS 限流、权限/额度拒绝等前置拦截同理), + // 不记用量日志,避免零消耗的 error 记录污染请求日志;运营侧的“被拒请求量”已由 + // 上方 Warning 日志、调度器排除明细指标、chn_error_events 覆盖。 return &channelUnavailableResult{nil, v.billingResult, noChErr} } @@ -917,23 +931,31 @@ func (e *RelayErrorWithRateLimit) Error() string { return e.Message } -// recordFailedUsage 记录失败用量 -func recordFailedUsage(provider common.DataProvider, rc *RelayContext, channelID int64, modelName string, relayMode constant.RelayMode, isStream bool, err error) { - provider.RecordUsage(context.Background(), &common.UsageRecord{ - TenantID: rc.TenantID, - UserID: rc.UserID, - ApiKeyID: rc.ApiKeyID, - ProjectID: rc.ProjectID, - ChannelID: channelID, - ModelName: modelName, - RelayMode: int(relayMode), - LatencyMs: 0, - IsStream: isStream, - Success: false, - RequestID: rc.RequestID, - Status: "error", - ErrorMessage: err.Error(), - }) +// recordFailedUsage 记录失败用量。 +// selection 非 nil 时记录具体失败渠道(ID/名称/类型/上游模型),便于在用量日志定位失败渠道; +// nil 表示无单一渠道(全部渠道失败),渠道字段留空,失败详情见 error_message。 +func recordFailedUsage(provider common.DataProvider, rc *RelayContext, selection *common.ChannelSelection, modelName string, relayMode constant.RelayMode, isStream bool, err error) { + record := &common.UsageRecord{ + TenantID: rc.TenantID, + UserID: rc.UserID, + ApiKeyID: rc.ApiKeyID, + ProjectID: rc.ProjectID, + ModelName: modelName, + RequestedModel: modelName, + RelayMode: int(relayMode), + IsStream: isStream, + Success: false, + RequestID: rc.RequestID, + Status: "error", + ErrorMessage: err.Error(), + } + if selection != nil { + record.ChannelID = selection.ChannelID + record.ChannelName = selection.ChannelName + record.ChannelType = selection.ChannelType + record.UpstreamModel = selection.UpstreamModelName + } + provider.RecordUsage(context.Background(), record) } // recordChannelError 记录渠道错误事件到 chn_error_events(异步,不阻塞请求) diff --git a/relay/handler/relay_handler_test.go b/relay/handler/relay_handler_test.go new file mode 100644 index 0000000..5d427c4 --- /dev/null +++ b/relay/handler/relay_handler_test.go @@ -0,0 +1,96 @@ +package handler + +import ( + "context" + "testing" + + "github.com/qianfree/team-api/relay/common" +) + +type modelMappingStub struct { + models map[string]bool + calls []string +} + +func (s *modelMappingStub) GetModelMapping(_ context.Context, modelName string) (string, string, error) { + s.calls = append(s.calls, modelName) + if !s.models[modelName] { + return "", "", common.ErrModelNotFound + } + return modelName, "chat", nil +} + +func TestResolveRelayModel(t *testing.T) { + tests := []struct { + name string + model string + models map[string]bool + wantLookup string + wantEffort string + wantCalls []string + wantErr bool + }{ + { + name: "literal model ending in effort suffix wins", + model: "qwen3.8-max", + models: map[string]bool{"qwen3.8-max": true, "qwen3.8": true}, + wantLookup: "qwen3.8-max", + wantCalls: []string{"qwen3.8-max"}, + }, + { + name: "virtual effort suffix falls back to base model", + model: "o3-max", + models: map[string]bool{"o3": true}, + wantLookup: "o3", + wantEffort: "max", + wantCalls: []string{"o3-max", "o3"}, + }, + { + name: "model without suffix resolves literally", + model: "gpt-4o", + models: map[string]bool{"gpt-4o": true}, + wantLookup: "gpt-4o", + wantCalls: []string{"gpt-4o"}, + }, + { + name: "missing literal and base model returns error", + model: "missing-high", + models: map[string]bool{}, + wantCalls: []string{"missing-high", "missing"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := &modelMappingStub{models: tt.models} + lookup, thinking, err := resolveRelayModel(t.Context(), provider, tt.model) + if (err != nil) != tt.wantErr { + t.Fatalf("resolveRelayModel() error = %v, wantErr %v", err, tt.wantErr) + } + if lookup != tt.wantLookup { + t.Errorf("lookup model = %q, want %q", lookup, tt.wantLookup) + } + if !tt.wantErr { + if thinking == nil { + t.Fatal("thinking info is nil") + } + if thinking.BaseModel != tt.wantLookup { + t.Errorf("thinking base model = %q, want %q", thinking.BaseModel, tt.wantLookup) + } + if thinking.EffortLevel != tt.wantEffort { + t.Errorf("effort level = %q, want %q", thinking.EffortLevel, tt.wantEffort) + } + } + if len(provider.calls) != len(tt.wantCalls) { + t.Fatalf("lookup calls = %v, want %v", provider.calls, tt.wantCalls) + } + for i := range tt.wantCalls { + if provider.calls[i] != tt.wantCalls[i] { + t.Errorf("lookup calls = %v, want %v", provider.calls, tt.wantCalls) + break + } + } + }) + } +} diff --git a/web/admin/src/styles/main.css b/web/admin/src/styles/main.css index 2533df7..56a536f 100644 --- a/web/admin/src/styles/main.css +++ b/web/admin/src/styles/main.css @@ -449,6 +449,16 @@ body { border-top: 1px solid var(--ta-border-light); } +/* 统计总数从表格左上角移到底部后:footer 内存在统计栏时两端对齐(统计居左、分页居右), + 无统计栏的 footer 保持右对齐不受影响(:has 特异性高于 scoped .table-footer,可统一覆盖各页脚注) */ +.page-table .table-footer:has(.table-stats) { + justify-content: space-between; +} +/* 统计栏移入底部后,去掉全局样式的下边距,与分页栏垂直居中 */ +.page-table .table-footer .table-stats { + margin-bottom: 0; +} + /* Tab 内嵌表格:让 Tab 容器与面板也参与纵向 flex,保证内部表格能撑满滚动 */ .page-table .arco-tabs { flex: 1; diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue index f056d08..6371369 100644 --- a/web/admin/src/views/AdminLayout.vue +++ b/web/admin/src/views/AdminLayout.vue @@ -332,6 +332,8 @@ const menuGroups = [ { name: 'AdminTickets', label: '工单管理', icon: IconCommand }, { name: 'AdminFeedback', label: '反馈管理', icon: IconMessage }, { name: 'AdminAgreements', label: '用户协议', icon: IconFile }, + { name: 'AdminHelpCategories', label: '帮助分类', icon: IconLayers }, + { name: 'AdminHelpArticles', label: '帮助文章', icon: IconFile }, ], }, { @@ -360,9 +362,6 @@ const menuGroups = [ { name: 'AdminSettings', label: '系统设置', icon: IconSettings }, { name: 'AdminFiles', label: '文件管理', icon: IconStorage }, { name: 'AdminPaymentSettings', label: '支付设置', icon: IconStorage }, - { name: 'AdminHelpCategories', label: '帮助分类', icon: IconLayers }, - { name: 'AdminHelpArticles', label: '帮助文章', icon: IconFile }, - { name: 'AdminChangelogs', label: '更新日志', icon: IconFile }, ], }, ] diff --git a/web/admin/src/views/ai/ChannelDetailPage.vue b/web/admin/src/views/ai/ChannelDetailPage.vue index 75072f0..f1a528d 100644 --- a/web/admin/src/views/ai/ChannelDetailPage.vue +++ b/web/admin/src/views/ai/ChannelDetailPage.vue @@ -19,6 +19,8 @@ const activeTab = ref('info') const statusTagColor: Record = { active: 'green', disabled: 'orangered', testing: 'arcoblue' } const statusLabel: Record = { active: '启用', disabled: '禁用', testing: '测试中' } +const breakerTagColor: Record = { 0: 'green', 1: 'red', 2: 'orange' } +const breakerLabel: Record = { 0: '正常', 1: '熔断', 2: '半开' } const tierLabel: Record = { primary: '首选', secondary: '备用', reserve: '保底' } const tierTagColor: Record = { primary: 'arcoblue', secondary: 'orange', reserve: 'gray' } const tierOptions = [ @@ -138,7 +140,8 @@ const abilityColumns: TableColumnData[] = [ modelValue: record.upstream_model || '', size: 'mini', placeholder: '留空则同名', - onChange: (v: string) => handleUpstreamModelChange(record, v), + 'onUpdate:modelValue': (v: string) => handleUpstreamModelChange(record, v), + onChange: () => scheduleAbilitySave('上游模型名已更新'), }) }, }, @@ -206,8 +209,8 @@ async function handleToggleAbility(ab: any) { } catch { /* error handled by interceptor */ } } -// 能力表内联编辑(成本比例 / 上游模型名):任意字段变更后统一 600ms 防抖整表提交, -// 连续改多字段只发一次请求。提交体 abilityPayload 已包含 upstream_model。 +// 能力表内联编辑:成本比例输入过程中 600ms 防抖整表提交; +// 上游模型名仅失焦/回车时提交(避免逐字保存)。提交体 abilityPayload 已包含 upstream_model。 let abilitySaveTimer: any = null function scheduleAbilitySave(successMsg: string) { clearTimeout(abilitySaveTimer) @@ -227,7 +230,6 @@ function handleCostRatioChange(record: any, value: number) { function handleUpstreamModelChange(record: any, value: string) { record.upstream_model = value - scheduleAbilitySave('上游模型名已更新') } async function handleDeleteAbility(id: number) { @@ -530,6 +532,18 @@ function formatHeaders(headers: Record): string { N/A + + + + — + VIP - diff --git a/web/admin/src/views/ai/ChannelsPage.vue b/web/admin/src/views/ai/ChannelsPage.vue index 4fa6ed1..8bf5ceb 100644 --- a/web/admin/src/views/ai/ChannelsPage.vue +++ b/web/admin/src/views/ai/ChannelsPage.vue @@ -1,7 +1,7 @@ @@ -353,8 +501,11 @@ const { exporting, exportFile } = useExport({ + + + 搜索 @@ -362,9 +513,9 @@ const { exporting, exportFile } = useExport({ - @@ -522,11 +673,17 @@ const { exporting, exportFile } = useExport({ diff --git a/web/tenant/CLAUDE.md b/web/tenant/CLAUDE.md index 5112d83..75df2f6 100644 --- a/web/tenant/CLAUDE.md +++ b/web/tenant/CLAUDE.md @@ -113,7 +113,10 @@ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; |------|------|------| | 小元素(Badge、小按钮) | `rounded-lg` / `rounded-full` | 8px / 全圆 | | 中元素(按钮、输入框、Tab) | `rounded-xl` | 12px | -| 大元素(卡片、模态框、面板) | `rounded-2xl` | 16px | +| 紧凑面板(表格、筛选栏、模态框) | `rounded-2xl` | 16px | +| 普通卡片 | `rounded-[20px]` | 20px | +| 重点卡片(登录、主图表) | `rounded-3xl` | 24px | +| 大型容器(侧栏) | `rounded-[28px]` | 28px | --- @@ -569,4 +572,4 @@ Logo(16x16 圆角方形,shadow-glow) - Axios 响应拦截器以 `response.data.code` 为唯一判断依据:`code === 0` 为成功,否则为失败 - 拦截器统一处理错误提示(读取 `response.data.message`),业务代码中不再逐个 catch 显示错误 - 所有 API 调用必须使用 `try/catch` 包裹 -- 禁止在 API 调用的 try 块中直接显示成功提示(`Message.success`),必须确认请求真正成功后再显示 \ No newline at end of file +- 禁止在 API 调用的 try 块中直接显示成功提示(`Message.success`),必须确认请求真正成功后再显示 diff --git a/web/tenant/DESIGN_SYSTEM.md b/web/tenant/DESIGN_SYSTEM.md index 92d1f4b..4c64206 100644 --- a/web/tenant/DESIGN_SYSTEM.md +++ b/web/tenant/DESIGN_SYSTEM.md @@ -148,7 +148,8 @@ radial-gradient(at 45% 100%, rgba(99, 102, 241, 0.035) 0, transparent 42%) 普通内容卡片使用较高的不透明度: - 背景:`rgba(255, 255, 255, 0.84)`。 -- 圆角:`24px`。 +- 普通卡片圆角:`20px`。 +- 登录、主图表等重点卡片圆角:`24px`。 - 描边:`rgba(255, 255, 255, 0.82)`。 - 阴影:`0 10px 34px rgba(91, 104, 151, 0.08)`。 - 内高光:`inset 0 1px 0 rgba(255, 255, 255, 0.9)`。 @@ -193,8 +194,9 @@ backdrop-filter: blur(32px) saturate(1.35); | 小 | `8px` | 标签、紧凑按钮、序号 | | 中 | `12px` | 输入框、小图标容器 | | 常规 | `15px–16px` | 菜单项、普通按钮 | -| 大 | `18px` | 快捷操作、内部面板 | -| 卡片 | `24px` | 数据卡片、图表卡片 | +| 面板 | `16px` | 表格、筛选栏、紧凑内部面板 | +| 卡片 | `20px` | 普通内容卡片、数据卡片 | +| 重点卡片 | `24px` | 登录卡片、主图表和大型展示卡片 | | 容器 | `28px` | 主侧栏、大型玻璃容器 | | 胶囊 | `9999px` | 搜索框、状态标签、圆形操作区 | @@ -366,7 +368,7 @@ ease-out ### 15.1 表格容器 -- 圆角:`20px`。 +- 圆角:`16px`。 - 背景:半透明冷白玻璃,叠加极淡的冰青和青绿环境光。 - 模糊:`blur(22px) saturate(1.2)`。 - 描边:半透明白色,不使用传统灰色实线外框。 diff --git a/web/tenant/index.html b/web/tenant/index.html index bf202ff..65016ae 100644 --- a/web/tenant/index.html +++ b/web/tenant/index.html @@ -10,7 +10,7 @@ - + diff --git a/web/tenant/src/components/charts/ModelDistChart.vue b/web/tenant/src/components/charts/ModelDistChart.vue index f4b88e2..5b03a30 100644 --- a/web/tenant/src/components/charts/ModelDistChart.vue +++ b/web/tenant/src/components/charts/ModelDistChart.vue @@ -81,7 +81,7 @@ const modelList = computed(() =>