{{ siteName }}
+{{ publicSettings.site_description || '开源自托管的多租户 大模型 API 网关平台' }}
+{{ siteName }}
+ +{{ publicSettings.site_description || '开源自托管的多租户 大模型 API 网关平台' }}
-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 @@
+
+
+
企业级大模型 API 网关
++ 统一接入 OpenAI / Claude / Gemini 等主流大模型,为多租户提供 API Key 管理与计费能力 +
+ + + + + ++ 开发环境请通过 Vite 访问 + 管理后台 :4001 + · + 租户控制台 :4002 +
+ ++ Team-API v__VERSION__ +
+请立即复制密钥,关闭后将无法再次查看
+请复制并妥善保存密钥,之后仍可在密钥列表中重新复制
{{ publicSettings.site_description || '开源自托管的多租户 大模型 API 网关平台' }}
+{{ publicSettings.site_description || '开源自托管的多租户 大模型 API 网关平台' }}
-