commit
f25ec66092
137 files changed
+4925
-1962
No files matched your search
@@ -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 返回值(账本链连续可信)。
|
||||
|
||||
### 系统货币规则
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+25
-6
@@ -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{}
|
||||
@@ -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{}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 导出订单列表请求
|
||||
|
||||
@@ -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{}
|
||||
+21
-6
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 熔断探测令牌
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,前端据此展示「未开启审计」
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 计费 + 快照)。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
+394
-368
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 代码(如提示充值后不支持退款、指定渠道折扣活动等)。纯文本换行需用 <br>,富文本请直接粘贴 HTML"},
|
||||
|
||||
// ── Performance ──
|
||||
{Key: "global_qps_limit", Type: SettingTypeInt, Default: "10000", Category: "performance",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ")
|
||||
|
||||
@@ -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)
|
||||
|
||||
+127
-62
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 套餐管理 ──
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Team-API · 企业级大模型 API 网关</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20500%20500%22%20role%3D%22img%22%20aria-label%3D%22Three-color%20orbit%20logo%22%20shape-rendering%3D%22geometricPrecision%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22purple-arm%22%20x1%3D%22315%22%20y1%3D%2277%22%20x2%3D%22201%22%20y2%3D%22279%22%20gradientUnits%3D%22userSpaceOnUse%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%23f000da%22%2F%3E%3Cstop%20offset%3D%220.48%22%20stop-color%3D%22%23b800d1%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%236d00a9%22%2F%3E%3C%2FlinearGradient%3E%3ClinearGradient%20id%3D%22green-arm%22%20x1%3D%22315%22%20y1%3D%2277%22%20x2%3D%22201%22%20y2%3D%22279%22%20gradientUnits%3D%22userSpaceOnUse%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%2300dc72%22%2F%3E%3Cstop%20offset%3D%220.48%22%20stop-color%3D%22%2300bd69%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%23008e4c%22%2F%3E%3C%2FlinearGradient%3E%3ClinearGradient%20id%3D%22orange-arm%22%20x1%3D%22315%22%20y1%3D%2277%22%20x2%3D%22201%22%20y2%3D%22279%22%20gradientUnits%3D%22userSpaceOnUse%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%23d90016%22%2F%3E%3Cstop%20offset%3D%220.48%22%20stop-color%3D%22%23ff7900%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%23f79a00%22%2F%3E%3C%2FlinearGradient%3E%3CradialGradient%20id%3D%22purple-ball%22%20cx%3D%2234%25%22%20cy%3D%2227%25%22%20r%3D%2272%25%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%23ff25e8%22%2F%3E%3Cstop%20offset%3D%220.52%22%20stop-color%3D%22%23d500d2%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%238700ad%22%2F%3E%3C%2FradialGradient%3E%3CradialGradient%20id%3D%22green-ball%22%20cx%3D%2235%25%22%20cy%3D%2227%25%22%20r%3D%2273%25%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%2322ed91%22%2F%3E%3Cstop%20offset%3D%220.52%22%20stop-color%3D%22%2300bd6b%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%2300834c%22%2F%3E%3C%2FradialGradient%3E%3CradialGradient%20id%3D%22orange-ball%22%20cx%3D%2234%25%22%20cy%3D%2227%25%22%20r%3D%2273%25%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%23ffdc00%22%2F%3E%3Cstop%20offset%3D%220.52%22%20stop-color%3D%22%23ff8b00%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%23d71712%22%2F%3E%3C%2FradialGradient%3E%3CradialGradient%20id%3D%22cyan-ball%22%20cx%3D%2235%25%22%20cy%3D%2228%25%22%20r%3D%2273%25%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%2317efff%22%2F%3E%3Cstop%20offset%3D%220.58%22%20stop-color%3D%22%2308d8ef%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%2319bdd5%22%2F%3E%3C%2FradialGradient%3E%3Cpath%20id%3D%22arm-shape%22%20d%3D%22M219%20286c-38-22-61-59-60-103%201-61%2046-109%20105-112%2020-1%2039%201%2055%206l-5%2021c-16-4-32-6-48-5-47%202-84%2041-85%2090-1%2042%2013%2076%2038%20103z%22%2F%3E%3Ccircle%20id%3D%22endpoint-shape%22%20cx%3D%22331%22%20cy%3D%2282%22%20r%3D%2226.56218506%22%2F%3E%3C%2Fdefs%3E%3Cg%20transform%3D%22translate(250%20244)%20rotate(10.20552699)%20scale(0.94118763)%20translate(-250%20-244)%22%3E%3Cuse%20href%3D%22%23arm-shape%22%20fill%3D%22url(%23purple-arm)%22%2F%3E%3Cuse%20href%3D%22%23endpoint-shape%22%20fill%3D%22url(%23purple-ball)%22%2F%3E%3C%2Fg%3E%3Cg%20transform%3D%22rotate(-120%20250%20244)%20translate(250%20244)%20rotate(10.20552699)%20scale(0.94118763)%20translate(-250%20-244)%22%3E%3Cuse%20href%3D%22%23arm-shape%22%20fill%3D%22url(%23green-arm)%22%2F%3E%3Cuse%20href%3D%22%23endpoint-shape%22%20fill%3D%22url(%23green-ball)%22%2F%3E%3C%2Fg%3E%3Cg%20transform%3D%22rotate(120%20250%20244)%20translate(250%20244)%20rotate(10.20552699)%20scale(0.94118763)%20translate(-250%20-244)%22%3E%3Cuse%20href%3D%22%23arm-shape%22%20fill%3D%22url(%23orange-arm)%22%2F%3E%3Cuse%20href%3D%22%23endpoint-shape%22%20fill%3D%22url(%23orange-ball)%22%2F%3E%3C%2Fg%3E%3Ccircle%20cx%3D%22250%22%20cy%3D%22244%22%20r%3D%2237%22%20fill%3D%22url(%23cyan-ball)%22%2F%3E%3C%2Fsvg%3E">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['DM Sans', 'system-ui', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'monospace'],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { font-family: 'DM Sans', system-ui, sans-serif; }
|
||||
|
||||
.card {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.03),
|
||||
0 2px 4px rgba(0, 0, 0, 0.03),
|
||||
0 12px 24px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.entry-btn {
|
||||
transition: background-color 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.entry-btn:hover { box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3); }
|
||||
.entry-btn:active { transform: translateY(0.5px); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex items-center justify-center p-4" style="background: linear-gradient(160deg, #f0f4f8 0%, #e8edf5 50%, #f5f3ff 100%);">
|
||||
|
||||
<div class="w-full max-w-md">
|
||||
<div class="card bg-white rounded-2xl p-8 sm:p-10">
|
||||
|
||||
<!-- Brand -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-4 border border-gray-100 bg-white" style="box-shadow: 0 4px 12px rgba(0,0,0,0.06);">
|
||||
<svg class="w-9 h-9" viewBox="0 0 500 500" role="img" aria-label="Team-API">
|
||||
<defs>
|
||||
<linearGradient id="purple-arm" x1="315" y1="77" x2="201" y2="279" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#f000da"/>
|
||||
<stop offset="0.48" stop-color="#b800d1"/>
|
||||
<stop offset="1" stop-color="#6d00a9"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="green-arm" x1="315" y1="77" x2="201" y2="279" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#00dc72"/>
|
||||
<stop offset="0.48" stop-color="#00bd69"/>
|
||||
<stop offset="1" stop-color="#008e4c"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="orange-arm" x1="315" y1="77" x2="201" y2="279" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#d90016"/>
|
||||
<stop offset="0.48" stop-color="#ff7900"/>
|
||||
<stop offset="1" stop-color="#f79a00"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="purple-ball" cx="34%" cy="27%" r="72%">
|
||||
<stop offset="0" stop-color="#ff25e8"/>
|
||||
<stop offset="0.52" stop-color="#d500d2"/>
|
||||
<stop offset="1" stop-color="#8700ad"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="green-ball" cx="35%" cy="27%" r="73%">
|
||||
<stop offset="0" stop-color="#22ed91"/>
|
||||
<stop offset="0.52" stop-color="#00bd6b"/>
|
||||
<stop offset="1" stop-color="#00834c"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="orange-ball" cx="34%" cy="27%" r="73%">
|
||||
<stop offset="0" stop-color="#ffdc00"/>
|
||||
<stop offset="0.52" stop-color="#ff8b00"/>
|
||||
<stop offset="1" stop-color="#d71712"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="cyan-ball" cx="35%" cy="28%" r="73%">
|
||||
<stop offset="0" stop-color="#17efff"/>
|
||||
<stop offset="0.58" stop-color="#08d8ef"/>
|
||||
<stop offset="1" stop-color="#19bdd5"/>
|
||||
</radialGradient>
|
||||
<path id="arm-shape" d="M219 286c-38-22-61-59-60-103 1-61 46-109 105-112 20-1 39 1 55 6l-5 21c-16-4-32-6-48-5-47 2-84 41-85 90-1 42 13 76 38 103z"/>
|
||||
<circle id="endpoint-shape" cx="331" cy="82" r="26.56218506"/>
|
||||
</defs>
|
||||
<g transform="translate(250 244) rotate(10.20552699) scale(0.94118763) translate(-250 -244)">
|
||||
<use href="#arm-shape" fill="url(#purple-arm)"/>
|
||||
<use href="#endpoint-shape" fill="url(#purple-ball)"/>
|
||||
</g>
|
||||
<g transform="rotate(-120 250 244) translate(250 244) rotate(10.20552699) scale(0.94118763) translate(-250 -244)">
|
||||
<use href="#arm-shape" fill="url(#green-arm)"/>
|
||||
<use href="#endpoint-shape" fill="url(#green-ball)"/>
|
||||
</g>
|
||||
<g transform="rotate(120 250 244) translate(250 244) rotate(10.20552699) scale(0.94118763) translate(-250 -244)">
|
||||
<use href="#arm-shape" fill="url(#orange-arm)"/>
|
||||
<use href="#endpoint-shape" fill="url(#orange-ball)"/>
|
||||
</g>
|
||||
<circle cx="250" cy="244" r="37" fill="url(#cyan-ball)"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 tracking-tight">Team-API</h1>
|
||||
<p class="text-sm text-gray-500 mt-1.5">企业级大模型 API 网关</p>
|
||||
</div>
|
||||
|
||||
<!-- Intro -->
|
||||
<p class="text-sm text-gray-500 text-center mb-8">
|
||||
统一接入 OpenAI / Claude / Gemini 等主流大模型,为多租户提供 API Key 管理与计费能力
|
||||
</p>
|
||||
|
||||
<!-- Frontend entry -->
|
||||
<div class="space-y-3">
|
||||
<a href="/admin" class="entry-btn flex items-center justify-between w-full bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<span>管理后台</span>
|
||||
<span class="text-xs text-gray-400">平台运营</span>
|
||||
</a>
|
||||
<a href="/" class="entry-btn flex items-center justify-between w-full bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<span>租户控制台</span>
|
||||
<span class="text-xs text-gray-400">组织使用</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Dev hint -->
|
||||
<p class="text-center text-xs text-gray-400 mt-6">
|
||||
开发环境请通过 Vite 访问
|
||||
<a id="dev-admin" href="#" class="text-blue-600 hover:underline">管理后台 :4001</a>
|
||||
<span class="mx-1">·</span>
|
||||
<a id="dev-tenant" href="#" class="text-blue-600 hover:underline">租户控制台 :4002</a>
|
||||
</p>
|
||||
|
||||
<p class="text-center text-xs text-gray-400 mt-4">
|
||||
Team-API v<span id="version">__VERSION__</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// 开发环境前端 Vite 开发服务器地址(管理后台 :4001 / 租户控制台 :4002),按当前 hostname 动态拼接
|
||||
var host = window.location.hostname || 'localhost';
|
||||
var admin = document.getElementById('dev-admin');
|
||||
var tenant = document.getElementById('dev-tenant');
|
||||
if (admin) admin.href = 'http://' + host + ':4001';
|
||||
if (tenant) tenant.href = 'http://' + host + ':4002';
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,3 +4,6 @@ import _ "embed"
|
||||
|
||||
//go:embed setup.html
|
||||
var SetupHTML []byte
|
||||
|
||||
//go:embed landing.html
|
||||
var LandingHTML []byte
|
||||
@@ -2,6 +2,7 @@ package response
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
@@ -128,6 +129,9 @@ func ErrorMsg(r *ghttp.Request, code int, message string) {
|
||||
// ErrorWithCode writes an error with separate HTTP status and business code.
|
||||
// Use this when HTTP status differs from the business code (e.g., HTTP 401 with code 10020).
|
||||
func ErrorWithCode(r *ghttp.Request, httpStatus int, code int, message string) {
|
||||
if httpStatus < 100 || httpStatus > 599 {
|
||||
httpStatus = http.StatusInternalServerError
|
||||
}
|
||||
requestID := getRequestID(r)
|
||||
r.Response.WriteHeader(httpStatus)
|
||||
r.Response.WriteJson(jsonResp{
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package response_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
"github.com/gogf/gf/v2/util/guid"
|
||||
|
||||
apiresponse "github.com/qianfree/team-api/internal/response"
|
||||
)
|
||||
|
||||
func TestErrorResponseStatusAndCode(t *testing.T) {
|
||||
s := g.Server(guid.S())
|
||||
s.SetDumpRouterMap(false)
|
||||
s.BindHandler("/standard", func(r *ghttp.Request) {
|
||||
apiresponse.ErrorMsg(r, http.StatusBadRequest, "bad request")
|
||||
})
|
||||
s.BindHandler("/business", func(r *ghttp.Request) {
|
||||
apiresponse.ErrorWithCode(r, http.StatusUnprocessableEntity, 10079, "invalid username")
|
||||
})
|
||||
s.BindHandler("/invalid-status", func(r *ghttp.Request) {
|
||||
apiresponse.ErrorWithCode(r, 10079, 10079, "invalid username")
|
||||
})
|
||||
|
||||
server := httptest.NewServer(s)
|
||||
defer server.Close()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
wantStatus int
|
||||
wantCode int
|
||||
}{
|
||||
{
|
||||
name: "standard HTTP error",
|
||||
path: "/standard",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "business error uses separate HTTP status",
|
||||
path: "/business",
|
||||
wantStatus: http.StatusUnprocessableEntity,
|
||||
wantCode: 10079,
|
||||
},
|
||||
{
|
||||
name: "invalid HTTP status falls back to internal error",
|
||||
path: "/invalid-status",
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantCode: 10079,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res, err := server.Client().Get(server.URL + tt.path)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", tt.path, err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != tt.wantStatus {
|
||||
t.Errorf("status = %d, want %d", res.StatusCode, tt.wantStatus)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Code int `json:"code"`
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if body.Code != tt.wantCode {
|
||||
t.Errorf("code = %d, want %d", body.Code, tt.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,9 @@ type (
|
||||
GetProviderDefaultURLs(ctx context.Context, _ *v1.ProviderDefaultURLReq) (*v1.ProviderDefaultURLRes, error)
|
||||
// GetChannelHealthTrend 获取渠道健康趋势数据
|
||||
GetChannelHealthTrend(ctx context.Context, req *v1.ChannelHealthTrendReq) (*v1.ChannelHealthTrendRes, error)
|
||||
// ResetChannelHealth 重置渠道健康度(渠道可能已修复、重新可用):落库健康分 80(展示层),
|
||||
// 并复位熔断 + 成功率 EWMA(调度层),使渠道立即恢复被调度选择的能力。
|
||||
ResetChannelHealth(ctx context.Context, req *v1.ChannelResetHealthReq) (*v1.ChannelResetHealthRes, error)
|
||||
// ExportChannels exports channel list to CSV or Excel.
|
||||
ExportChannels(ctx context.Context, req *v1.ChannelExportReq) (*v1.ChannelExportRes, error)
|
||||
// ChannelErrorEventList 渠道错误事件列表
|
||||
@@ -160,14 +163,37 @@ type (
|
||||
GetAllBillingRecords(ctx context.Context, req *v1.AdminBillingRecordListReq) (*v1.AdminBillingRecordListRes, error)
|
||||
// GetTenantWallets 获取所有租户钱包(管理后台)
|
||||
GetTenantWallets(ctx context.Context, req *v1.AdminWalletListReq) (*v1.AdminWalletListRes, error)
|
||||
// AdjustBalance 调整租户余额(管理后台)
|
||||
// AdjustBalance 调整租户余额(管理后台)。
|
||||
// Redis 权威化架构:余额变动以 Redis Lua 为资金提交点(扣减在权威可用余额上原子判断,
|
||||
// 穿透冻结会破坏预扣一致性),DB 侧仅记流水;流水失败时逆转 Redis 已发生的变动。
|
||||
AdjustBalance(ctx context.Context, req *v1.AdminWalletAdjustReq) (*v1.AdminWalletAdjustRes, error)
|
||||
// OfflineRecharge 线下充值入账(管理后台)
|
||||
// 场景:用户线下银行转账(人民币 CNY),运营确认到账后按平台汇率换算为 USD 入账。
|
||||
// 与 AdjustBalance 的区别:走正规充值链路——余额与累计充值同步累加、触发等级检查、
|
||||
// 流水类型为 recharge,并在描述中携带 CNY 快照(原始人民币 + 汇率 + 入账 USD + 转账流水号),
|
||||
// 供现金对账与开票追溯。
|
||||
OfflineRecharge(ctx context.Context, req *v1.AdminWalletOfflineRechargeReq) (*v1.AdminWalletOfflineRechargeRes, error)
|
||||
// GetWalletInfo 获取租户钱包信息(管理后台)
|
||||
GetWalletInfo(ctx context.Context, req *v1.AdminWalletInfoReq) (*v1.AdminWalletInfoRes, error)
|
||||
// GetWalletTransactions 获取租户钱包交易流水(管理后台)
|
||||
GetWalletTransactions(ctx context.Context, req *v1.AdminWalletTransactionListReq) (*v1.AdminWalletTransactionListRes, error)
|
||||
// SetWarningThreshold 设置租户钱包预警阈值(管理后台)
|
||||
SetWarningThreshold(ctx context.Context, req *v1.AdminWalletSetWarningThresholdReq) (*v1.AdminWalletSetWarningThresholdRes, error)
|
||||
// GetWalletFrozenItems 获取租户钱包冻结明细(管理后台)。
|
||||
// 数据源为 Redis 预扣明细(权威),DB 预扣追踪表已废弃。
|
||||
GetWalletFrozenItems(ctx context.Context, req *v1.AdminWalletFrozenItemListReq) (*v1.AdminWalletFrozenItemListRes, error)
|
||||
// ReleaseWalletFrozenItem 按笔释放冻结(管理后台运维逃生舱)。
|
||||
// 释放走 billing.UnfreezePreDeduct 的 Redis 认领路径:认领即删预扣 hash、幂等、逐笔精确,
|
||||
// 绝不直接改写钱包 hash 的 frozen_balance 汇总值。
|
||||
ReleaseWalletFrozenItem(ctx context.Context, req *v1.AdminWalletFrozenReleaseReq) (*v1.AdminWalletFrozenReleaseRes, error)
|
||||
// ReleaseAllWalletFrozenItems 一键释放租户全部冻结(管理后台运维逃生舱)。
|
||||
// 遍历 Redis 活跃冻结明细,复用 frozenItemGuard 护栏逐项筛选:
|
||||
// - 任务关联在途/待结算项自动跳过(由任务结算流程负责,禁止手动释放);
|
||||
// - 保护期内(冻结不足 frozenReleaseMinAge)项默认跳过,Force=true 时才释放;
|
||||
// - 其余项逐个 UnfreezePreDeduct(Lua 认领即删,幂等,与并发结算竞争安全)。
|
||||
//
|
||||
// 返回实际释放笔数/金额与跳过原因摘要(去重)。
|
||||
ReleaseAllWalletFrozenItems(ctx context.Context, req *v1.AdminWalletFrozenReleaseAllReq) (*v1.AdminWalletFrozenReleaseAllRes, error)
|
||||
// GetAllTransactions 获取所有租户交易流水(管理后台)
|
||||
GetAllTransactions(ctx context.Context, req *v1.AdminTransactionListReq) (*v1.AdminTransactionListRes, error)
|
||||
// GetDashboardChannelHealth 获取渠道健康概览(最不健康的5个活跃渠道)
|
||||
|
||||
@@ -54,6 +54,15 @@ cache:
|
||||
default:
|
||||
adapter: "redis"
|
||||
|
||||
# Billing
|
||||
# 钱包物化间隔(毫秒,sys_options 键 billing_wallet_materialize_interval_ms,可选):
|
||||
# Redis 权威钱包状态刷回 DB bil_wallets 的周期。Redis 权威化架构下该值决定 DB 余额展示
|
||||
# 的滞后上限;过小增加 DB 写频,过大展示滞后明显。默认 5000(未配置即用默认值)。
|
||||
# billing_wallet_materialize_interval_ms: 5000
|
||||
|
||||
# 预扣孤儿清扫说明:由 cron `prededuct_sweep`(每 2 分钟)基于 Redis 预扣明细重算
|
||||
# frozen_balance 自愈,不再依赖 DB 预扣追踪表(bil_prededuct_tracks 已废弃)。
|
||||
|
||||
# JWT Authentication
|
||||
jwt:
|
||||
secret: "<change-me-to-a-random-secret>"
|
||||
|
||||
@@ -28,7 +28,7 @@ func (a *Adaptor) Init(info *common.RelayInfo) {
|
||||
// GetRequestURL 构建上游请求 URL。
|
||||
// Claude 协议走 Anthropic 兼容端点,OpenAI 走 compatible-mode 端点,图像生成使用独立端点。
|
||||
func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) {
|
||||
baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/")
|
||||
baseURL := normalizeBaseURL(info.ChannelMeta.BaseURL)
|
||||
|
||||
switch constant.RelayMode(info.RelayMode) {
|
||||
case constant.RelayModeClaudeMessages:
|
||||
@@ -161,6 +161,30 @@ func (a *Adaptor) GetChannelName() string {
|
||||
|
||||
var _ common.Adaptor = (*Adaptor)(nil)
|
||||
|
||||
// normalizeBaseURL 归一化渠道 base URL 为 DashScope 裸域名。
|
||||
//
|
||||
// adaptor 各端点自行拼接完整路径(compatible-mode / apps/anthropic / api/v1/services),
|
||||
// 因此 base 必须是裸域名。但用户手填时有三种常见写法都很自然:
|
||||
//
|
||||
// https://dashscope.aliyuncs.com
|
||||
// https://dashscope.aliyuncs.com/compatible-mode (旧版默认值、控制台文档)
|
||||
// https://dashscope.aliyuncs.com/compatible-mode/v1 (阿里 OpenAI SDK 文档的 base_url)
|
||||
//
|
||||
// 后两种若直接拼接会产生 /compatible-mode/compatible-mode/v1/... 这类重复路径,
|
||||
// DashScope 网关在路由层直接返回 404 且响应体为空,极难定位。
|
||||
// 这里统一剥离已知后缀,让三种写法都能正常工作。
|
||||
func normalizeBaseURL(raw string) string {
|
||||
base := strings.TrimSuffix(strings.TrimSpace(raw), "/")
|
||||
// 从长到短依次剥离,避免先匹配短后缀导致残留
|
||||
for _, suffix := range []string{"/compatible-mode/v1", "/compatible-mode"} {
|
||||
if strings.HasSuffix(base, suffix) {
|
||||
base = strings.TrimSuffix(base, suffix)
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.TrimSuffix(base, "/")
|
||||
}
|
||||
|
||||
// convertClaudeRequest 处理 Claude 入站请求的模型映射。
|
||||
func convertClaudeRequest(requestBody []byte, info *common.RelayInfo) (io.Reader, error) {
|
||||
if !info.ChannelMeta.IsModelMapped {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package ali
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestNormalizeBaseURL 验证三种常见 base URL 写法都归一化为同一个裸域名,
|
||||
// 防止再次出现 /compatible-mode/compatible-mode/v1/... 重复路径导致上游 404。
|
||||
func TestNormalizeBaseURL(t *testing.T) {
|
||||
const want = "https://dashscope.aliyuncs.com"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"裸域名", "https://dashscope.aliyuncs.com", want},
|
||||
{"裸域名带尾斜杠", "https://dashscope.aliyuncs.com/", want},
|
||||
{"带 compatible-mode", "https://dashscope.aliyuncs.com/compatible-mode", want},
|
||||
{"带 compatible-mode 尾斜杠", "https://dashscope.aliyuncs.com/compatible-mode/", want},
|
||||
{"带 compatible-mode/v1(阿里 SDK 文档写法)", "https://dashscope.aliyuncs.com/compatible-mode/v1", want},
|
||||
{"带 compatible-mode/v1 尾斜杠", "https://dashscope.aliyuncs.com/compatible-mode/v1/", want},
|
||||
{"首尾空格", " https://dashscope.aliyuncs.com/compatible-mode/v1 ", want},
|
||||
{"自定义代理域名保持不变", "https://proxy.example.com/dashscope", "https://proxy.example.com/dashscope"},
|
||||
{"自定义代理域名剥离 compatible-mode", "https://proxy.example.com/compatible-mode/v1", "https://proxy.example.com"},
|
||||
{"空串", "", ""},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := normalizeBaseURL(c.in); got != c.want {
|
||||
t.Errorf("normalizeBaseURL(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeBaseURLIdempotent 归一化必须幂等,重复调用不改变结果。
|
||||
func TestNormalizeBaseURLIdempotent(t *testing.T) {
|
||||
inputs := []string{
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode",
|
||||
"https://dashscope.aliyuncs.com",
|
||||
}
|
||||
for _, in := range inputs {
|
||||
once := normalizeBaseURL(in)
|
||||
if twice := normalizeBaseURL(once); twice != once {
|
||||
t.Errorf("非幂等: %q -> %q -> %q", in, once, twice)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,7 +308,8 @@ func (a *Adaptor) handleChatNonStreamResponse(ctx context.Context, resp *http.Re
|
||||
upstreamErr.ResponseWritten = true
|
||||
return &common.Usage{}, upstreamErr
|
||||
}
|
||||
return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header))
|
||||
// 响应体不是标准 OpenAI 错误格式(含空响应体):附带上游 URL 供日志定位
|
||||
return nil, constant.NewUpstreamErrorFromResponse(resp, body)
|
||||
}
|
||||
|
||||
if info.ChannelMeta.IsModelMapped {
|
||||
@@ -347,7 +348,8 @@ func (a *Adaptor) handleChatStreamResponse(ctx context.Context, resp *http.Respo
|
||||
upstreamErr.ResponseWritten = true
|
||||
return &common.Usage{}, upstreamErr
|
||||
}
|
||||
return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header))
|
||||
// 响应体不是标准 OpenAI 错误格式(含空响应体):附带上游 URL 供日志定位
|
||||
return nil, constant.NewUpstreamErrorFromResponse(resp, body)
|
||||
}
|
||||
|
||||
return StreamHandler(ctx, resp, info, writer)
|
||||
|
||||
@@ -21,6 +21,9 @@ var ErrTenantModelNotEnabled = errors.New("model not enabled for this tenant")
|
||||
// ErrMemberModelNotAllowed 成员无权使用该模型
|
||||
var ErrMemberModelNotAllowed = errors.New("model not allowed for this member")
|
||||
|
||||
// ErrApiKeyModelNotAllowed API Key 无权使用该模型
|
||||
var ErrApiKeyModelNotAllowed = errors.New("model not allowed for this API key")
|
||||
|
||||
// DeprecationInfo 模型弃用信息
|
||||
type DeprecationInfo struct {
|
||||
Deprecated bool
|
||||
@@ -60,7 +63,8 @@ type DataProvider interface {
|
||||
|
||||
// GetModelDetail 获取单个模型的详细信息。
|
||||
// tenantID 用于权限校验(检查租户是否有权使用该模型)。
|
||||
GetModelDetail(ctx context.Context, tenantID int64, modelName string) (*ModelDetail, error)
|
||||
// apiKeyID / userID 大于 0 时进一步校验 API Key 与成员的模型范围(与 GetAvailableModels 口径一致)。
|
||||
GetModelDetail(ctx context.Context, tenantID, apiKeyID, userID int64, modelName string) (*ModelDetail, error)
|
||||
|
||||
// CheckTenantModelAccess 检查租户是否有权使用指定模型。
|
||||
// 返回是否启用和渠道范围(nil/空表示不限渠道)。
|
||||
@@ -84,6 +88,9 @@ type DataProvider interface {
|
||||
// InvalidateMemberModelCache 清除指定成员的模型范围缓存。
|
||||
InvalidateMemberModelCache(ctx context.Context, tenantID, userID int64)
|
||||
|
||||
// InvalidateApiKeyModelCache 清除指定 API Key 的模型权限缓存。
|
||||
InvalidateApiKeyModelCache(ctx context.Context, apiKeyID int64)
|
||||
|
||||
// MaterializeSelection 由新调度引擎的决策构造 ChannelSelection:
|
||||
// 从目录快照取转发元数据,按 keyID 解密渠道 Key(keyID=0 时取渠道首个 active Key)。
|
||||
MaterializeSelection(ctx context.Context, channelID, keyID int64, modelName string) (*ChannelSelection, error)
|
||||
|
||||
@@ -23,7 +23,10 @@ type ChannelMeta struct {
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultTimeoutSeconds = 60
|
||||
// DefaultTimeoutSeconds 非流式请求的默认总超时(http.Client.Timeout)。
|
||||
// 推理模型(GLM-4.5/4.6 thinking、Claude thinking 等)非流式调用需跑完整段生成才回响应头,
|
||||
// 60s 会误杀;放宽到 180s。个别仍超时的模型建议在渠道级 settings.timeout_seconds 配 >180(自动切 longRun 传输层)。
|
||||
DefaultTimeoutSeconds = 180
|
||||
ImagesGenerationTimeoutSecs = 600
|
||||
)
|
||||
|
||||
|
||||
@@ -30,21 +30,32 @@ type RelayError struct {
|
||||
// RetryAfter 上游 Retry-After 头解析结果(0 = 未携带)。
|
||||
// 调度重试 FSM 据此决定 429 时「原地等待重试」还是立即 failover。
|
||||
RetryAfter time.Duration
|
||||
// Detail 服务端诊断上下文(如上游请求 URL),仅出现在 Error() 输出即日志中。
|
||||
// 客户端响应只取 Message 字段(见 WriteRelayError),故此字段不会泄露上游地址。
|
||||
Detail string
|
||||
}
|
||||
|
||||
func (e *RelayError) Error() string {
|
||||
if e.Cause != nil {
|
||||
return e.Message + ": " + e.Cause.Error()
|
||||
msg := e.Message
|
||||
if e.Detail != "" {
|
||||
msg += " [" + e.Detail + "]"
|
||||
}
|
||||
return e.Message
|
||||
if e.Cause != nil {
|
||||
return msg + ": " + e.Cause.Error()
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func (e *RelayError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// NewUpstreamError 创建上游错误
|
||||
// NewUpstreamError 创建上游错误。
|
||||
// message 为空时(上游返回空响应体)用状态码兜底,避免日志里出现 error=[] 这类无信息量的记录。
|
||||
func NewUpstreamError(statusCode int, message string, cause error) *RelayError {
|
||||
if strings.TrimSpace(message) == "" && cause == nil {
|
||||
message = "upstream returned HTTP " + strconv.Itoa(statusCode) + " with empty response body"
|
||||
}
|
||||
return &RelayError{
|
||||
StatusCode: statusCode,
|
||||
Message: message,
|
||||
@@ -53,6 +64,17 @@ func NewUpstreamError(statusCode int, message string, cause error) *RelayError {
|
||||
}
|
||||
}
|
||||
|
||||
// NewUpstreamErrorFromResponse 由上游响应构造错误,并把请求 URL 记入 Detail 供日志定位。
|
||||
// 空响应体的 4xx 通常是 URL 路径拼错(被上游网关在路由层拒绝),此时 URL 是唯一有效线索。
|
||||
func NewUpstreamErrorFromResponse(resp *http.Response, body []byte) *RelayError {
|
||||
e := NewUpstreamError(resp.StatusCode, string(body), nil).
|
||||
WithRetryAfter(RetryAfterFromHeader(resp.Header))
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
e.Detail = "upstream_url=" + resp.Request.URL.String()
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// WithRetryAfter 附着上游 Retry-After 解析结果(链式调用,d<=0 时不生效)。
|
||||
func (e *RelayError) WithRetryAfter(d time.Duration) *RelayError {
|
||||
if e != nil && d > 0 {
|
||||
|
||||
@@ -73,8 +73,9 @@ func HandleModels(ctx context.Context, tenantID, apiKeyID, userID int64, provide
|
||||
}
|
||||
|
||||
// HandleModelDetail 处理 /v1/models/{model_id} 请求(获取单个模型详情)
|
||||
func HandleModelDetail(ctx context.Context, tenantID int64, modelName string, provider common.DataProvider) (*dto.ModelDetailResponse, error) {
|
||||
detail, err := provider.GetModelDetail(ctx, tenantID, modelName)
|
||||
// apiKeyID / userID 用于进一步按 API Key / 成员的模型范围过滤
|
||||
func HandleModelDetail(ctx context.Context, tenantID, apiKeyID, userID int64, modelName string, provider common.DataProvider) (*dto.ModelDetailResponse, error) {
|
||||
detail, err := provider.GetModelDetail(ctx, tenantID, apiKeyID, userID, modelName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -43,8 +43,9 @@ func HandleGeminiModels(ctx context.Context, tenantID, apiKeyID, userID int64, p
|
||||
}
|
||||
|
||||
// HandleGeminiModelDetail 处理 GET /v1beta/models/{model} 请求(Gemini 原生格式模型详情)
|
||||
func HandleGeminiModelDetail(ctx context.Context, tenantID int64, modelName string, provider common.DataProvider) (*dto.GeminiModel, error) {
|
||||
detail, err := provider.GetModelDetail(ctx, tenantID, modelName)
|
||||
// apiKeyID / userID 用于进一步按 API Key / 成员的模型范围过滤
|
||||
func HandleGeminiModelDetail(ctx context.Context, tenantID, apiKeyID, userID int64, modelName string, provider common.DataProvider) (*dto.GeminiModel, error) {
|
||||
detail, err := provider.GetModelDetail(ctx, tenantID, apiKeyID, userID, modelName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -67,6 +67,32 @@ type relayValidation struct {
|
||||
sessionSignals dispatch.SessionSignals // 请求体中的会话信号(影子模式/新调度用)
|
||||
}
|
||||
|
||||
type modelMappingProvider interface {
|
||||
GetModelMapping(ctx context.Context, modelName string) (standardName string, category string, err error)
|
||||
}
|
||||
|
||||
// resolveRelayModel gives a literal catalog model precedence over the optional
|
||||
// thinking/effort suffix syntax. This avoids treating names such as
|
||||
// "qwen3.8-max" as the virtual model "qwen3.8" with effort=max.
|
||||
func resolveRelayModel(ctx context.Context, provider modelMappingProvider, modelName string) (string, *helper.ThinkingInfo, error) {
|
||||
parsed := helper.ParseThinkingSuffix(modelName)
|
||||
literal := &helper.ThinkingInfo{BaseModel: modelName}
|
||||
|
||||
_, _, err := provider.GetModelMapping(ctx, modelName)
|
||||
if err == nil {
|
||||
return modelName, literal, nil
|
||||
}
|
||||
if parsed.BaseModel == modelName {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
_, _, err = provider.GetModelMapping(ctx, parsed.BaseModel)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return parsed.BaseModel, &parsed, nil
|
||||
}
|
||||
|
||||
// validateRelayRequest 校验请求合法性:relay mode、QPS 限流(前置)、模型存在性、弃用状态、成员/API Key 模型范围。
|
||||
// 纯校验逻辑,无 defer 副作用。
|
||||
func validateRelayRequest(
|
||||
@@ -134,23 +160,9 @@ func validateRelayRequest(
|
||||
return nil, constant.NewRequestError("model is required", nil)
|
||||
}
|
||||
|
||||
// 2.5 解析 thinking/effort 后缀
|
||||
parsed := helper.ParseThinkingSuffix(modelName)
|
||||
thinkingInfo := &parsed
|
||||
lookupModel := modelName
|
||||
if thinkingInfo.BaseModel != modelName {
|
||||
lookupModel = thinkingInfo.BaseModel
|
||||
}
|
||||
|
||||
// 3. 验证模型存在且活跃
|
||||
_, _, modelErr := provider.GetModelMapping(ctx, modelName)
|
||||
if modelErr != nil && thinkingInfo.BaseModel != modelName {
|
||||
// 完整模型名查找失败,尝试基础模型名
|
||||
_, _, modelErr = provider.GetModelMapping(ctx, thinkingInfo.BaseModel)
|
||||
if modelErr == nil {
|
||||
lookupModel = thinkingInfo.BaseModel
|
||||
}
|
||||
}
|
||||
// 2.5 解析 thinking/effort 后缀并验证模型。完整模型名存在时按字面模型处理;
|
||||
// 仅在完整名称不存在时,才尝试将后缀解析为虚拟 thinking/effort 参数。
|
||||
lookupModel, thinkingInfo, modelErr := resolveRelayModel(ctx, provider, modelName)
|
||||
if modelErr != nil {
|
||||
if modelErr == common.ErrModelNotFound {
|
||||
return nil, constant.NewRequestError("model not found: "+modelName, modelErr)
|
||||
@@ -604,7 +616,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
|
||||
if billing != nil && preDeductAmount > 0 {
|
||||
_ = billing.SettleFailed(settleCtx, rc.TenantID, rc.RequestID, preDeductAmount)
|
||||
}
|
||||
recordFailedUsage(provider, rc, selection.ChannelID, v.modelName, v.relayMode, v.isStream, err)
|
||||
recordFailedUsage(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err)
|
||||
recordChannelError(rc, selection, v.modelName, attempt, true, err, info.LatencyMs())
|
||||
finalizeTrace(trace, rc, hop, false, attempt, selection, err.Error(), info.LatencyMs())
|
||||
return nil, v.billingResult, helper.RemapStatusCode(constant.NewUpstreamError(502, "upstream request failed", err), info.ChannelMeta.Settings.StatusCodeMapping)
|
||||
@@ -646,7 +658,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
|
||||
billing.IncrApiKeyQuotaUsed(settleCtx, rc.ApiKeyID, settleResult.ActualCost)
|
||||
}
|
||||
}
|
||||
recordFailedUsage(provider, rc, selection.ChannelID, v.modelName, v.relayMode, v.isStream, err)
|
||||
recordFailedUsage(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err)
|
||||
finalizeTrace(trace, rc, hop, false, attempt, selection, err.Error(), info.LatencyMs())
|
||||
return usage, v.billingResult, err
|
||||
}
|
||||
@@ -692,7 +704,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
|
||||
if billing != nil && preDeductAmount > 0 {
|
||||
_ = billing.SettleFailed(settleCtx, rc.TenantID, rc.RequestID, preDeductAmount)
|
||||
}
|
||||
recordFailedUsage(provider, rc, selection.ChannelID, v.modelName, v.relayMode, v.isStream, err)
|
||||
recordFailedUsage(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err)
|
||||
recordChannelError(rc, selection, v.modelName, attempt, true, err, info.LatencyMs())
|
||||
finalizeTrace(trace, rc, hop, false, attempt, selection, err.Error(), info.LatencyMs())
|
||||
return nil, v.billingResult, err
|
||||
@@ -845,7 +857,7 @@ func handleChannelUnavailable(
|
||||
fmt.Sprintf("all %d channels failed for model: %s", len(channelErrors), v.modelName),
|
||||
constant.ErrAllChannelsFailed,
|
||||
)
|
||||
recordFailedUsage(provider, rc, 0, v.modelName, v.relayMode, v.isStream, allFailedErr)
|
||||
recordFailedUsage(provider, rc, nil, v.modelName, v.relayMode, v.isStream, allFailedErr)
|
||||
return &channelUnavailableResult{nil, v.billingResult, allFailedErr}
|
||||
}
|
||||
|
||||
@@ -857,7 +869,9 @@ func handleChannelUnavailable(
|
||||
g.Log().Stack(false).Warningf(ctx, "[RelayHandler] 无可用渠道: model=%s tenant=%d user=%d request=%s%s",
|
||||
v.modelName, rc.TenantID, rc.UserID, rc.RequestID, diagSuffix)
|
||||
noChErr := constant.NewChannelError("no available channel for model: "+v.modelName, err)
|
||||
recordFailedUsage(provider, rc, 0, v.modelName, v.relayMode, v.isStream, noChErr)
|
||||
// 无可用渠道时请求未转发到任何上游(与 QPS 限流、权限/额度拒绝等前置拦截同理),
|
||||
// 不记用量日志,避免零消耗的 error 记录污染请求日志;运营侧的“被拒请求量”已由
|
||||
// 上方 Warning 日志、调度器排除明细指标、chn_error_events 覆盖。
|
||||
return &channelUnavailableResult{nil, v.billingResult, noChErr}
|
||||
}
|
||||
|
||||
@@ -917,23 +931,31 @@ func (e *RelayErrorWithRateLimit) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// recordFailedUsage 记录失败用量
|
||||
func recordFailedUsage(provider common.DataProvider, rc *RelayContext, channelID int64, modelName string, relayMode constant.RelayMode, isStream bool, err error) {
|
||||
provider.RecordUsage(context.Background(), &common.UsageRecord{
|
||||
TenantID: rc.TenantID,
|
||||
UserID: rc.UserID,
|
||||
ApiKeyID: rc.ApiKeyID,
|
||||
ProjectID: rc.ProjectID,
|
||||
ChannelID: channelID,
|
||||
ModelName: modelName,
|
||||
RelayMode: int(relayMode),
|
||||
LatencyMs: 0,
|
||||
IsStream: isStream,
|
||||
Success: false,
|
||||
RequestID: rc.RequestID,
|
||||
Status: "error",
|
||||
ErrorMessage: err.Error(),
|
||||
})
|
||||
// recordFailedUsage 记录失败用量。
|
||||
// selection 非 nil 时记录具体失败渠道(ID/名称/类型/上游模型),便于在用量日志定位失败渠道;
|
||||
// nil 表示无单一渠道(全部渠道失败),渠道字段留空,失败详情见 error_message。
|
||||
func recordFailedUsage(provider common.DataProvider, rc *RelayContext, selection *common.ChannelSelection, modelName string, relayMode constant.RelayMode, isStream bool, err error) {
|
||||
record := &common.UsageRecord{
|
||||
TenantID: rc.TenantID,
|
||||
UserID: rc.UserID,
|
||||
ApiKeyID: rc.ApiKeyID,
|
||||
ProjectID: rc.ProjectID,
|
||||
ModelName: modelName,
|
||||
RequestedModel: modelName,
|
||||
RelayMode: int(relayMode),
|
||||
IsStream: isStream,
|
||||
Success: false,
|
||||
RequestID: rc.RequestID,
|
||||
Status: "error",
|
||||
ErrorMessage: err.Error(),
|
||||
}
|
||||
if selection != nil {
|
||||
record.ChannelID = selection.ChannelID
|
||||
record.ChannelName = selection.ChannelName
|
||||
record.ChannelType = selection.ChannelType
|
||||
record.UpstreamModel = selection.UpstreamModelName
|
||||
}
|
||||
provider.RecordUsage(context.Background(), record)
|
||||
}
|
||||
|
||||
// recordChannelError 记录渠道错误事件到 chn_error_events(异步,不阻塞请求)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/qianfree/team-api/relay/common"
|
||||
)
|
||||
|
||||
type modelMappingStub struct {
|
||||
models map[string]bool
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (s *modelMappingStub) GetModelMapping(_ context.Context, modelName string) (string, string, error) {
|
||||
s.calls = append(s.calls, modelName)
|
||||
if !s.models[modelName] {
|
||||
return "", "", common.ErrModelNotFound
|
||||
}
|
||||
return modelName, "chat", nil
|
||||
}
|
||||
|
||||
func TestResolveRelayModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
models map[string]bool
|
||||
wantLookup string
|
||||
wantEffort string
|
||||
wantCalls []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "literal model ending in effort suffix wins",
|
||||
model: "qwen3.8-max",
|
||||
models: map[string]bool{"qwen3.8-max": true, "qwen3.8": true},
|
||||
wantLookup: "qwen3.8-max",
|
||||
wantCalls: []string{"qwen3.8-max"},
|
||||
},
|
||||
{
|
||||
name: "virtual effort suffix falls back to base model",
|
||||
model: "o3-max",
|
||||
models: map[string]bool{"o3": true},
|
||||
wantLookup: "o3",
|
||||
wantEffort: "max",
|
||||
wantCalls: []string{"o3-max", "o3"},
|
||||
},
|
||||
{
|
||||
name: "model without suffix resolves literally",
|
||||
model: "gpt-4o",
|
||||
models: map[string]bool{"gpt-4o": true},
|
||||
wantLookup: "gpt-4o",
|
||||
wantCalls: []string{"gpt-4o"},
|
||||
},
|
||||
{
|
||||
name: "missing literal and base model returns error",
|
||||
model: "missing-high",
|
||||
models: map[string]bool{},
|
||||
wantCalls: []string{"missing-high", "missing"},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
provider := &modelMappingStub{models: tt.models}
|
||||
lookup, thinking, err := resolveRelayModel(t.Context(), provider, tt.model)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("resolveRelayModel() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if lookup != tt.wantLookup {
|
||||
t.Errorf("lookup model = %q, want %q", lookup, tt.wantLookup)
|
||||
}
|
||||
if !tt.wantErr {
|
||||
if thinking == nil {
|
||||
t.Fatal("thinking info is nil")
|
||||
}
|
||||
if thinking.BaseModel != tt.wantLookup {
|
||||
t.Errorf("thinking base model = %q, want %q", thinking.BaseModel, tt.wantLookup)
|
||||
}
|
||||
if thinking.EffortLevel != tt.wantEffort {
|
||||
t.Errorf("effort level = %q, want %q", thinking.EffortLevel, tt.wantEffort)
|
||||
}
|
||||
}
|
||||
if len(provider.calls) != len(tt.wantCalls) {
|
||||
t.Fatalf("lookup calls = %v, want %v", provider.calls, tt.wantCalls)
|
||||
}
|
||||
for i := range tt.wantCalls {
|
||||
if provider.calls[i] != tt.wantCalls[i] {
|
||||
t.Errorf("lookup calls = %v, want %v", provider.calls, tt.wantCalls)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -449,6 +449,16 @@ body {
|
||||
border-top: 1px solid var(--ta-border-light);
|
||||
}
|
||||
|
||||
/* 统计总数从表格左上角移到底部后:footer 内存在统计栏时两端对齐(统计居左、分页居右),
|
||||
无统计栏的 footer 保持右对齐不受影响(:has 特异性高于 scoped .table-footer,可统一覆盖各页脚注) */
|
||||
.page-table .table-footer:has(.table-stats) {
|
||||
justify-content: space-between;
|
||||
}
|
||||
/* 统计栏移入底部后,去掉全局样式的下边距,与分页栏垂直居中 */
|
||||
.page-table .table-footer .table-stats {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Tab 内嵌表格:让 Tab 容器与面板也参与纵向 flex,保证内部表格能撑满滚动 */
|
||||
.page-table .arco-tabs {
|
||||
flex: 1;
|
||||
|
||||
@@ -332,6 +332,8 @@ const menuGroups = [
|
||||
{ name: 'AdminTickets', label: '工单管理', icon: IconCommand },
|
||||
{ name: 'AdminFeedback', label: '反馈管理', icon: IconMessage },
|
||||
{ name: 'AdminAgreements', label: '用户协议', icon: IconFile },
|
||||
{ name: 'AdminHelpCategories', label: '帮助分类', icon: IconLayers },
|
||||
{ name: 'AdminHelpArticles', label: '帮助文章', icon: IconFile },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -360,9 +362,6 @@ const menuGroups = [
|
||||
{ name: 'AdminSettings', label: '系统设置', icon: IconSettings },
|
||||
{ name: 'AdminFiles', label: '文件管理', icon: IconStorage },
|
||||
{ name: 'AdminPaymentSettings', label: '支付设置', icon: IconStorage },
|
||||
{ name: 'AdminHelpCategories', label: '帮助分类', icon: IconLayers },
|
||||
{ name: 'AdminHelpArticles', label: '帮助文章', icon: IconFile },
|
||||
{ name: 'AdminChangelogs', label: '更新日志', icon: IconFile },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -19,6 +19,8 @@ const activeTab = ref('info')
|
||||
|
||||
const statusTagColor: Record<string, string> = { active: 'green', disabled: 'orangered', testing: 'arcoblue' }
|
||||
const statusLabel: Record<string, string> = { active: '启用', disabled: '禁用', testing: '测试中' }
|
||||
const breakerTagColor: Record<number, string> = { 0: 'green', 1: 'red', 2: 'orange' }
|
||||
const breakerLabel: Record<number, string> = { 0: '正常', 1: '熔断', 2: '半开' }
|
||||
const tierLabel: Record<string, string> = { primary: '首选', secondary: '备用', reserve: '保底' }
|
||||
const tierTagColor: Record<string, string> = { primary: 'arcoblue', secondary: 'orange', reserve: 'gray' }
|
||||
const tierOptions = [
|
||||
@@ -138,7 +140,8 @@ const abilityColumns: TableColumnData[] = [
|
||||
modelValue: record.upstream_model || '',
|
||||
size: 'mini',
|
||||
placeholder: '留空则同名',
|
||||
onChange: (v: string) => handleUpstreamModelChange(record, v),
|
||||
'onUpdate:modelValue': (v: string) => handleUpstreamModelChange(record, v),
|
||||
onChange: () => scheduleAbilitySave('上游模型名已更新'),
|
||||
})
|
||||
},
|
||||
},
|
||||
@@ -206,8 +209,8 @@ async function handleToggleAbility(ab: any) {
|
||||
} catch { /* error handled by interceptor */ }
|
||||
}
|
||||
|
||||
// 能力表内联编辑(成本比例 / 上游模型名):任意字段变更后统一 600ms 防抖整表提交,
|
||||
// 连续改多字段只发一次请求。提交体 abilityPayload 已包含 upstream_model。
|
||||
// 能力表内联编辑:成本比例输入过程中 600ms 防抖整表提交;
|
||||
// 上游模型名仅失焦/回车时提交(避免逐字保存)。提交体 abilityPayload 已包含 upstream_model。
|
||||
let abilitySaveTimer: any = null
|
||||
function scheduleAbilitySave(successMsg: string) {
|
||||
clearTimeout(abilitySaveTimer)
|
||||
@@ -227,7 +230,6 @@ function handleCostRatioChange(record: any, value: number) {
|
||||
|
||||
function handleUpstreamModelChange(record: any, value: string) {
|
||||
record.upstream_model = value
|
||||
scheduleAbilitySave('上游模型名已更新')
|
||||
}
|
||||
|
||||
async function handleDeleteAbility(id: number) {
|
||||
@@ -530,6 +532,18 @@ function formatHeaders(headers: Record<string, string>): string {
|
||||
</span>
|
||||
<span v-else style="color: #94a3b8">N/A</span>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="调度状态">
|
||||
<!-- disabled / testing 渠道不在目录快照中,熔断状态无意义,置灰展示 -->
|
||||
<template v-if="detail.status === 'active'">
|
||||
<ATag :color="breakerTagColor[detail.breaker_state ?? 0]" size="small">
|
||||
{{ breakerLabel[detail.breaker_state ?? 0] }}
|
||||
</ATag>
|
||||
<span v-if="(detail.breaker_models ?? 0) > 0" style="margin-left: 6px; color: #f59e0b; font-size: 12px; font-weight: 600">
|
||||
{{ detail.breaker_models }} 个模型受影响
|
||||
</span>
|
||||
</template>
|
||||
<span v-else style="color: #94a3b8">—</span>
|
||||
</ADescriptionsItem>
|
||||
<ADescriptionsItem label="VIP">
|
||||
<ATag v-if="detail.is_vip" color="gold" size="small">VIP</ATag>
|
||||
<span v-else>-</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, h } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Tag, Button, Space, Popconfirm, Message, Radio } from '@arco-design/web-vue'
|
||||
import { Tag, Button, Space, Popconfirm, Message, Radio, InputNumber, Select, Tooltip } from '@arco-design/web-vue'
|
||||
import { IconInfoCircle } from '@arco-design/web-vue/es/icon'
|
||||
import type { TableColumnData } from '@arco-design/web-vue'
|
||||
import type { FormInstance } from '@arco-design/web-vue'
|
||||
@@ -26,6 +26,9 @@ const pagination = reactive({ current: 1, pageSize: 20, total: 0, showPageSize:
|
||||
const filterType = ref<number | undefined>(undefined)
|
||||
const filterStatus = ref<string | undefined>(undefined)
|
||||
const filterSearch = ref('')
|
||||
const filterID = ref<number | undefined>(undefined)
|
||||
const filterModel = ref('')
|
||||
const filterBaseURL = ref('')
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: '' },
|
||||
@@ -33,6 +36,17 @@ const statusOptions = [
|
||||
]
|
||||
const statusTagColor: Record<string, string> = { active: 'green', disabled: 'orangered', testing: 'arcoblue' }
|
||||
const statusLabel: Record<string, string> = { active: '启用', disabled: '禁用', testing: '测试中' }
|
||||
const breakerTagColor: Record<number, string> = { 0: 'green', 1: 'red', 2: 'orange' }
|
||||
const breakerLabel: Record<number, string> = { 0: '正常', 1: '熔断', 2: '半开' }
|
||||
|
||||
// 调度状态悬浮说明:渠道级熔断 / 半开探活 / 模型级熔断的语义区分。
|
||||
function breakerTooltip(state: number, models: number): string {
|
||||
if (state === 1) return '渠道级熔断中:该渠道已被调度器硬排除,请求会路由到其他渠道。上游修复后可点击「重置健康度」立即复位熔断。'
|
||||
if (state === 2) return '渠道半开探活中:冷却期已过,调度器按窗口放行少量真实请求验证上游恢复,探测成功即自动闭合。'
|
||||
if (models > 0) return `渠道本体正常,但 ${models} 个模型处于熔断/半开:这些模型的请求会被排除或进入探测限流。`
|
||||
return '调度运行正常,无熔断。'
|
||||
}
|
||||
|
||||
const tierLabel: Record<string, string> = { primary: '首选', secondary: '备用', reserve: '保底' }
|
||||
const tierTagColor: Record<string, string> = { primary: 'arcoblue', secondary: 'orange', reserve: 'gray' }
|
||||
const tierOptions = [
|
||||
@@ -67,12 +81,42 @@ const columns: TableColumnData[] = [
|
||||
title: '状态', dataIndex: 'status', width: 80,
|
||||
render({ record }) { return h(Tag, { color: statusTagColor[record.status], size: 'small' }, () => statusLabel[record.status] || record.status) },
|
||||
},
|
||||
{ title: '优先级', dataIndex: 'priority', width: 70 },
|
||||
{ title: '权重', dataIndex: 'weight', width: 70 },
|
||||
{
|
||||
title: '层级', dataIndex: 'tier', width: 80,
|
||||
title: '层级', dataIndex: 'tier', width: 120,
|
||||
render({ record }) {
|
||||
return h(Tag, { color: tierTagColor[record.tier] || 'gray', size: 'small' }, () => tierLabel[record.tier] || record.tier || '-')
|
||||
// 无编辑权限时只读展示
|
||||
if (!hasPermission('channel:edit')) {
|
||||
return h(Tag, { color: tierTagColor[record.tier] || 'gray', size: 'small' }, () => tierLabel[record.tier] || record.tier || '-')
|
||||
}
|
||||
return h(Select, {
|
||||
modelValue: record.tier,
|
||||
size: 'small',
|
||||
style: 'width: 108px',
|
||||
options: [
|
||||
{ label: '首选', value: 'primary' },
|
||||
{ label: '备用', value: 'secondary' },
|
||||
{ label: '保底', value: 'reserve' },
|
||||
],
|
||||
'onUpdate:modelValue': (val: any) => { record.tier = val },
|
||||
onChange: (val: any) => saveTier(record, val),
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '权重', dataIndex: 'weight', width: 100,
|
||||
render({ record }) {
|
||||
// 无编辑权限时只读展示
|
||||
if (!hasPermission('channel:edit')) return h('span', {}, record.weight)
|
||||
return h(InputNumber, {
|
||||
modelValue: record.weight,
|
||||
min: 0,
|
||||
max: 100,
|
||||
size: 'small',
|
||||
disabled: record._saving,
|
||||
style: 'width: 84px',
|
||||
'onUpdate:modelValue': (val: number | undefined) => { record.weight = val ?? 0 },
|
||||
onChange: (val: number | undefined) => onWeightChange(record, val),
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -82,6 +126,29 @@ const columns: TableColumnData[] = [
|
||||
return h('span', { style: { color: healthColor(record.health_score), fontWeight: 600 } }, record.health_score.toFixed(0))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '调度状态', dataIndex: 'breaker_state', width: 110,
|
||||
render({ record }) {
|
||||
// disabled / testing 渠道不在目录快照中,熔断状态无意义,置灰展示
|
||||
if (record.status !== 'active') {
|
||||
return h('span', { style: 'color:#94a3b8' }, '—')
|
||||
}
|
||||
const state = record.breaker_state ?? 0
|
||||
const models = record.breaker_models ?? 0
|
||||
const tag = h(Tag, { color: breakerTagColor[state], size: 'small' }, () => breakerLabel[state])
|
||||
const badge = models > 0
|
||||
? h('span', { style: 'font-size:12px;color:#f59e0b;margin-left:4px;font-weight:600' }, `${models}`)
|
||||
: null
|
||||
const trigger = h('span', { style: 'display:inline-flex;gap:4px;align-items:center;cursor:help' }, [tag, badge])
|
||||
const content = () => h('div', { style: 'line-height:1.8' }, [
|
||||
h('div', null, breakerTooltip(state, models)),
|
||||
(state === 1 || state === 2 || models > 0) && hasPermission('channel:edit')
|
||||
? h('a', { style: 'color:#165dff;cursor:pointer', onClick: () => resetHealth(record) }, '重置健康度')
|
||||
: null,
|
||||
])
|
||||
return h(Tooltip, null, { default: () => trigger, content })
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'VIP', dataIndex: 'is_vip', width: 70,
|
||||
render({ record }) {
|
||||
@@ -90,7 +157,7 @@ const columns: TableColumnData[] = [
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 170 },
|
||||
{
|
||||
title: '操作', dataIndex: 'actions', width: 210, fixed: 'right',
|
||||
title: '操作', dataIndex: 'actions', width: 300, fixed: 'right',
|
||||
render({ record }) {
|
||||
const isActive = record.status === 'active'
|
||||
const action = isActive ? '禁用' : '启用'
|
||||
@@ -105,6 +172,15 @@ const columns: TableColumnData[] = [
|
||||
status: isActive ? 'normal' : 'success',
|
||||
}, () => action))
|
||||
: null,
|
||||
hasPermission('channel:edit')
|
||||
? h(Popconfirm, {
|
||||
content: '确定重置该渠道健康度?',
|
||||
onOk: () => resetHealth(record),
|
||||
}, () => h(Button, {
|
||||
size: 'small',
|
||||
status: 'warning',
|
||||
}, () => '重置健康度'))
|
||||
: null,
|
||||
h(Button, { size: 'small', type: 'primary', onClick: () => openDetail(record) }, () => '详情'),
|
||||
hasPermission('channel:delete')
|
||||
? h(Popconfirm, { content: '确定删除该渠道?', onOk: () => deleteChannel(record) }, () => h(Button, { size: 'small', status: 'danger' }, () => '删除'))
|
||||
@@ -122,8 +198,13 @@ async function fetchData() {
|
||||
if (filterType.value) params.type = filterType.value
|
||||
if (filterStatus.value) params.status = filterStatus.value
|
||||
if (filterSearch.value) params.search = filterSearch.value
|
||||
if (filterID.value) params.id = filterID.value
|
||||
if (filterModel.value) params.model = filterModel.value
|
||||
if (filterBaseURL.value) params.base_url = filterBaseURL.value
|
||||
const res: any = await request.get('/admin/channels', { params })
|
||||
data.value = res.data?.data?.list || res.data?.list || []
|
||||
// 记录本次加载时已持久化的权重/层级,供行内编辑失败回滚
|
||||
data.value.forEach((r: any) => { r._savedWeight = r.weight; r._tierSaved = r.tier })
|
||||
pagination.total = res.data?.data?.total || res.data?.total || 0
|
||||
} catch {
|
||||
data.value = []; pagination.total = 0
|
||||
@@ -147,7 +228,7 @@ const providerDefaultURLs: Record<number, string> = {
|
||||
1: 'https://api.openai.com',
|
||||
2: 'https://api.anthropic.com',
|
||||
3: 'https://generativelanguage.googleapis.com',
|
||||
4: 'https://dashscope.aliyuncs.com/compatible-mode',
|
||||
4: 'https://dashscope.aliyuncs.com',
|
||||
6: 'https://hunyuan.tencentcloudapi.com',
|
||||
7: 'https://open.bigmodel.cn/api/paas',
|
||||
8: 'https://api.deepseek.com',
|
||||
@@ -198,6 +279,15 @@ async function deleteChannel(row: any) {
|
||||
catch { /* interceptor handles error toast */ }
|
||||
}
|
||||
|
||||
// 重置渠道健康度:熔断复位 + 成功率恢复,渠道立即恢复被调度选择的能力。
|
||||
async function resetHealth(row: any) {
|
||||
try {
|
||||
await request.post(`/admin/channels/${row.id}/reset-health`)
|
||||
message.success('已重置健康度')
|
||||
fetchData()
|
||||
} catch { /* interceptor handles error toast */ }
|
||||
}
|
||||
|
||||
// 快捷启用/禁用渠道:复用 PUT /admin/channels/{id}。
|
||||
// 后端 UpdateChannel 会无条件写入 priority/weight,故必须带上当前值,否则会被重置为 0。
|
||||
// status 为 active → 禁用;其它(disabled/testing)→ 启用为 active。
|
||||
@@ -222,6 +312,61 @@ async function toggleStatus(record: any) {
|
||||
}
|
||||
}
|
||||
|
||||
// === 行内编辑(权重 / 层级) ===
|
||||
// 行内编辑复用 PUT /admin/channels/{id}。后端 UpdateChannel 会无条件写入 priority/weight,
|
||||
// 故每次提交必须携带当前 priority 与 weight,避免被重置为 0。
|
||||
const weightTimers = new Map<number, any>()
|
||||
|
||||
function onWeightChange(record: any, val: number | undefined) {
|
||||
if (val === undefined || val === null) {
|
||||
// 清空字段时取消待提交的防抖任务,避免误提交旧值
|
||||
const timer = weightTimers.get(record.id)
|
||||
if (timer) { clearTimeout(timer); weightTimers.delete(record.id) }
|
||||
return
|
||||
}
|
||||
const timer = weightTimers.get(record.id)
|
||||
if (timer) clearTimeout(timer)
|
||||
// 防抖:连续点击步进按钮或连续输入时,只提交最后一次值
|
||||
weightTimers.set(record.id, setTimeout(() => saveWeight(record, val), 350))
|
||||
}
|
||||
|
||||
async function saveWeight(record: any, val: number) {
|
||||
if (record._saving || val === record._savedWeight) return
|
||||
const prev = record._savedWeight
|
||||
record._saving = true
|
||||
try {
|
||||
await request.put(`/admin/channels/${record.id}`, {
|
||||
priority: record.priority,
|
||||
weight: val,
|
||||
})
|
||||
record._savedWeight = val
|
||||
message.success('权重已更新')
|
||||
} catch {
|
||||
record.weight = prev // 失败回滚显示值
|
||||
} finally {
|
||||
record._saving = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTier(record: any, val: string) {
|
||||
if (!val || record._saving || val === record._tierSaved) return
|
||||
const prev = record._tierSaved
|
||||
record._saving = true
|
||||
try {
|
||||
await request.put(`/admin/channels/${record.id}`, {
|
||||
priority: record.priority,
|
||||
weight: record.weight,
|
||||
tier: val,
|
||||
})
|
||||
record._tierSaved = val
|
||||
message.success('层级已更新')
|
||||
} catch {
|
||||
record.tier = prev // 失败回滚
|
||||
} finally {
|
||||
record._saving = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
|
||||
// === OAuth ===
|
||||
@@ -292,6 +437,9 @@ const { exporting, exportFile } = useExport({
|
||||
type: filterType.value,
|
||||
status: filterStatus.value,
|
||||
search: filterSearch.value,
|
||||
id: filterID.value,
|
||||
model: filterModel.value,
|
||||
base_url: filterBaseURL.value,
|
||||
}),
|
||||
})
|
||||
</script>
|
||||
@@ -353,8 +501,11 @@ const { exporting, exportFile } = useExport({
|
||||
<!-- Filters -->
|
||||
<ACard :bordered="false" class="mb-4">
|
||||
<ASpace>
|
||||
<AInputNumber v-model="filterID" placeholder="渠道 ID" :min="1" allow-clear style="width: 120px" @change="handleFilter" @clear="handleFilter" />
|
||||
<ASelect v-model="filterType" :options="[{ label: '全部类型', value: '' }, ...providerTypeOptions]" placeholder="供应商类型" allow-clear allow-search style="width: 180px" @change="handleFilter" />
|
||||
<AInput v-model="filterModel" placeholder="搜索模型名..." allow-clear style="width: 160px" @keydown.enter="handleFilter" @clear="handleFilter" />
|
||||
<ASelect v-model="filterStatus" :options="statusOptions" placeholder="状态" allow-clear style="width: 120px" @change="handleFilter" />
|
||||
<AInput v-model="filterBaseURL" placeholder="搜索地址..." allow-clear style="width: 220px" @keydown.enter="handleFilter" @clear="handleFilter" />
|
||||
<AInput v-model="filterSearch" placeholder="搜索渠道名..." allow-clear style="width: 200px" @keydown.enter="handleFilter" @clear="handleFilter" />
|
||||
<AButton @click="handleFilter">搜索</AButton>
|
||||
</ASpace>
|
||||
@@ -362,9 +513,9 @@ const { exporting, exportFile } = useExport({
|
||||
|
||||
<!-- Table -->
|
||||
<ACard :bordered="false">
|
||||
<TableStats :total="pagination.total" />
|
||||
<ATable :columns="columns" :data="data" :loading="loading" :scroll="{ x: 1400 }" :bordered="false" :stripe="true" :pagination="false" row-key="id" />
|
||||
<div class="table-footer">
|
||||
<TableStats :total="pagination.total" />
|
||||
<APagination v-model:current="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-size-options="pagination.pageSizeOptions" show-page-size @change="fetchData" @page-size-change="(s: number) => { pagination.pageSize = s; pagination.current = 1; fetchData() }" />
|
||||
</div>
|
||||
</ACard>
|
||||
@@ -522,11 +673,17 @@ const { exporting, exportFile } = useExport({
|
||||
<style scoped>
|
||||
.table-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--ta-border-light);
|
||||
}
|
||||
/* 统计栏移入底部后,去掉全局样式的下边距,与分页栏垂直居中 */
|
||||
.table-footer :deep(.table-stats) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.scheduling-hint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -272,7 +272,6 @@ onMounted(() => {
|
||||
</ACard>
|
||||
|
||||
<ACard :bordered="false">
|
||||
<TableStats :total="pagination.total" />
|
||||
<ATable
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
@@ -284,6 +283,7 @@ onMounted(() => {
|
||||
:scroll="{ x: 1200 }"
|
||||
/>
|
||||
<div class="table-footer">
|
||||
<TableStats :total="pagination.total" />
|
||||
<APagination
|
||||
v-model:current="pagination.current"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
@@ -347,9 +347,15 @@ onMounted(() => {
|
||||
}
|
||||
.table-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
/* 统计栏移入底部后,去掉全局样式的下边距,与分页栏垂直居中 */
|
||||
.table-footer :deep(.table-stats) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.mb-4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@@ -656,7 +656,6 @@ function resetImport() {
|
||||
|
||||
<!-- Table -->
|
||||
<ACard :bordered="false">
|
||||
<TableStats :total="pagination.total" />
|
||||
<ATable
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
@@ -671,6 +670,7 @@ function resetImport() {
|
||||
@selection-change="handleSelectionChange"
|
||||
/>
|
||||
<div class="table-footer">
|
||||
<TableStats :total="pagination.total" />
|
||||
<APagination
|
||||
v-model:current="pagination.current"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
@@ -833,11 +833,17 @@ function resetImport() {
|
||||
<style scoped>
|
||||
.table-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--ta-border-light);
|
||||
}
|
||||
/* 统计栏移入底部后,去掉全局样式的下边距,与分页栏垂直居中 */
|
||||
.table-footer :deep(.table-stats) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.cap-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
|
||||
@@ -9,6 +9,21 @@ import PageHeader from '@/components/PageHeader.vue'
|
||||
import TableStats from '@/components/TableStats.vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 日期辅助(native,避免引入 dayjs 依赖)
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
function toDateTimeStr(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
|
||||
}
|
||||
// 默认查询当天:起始时间为当天 0 点,截止时间留空(后端按「到现在」实时处理)。
|
||||
// 日期选择器里的「现在」仅用于展示;查询时截止仍为默认「现在」则不传 end_date,避免固定截止时间漏掉后续新记录。
|
||||
const defaultEnd = toDateTimeStr(new Date())
|
||||
function defaultTodayRange(): string[] {
|
||||
const d = new Date()
|
||||
return [`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} 00:00:00`, defaultEnd]
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const data = ref<any[]>([])
|
||||
const pagination = reactive({
|
||||
@@ -22,6 +37,10 @@ const pagination = reactive({
|
||||
const filterStatus = ref<string | null>(null)
|
||||
const filterPlatform = ref<string | null>(null)
|
||||
const filterTaskId = ref('')
|
||||
const filterDateRange = ref<string[]>(defaultTodayRange())
|
||||
const filterModel = ref('')
|
||||
const filterTenantId = ref<number | undefined>(undefined)
|
||||
const filterUserId = ref<number | undefined>(undefined)
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: '' },
|
||||
@@ -156,6 +175,16 @@ async function fetchData() {
|
||||
if (filterStatus.value) params.status = filterStatus.value
|
||||
if (filterPlatform.value) params.platform = filterPlatform.value
|
||||
if (filterTaskId.value) params.public_task_id = filterTaskId.value
|
||||
if (filterDateRange.value && filterDateRange.value.length === 2) {
|
||||
params.start_date = filterDateRange.value[0]
|
||||
// 截止时间为默认「现在」时不传 end_date(后端按「到现在」实时处理),仅手动选择后才显式下发
|
||||
if (filterDateRange.value[1] && filterDateRange.value[1] !== defaultEnd) {
|
||||
params.end_date = filterDateRange.value[1]
|
||||
}
|
||||
}
|
||||
if (filterModel.value) params.model_name = filterModel.value
|
||||
if (filterTenantId.value) params.tenant_id = filterTenantId.value
|
||||
if (filterUserId.value) params.user_id = filterUserId.value
|
||||
const res: any = await request.get('/admin/tasks', { params })
|
||||
const raw = res.data?.data
|
||||
data.value = (raw?.list || []).filter(Boolean)
|
||||
@@ -177,10 +206,19 @@ function resetFilter() {
|
||||
filterStatus.value = null
|
||||
filterPlatform.value = null
|
||||
filterTaskId.value = ''
|
||||
filterDateRange.value = defaultTodayRange()
|
||||
filterModel.value = ''
|
||||
filterTenantId.value = undefined
|
||||
filterUserId.value = undefined
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 刷新:清空所有筛选条件,仅按当天起始时间查询最新记录(截止留空 = 到现在)
|
||||
function handleRefresh() {
|
||||
resetFilter()
|
||||
}
|
||||
|
||||
async function cancelTask(row: any) {
|
||||
try {
|
||||
await request.post(`/admin/tasks/${row.id}/cancel`)
|
||||
@@ -229,6 +267,12 @@ onMounted(() => {
|
||||
<!-- Filters -->
|
||||
<ACard :bordered="false" class="mb-4">
|
||||
<ASpace wrap>
|
||||
<ARangePicker
|
||||
v-model="filterDateRange"
|
||||
show-time
|
||||
style="width: 340px"
|
||||
@change="handleFilter"
|
||||
/>
|
||||
<AInput
|
||||
v-model="filterTaskId"
|
||||
placeholder="任务ID"
|
||||
@@ -236,14 +280,6 @@ onMounted(() => {
|
||||
style="width: 200px"
|
||||
@keydown.enter="handleFilter"
|
||||
/>
|
||||
<ASelect
|
||||
v-model="filterStatus"
|
||||
:options="statusOptions"
|
||||
placeholder="状态"
|
||||
allow-clear
|
||||
style="width: 130px"
|
||||
@change="handleFilter"
|
||||
/>
|
||||
<ASelect
|
||||
v-model="filterPlatform"
|
||||
:options="platformOptions"
|
||||
@@ -252,14 +288,47 @@ onMounted(() => {
|
||||
style="width: 130px"
|
||||
@change="handleFilter"
|
||||
/>
|
||||
<ASelect
|
||||
v-model="filterStatus"
|
||||
:options="statusOptions"
|
||||
placeholder="状态"
|
||||
allow-clear
|
||||
style="width: 130px"
|
||||
@change="handleFilter"
|
||||
/>
|
||||
<AInput
|
||||
v-model="filterModel"
|
||||
placeholder="模型"
|
||||
allow-clear
|
||||
style="width: 160px"
|
||||
@keydown.enter="handleFilter"
|
||||
/>
|
||||
<AInputNumber
|
||||
v-model="filterTenantId"
|
||||
placeholder="租户ID"
|
||||
:min="1"
|
||||
allow-clear
|
||||
style="width: 120px"
|
||||
@change="handleFilter"
|
||||
@clear="handleFilter"
|
||||
/>
|
||||
<AInputNumber
|
||||
v-model="filterUserId"
|
||||
placeholder="用户ID"
|
||||
:min="1"
|
||||
allow-clear
|
||||
style="width: 120px"
|
||||
@change="handleFilter"
|
||||
@clear="handleFilter"
|
||||
/>
|
||||
<AButton type="primary" @click="handleFilter">搜索</AButton>
|
||||
<AButton @click="resetFilter">重置</AButton>
|
||||
<AButton @click="handleRefresh">刷新</AButton>
|
||||
</ASpace>
|
||||
</ACard>
|
||||
|
||||
<!-- Table -->
|
||||
<ACard :bordered="false">
|
||||
<TableStats :total="pagination.total" />
|
||||
<ATable
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
@@ -271,6 +340,7 @@ onMounted(() => {
|
||||
row-key="id"
|
||||
/>
|
||||
<div class="table-footer">
|
||||
<TableStats :total="pagination.total" />
|
||||
<APagination
|
||||
v-model:current="pagination.current"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
@@ -349,11 +419,17 @@ onMounted(() => {
|
||||
<style scoped>
|
||||
.table-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--ta-border-light);
|
||||
}
|
||||
/* 统计栏移入底部后,去掉全局样式的下边距,与分页栏垂直居中 */
|
||||
.table-footer :deep(.table-stats) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-section-title {
|
||||
font-size: 14px;
|
||||
|
||||
@@ -94,7 +94,6 @@ const { exporting, exportFile } = useExport({
|
||||
</PageHeader>
|
||||
|
||||
<ACard :bordered="false" class="mt-4">
|
||||
<TableStats :total="billPagination.total" />
|
||||
<div class="mb-4">
|
||||
<ASpace>
|
||||
<AInput v-model="billFilterTenantId" placeholder="租户ID" allow-clear style="width: 120px" @keydown.enter="fetchBillingRecords" />
|
||||
@@ -103,6 +102,7 @@ const { exporting, exportFile } = useExport({
|
||||
</div>
|
||||
<ATable :columns="billColumns" :data="billData" :loading="billLoading" :scroll="{ x: 1800 }" :bordered="false" :stripe="true" size="small" :pagination="false" row-key="id" />
|
||||
<div class="table-footer">
|
||||
<TableStats :total="billPagination.total" />
|
||||
<APagination v-model:current="billPagination.current" v-model:page-size="billPagination.pageSize" :total="billPagination.total" :page-size-options="billPagination.pageSizeOptions" show-page-size @change="fetchBillingRecords" @page-size-change="(s: number) => { billPagination.pageSize = s; billPagination.current = 1; fetchBillingRecords() }" />
|
||||
</div>
|
||||
</ACard>
|
||||
|
||||
@@ -139,9 +139,9 @@ const { exporting, exportFile } = useExport({
|
||||
</ASpace>
|
||||
</div>
|
||||
</template>
|
||||
<TableStats :total="pagination.total" />
|
||||
<ATable :columns="columns" :data="orders" :loading="loading" row-key="id" :scroll="{ x: 1100 }" :pagination="false" />
|
||||
<div class="table-footer">
|
||||
<TableStats :total="pagination.total" />
|
||||
<APagination v-model:current="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-size-options="pagination.pageSizeOptions" show-page-size @change="fetchOrders" @page-size-change="(s: number) => { pagination.pageSize = s; pagination.current = 1; fetchOrders() }" />
|
||||
</div>
|
||||
</ACard>
|
||||
|
||||
@@ -141,9 +141,9 @@ const { exporting, exportFile } = useExport({
|
||||
<ASelect v-model="statusFilter" :options="statusOptions" style="width: 120px" allow-clear @change="() => { pagination.current = 1; fetchPlans() }" />
|
||||
</div>
|
||||
</template>
|
||||
<TableStats :total="pagination.total" />
|
||||
<ATable :columns="columns" :data="plans" :loading="loading" row-key="id" :scroll="{ x: 1200 }" :pagination="false" />
|
||||
<div class="table-footer">
|
||||
<TableStats :total="pagination.total" />
|
||||
<APagination v-model:current="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-size-options="pagination.pageSizeOptions" show-page-size @change="fetchPlans" @page-size-change="(s: number) => { pagination.pageSize = s; pagination.current = 1; fetchPlans() }" />
|
||||
</div>
|
||||
</ACard>
|
||||
|
||||
@@ -123,9 +123,9 @@ const { exporting, exportFile } = useExport({
|
||||
</PageHeader>
|
||||
|
||||
<ACard :bordered="false">
|
||||
<TableStats :total="pagination.total" />
|
||||
<ATable :columns="columns" :data="promoCodes" :loading="loading" row-key="id" :scroll="{ x: 1200 }" :pagination="false" />
|
||||
<div class="table-footer">
|
||||
<TableStats :total="pagination.total" />
|
||||
<APagination v-model:current="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-size-options="pagination.pageSizeOptions" show-page-size @change="fetchList" @page-size-change="(s: number) => { pagination.pageSize = s; pagination.current = 1; fetchList() }" />
|
||||
</div>
|
||||
</ACard>
|
||||
|
||||
@@ -140,7 +140,6 @@ const { exporting, exportFile } = useExport({
|
||||
<ATabs v-model:active-key="activeTab" @change="onTabChange">
|
||||
<ATabPane key="codes" title="兑换码列表">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<TableStats :total="pagination.total" />
|
||||
<ASelect v-model="statusFilter" :options="[
|
||||
{ label: '全部', value: '' }, { label: '可用', value: 'active' },
|
||||
{ label: '已禁用', value: 'disabled' }, { label: '已过期', value: 'expired' },
|
||||
@@ -148,14 +147,15 @@ const { exporting, exportFile } = useExport({
|
||||
</div>
|
||||
<ATable :columns="columns" :data="redemptions" :loading="loading" row-key="id" :scroll="{ x: 1100 }" :pagination="false" />
|
||||
<div class="table-footer">
|
||||
<TableStats :total="pagination.total" />
|
||||
<APagination v-model:current="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-size-options="pagination.pageSizeOptions" show-page-size @change="fetchRedemptions" @page-size-change="(s: number) => { pagination.pageSize = s; pagination.current = 1; fetchRedemptions() }" />
|
||||
</div>
|
||||
</ATabPane>
|
||||
|
||||
<ATabPane key="usages" title="使用记录">
|
||||
<TableStats :total="usagePagination.total" />
|
||||
<ATable :columns="usageColumns" :data="usages" :loading="usageLoading" row-key="id" :scroll="{ x: 900 }" :pagination="false" />
|
||||
<div class="table-footer">
|
||||
<TableStats :total="usagePagination.total" />
|
||||
<APagination v-model:current="usagePagination.current" v-model:page-size="usagePagination.pageSize" :total="usagePagination.total" :page-size-options="usagePagination.pageSizeOptions" show-page-size @change="fetchUsages" @page-size-change="(s: number) => { usagePagination.pageSize = s; usagePagination.current = 1; fetchUsages() }" />
|
||||
</div>
|
||||
</ATabPane>
|
||||
|
||||
@@ -94,7 +94,6 @@ onMounted(fetchData)
|
||||
<PageHeader title="交易流水" description="查看所有租户的钱包交易记录" />
|
||||
|
||||
<ACard :bordered="false" class="mt-4">
|
||||
<TableStats :total="pagination.total" />
|
||||
<div class="mb-4">
|
||||
<ASpace wrap>
|
||||
<AInput v-model="filterTenantId" placeholder="租户ID" allow-clear style="width: 120px" @keydown.enter="resetAndFetch" />
|
||||
@@ -107,6 +106,7 @@ onMounted(fetchData)
|
||||
</div>
|
||||
<ATable :columns="columns" :data="data" :loading="loading" :scroll="{ x: 1200 }" :bordered="false" :stripe="true" size="small" :pagination="false" row-key="id" />
|
||||
<div class="table-footer">
|
||||
<TableStats :total="pagination.total" />
|
||||
<APagination v-model:current="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-size-options="pagination.pageSizeOptions" show-page-size @change="fetchData" @page-size-change="(s: number) => { pagination.pageSize = s; pagination.current = 1; fetchData() }" />
|
||||
</div>
|
||||
</ACard>
|
||||
|
||||
@@ -201,9 +201,9 @@ onUnmounted(() => {
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<TableStats :total="total" />
|
||||
<a-table :data="data" :columns="columns" :loading="loading" :pagination="false" row-key="id" />
|
||||
<div class="table-footer">
|
||||
<TableStats :total="total" />
|
||||
<a-pagination :current="pagination.current" :page-size="pagination.pageSize" :total="total" @change="handlePageChange" />
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
@@ -237,9 +237,9 @@ onMounted(() => {
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<TableStats :total="total" />
|
||||
<a-table :data="data" :columns="columns" :loading="loading" :pagination="false" row-key="id" />
|
||||
<div class="table-footer">
|
||||
<TableStats :total="total" />
|
||||
<a-pagination :current="pagination.current" :page-size="pagination.pageSize" :total="total" @change="handlePageChange" />
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
@@ -283,7 +283,6 @@ onMounted(() => {
|
||||
|
||||
<!-- Table -->
|
||||
<ACard :bordered="false">
|
||||
<TableStats :total="total" />
|
||||
<ATable
|
||||
:data="data"
|
||||
:loading="loading"
|
||||
@@ -293,6 +292,7 @@ onMounted(() => {
|
||||
:scroll="{ x: 1400 }"
|
||||
/>
|
||||
<div class="table-footer">
|
||||
<TableStats :total="total" />
|
||||
<APagination
|
||||
v-model:current="pagination.current"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
|
||||
@@ -263,7 +263,6 @@ onMounted(() => {
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<TableStats :total="total" />
|
||||
<a-table
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
@@ -272,6 +271,7 @@ onMounted(() => {
|
||||
row-key="id"
|
||||
/>
|
||||
<div class="table-footer">
|
||||
<TableStats :total="total" />
|
||||
<a-pagination
|
||||
v-model:current="pagination.current"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
|
||||
@@ -228,7 +228,6 @@ onMounted(() => {
|
||||
</PageHeader>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<TableStats :total="jobs.length" />
|
||||
<a-table
|
||||
:data="jobs"
|
||||
:columns="columns"
|
||||
@@ -263,6 +262,9 @@ onMounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
</a-table>
|
||||
<div class="table-footer">
|
||||
<TableStats :total="jobs.length" />
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -310,7 +310,6 @@ onMounted(() => {
|
||||
|
||||
<!-- Table -->
|
||||
<a-card :bordered="false">
|
||||
<TableStats :total="total" />
|
||||
<a-table
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
@@ -322,6 +321,7 @@ onMounted(() => {
|
||||
:row-selection="{ type: 'checkbox', showCheckedAll: true, selectedRowKeys: selectedKeys, onSelect: handleSelection }"
|
||||
/>
|
||||
<div class="table-footer">
|
||||
<TableStats :total="total" />
|
||||
<a-pagination
|
||||
:current="pagination.current"
|
||||
:page-size="pagination.pageSize"
|
||||
|
||||
@@ -120,7 +120,6 @@ onMounted(() => {
|
||||
</PageHeader>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<TableStats :total="data.length" />
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
@@ -130,6 +129,9 @@ onMounted(() => {
|
||||
:scroll="{ x: 1200 }"
|
||||
size="medium"
|
||||
/>
|
||||
<div class="table-footer">
|
||||
<TableStats :total="data.length" />
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -267,9 +267,9 @@ onMounted(fetchList)
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<TableStats :total="pagination.total" />
|
||||
<ATable :columns="columns" :data="data" :loading="loading" row-key="id" :scroll="{ x: 1400 }" :pagination="false" />
|
||||
<div class="table-footer">
|
||||
<TableStats :total="pagination.total" />
|
||||
<APagination v-model:current="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-size-options="pagination.pageSizeOptions" show-page-size @change="fetchList" @page-size-change="(s: number) => { pagination.pageSize = s; pagination.current = 1; fetchList() }" />
|
||||
</div>
|
||||
</ACard>
|
||||
|
||||
@@ -212,9 +212,9 @@ onMounted(fetchList)
|
||||
<ASelect v-model="statusFilter" :options="statusOptions" style="width: 120px" allow-clear placeholder="状态筛选" @change="() => { pagination.current = 1; fetchList() }" />
|
||||
</div>
|
||||
</template>
|
||||
<TableStats :total="pagination.total" />
|
||||
<ATable :columns="columns" :data="announcements" :loading="loading" row-key="id" :scroll="{ x: 1400 }" :pagination="false" />
|
||||
<div class="table-footer">
|
||||
<TableStats :total="pagination.total" />
|
||||
<APagination v-model:current="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-size-options="pagination.pageSizeOptions" show-page-size @change="fetchList" @page-size-change="(s: number) => { pagination.pageSize = s; pagination.current = 1; fetchList() }" />
|
||||
</div>
|
||||
</ACard>
|
||||
|
||||
@@ -183,9 +183,9 @@ onMounted(fetchList)
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<TableStats :total="pagination.total" />
|
||||
<ATable :columns="columns" :data="data" :loading="loading" row-key="id" :scroll="{ x: 1200 }" :pagination="false" />
|
||||
<div class="table-footer">
|
||||
<TableStats :total="pagination.total" />
|
||||
<APagination v-model:current="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-size-options="pagination.pageSizeOptions" show-page-size @change="fetchList" @page-size-change="(s: number) => { pagination.pageSize = s; pagination.current = 1; fetchList() }" />
|
||||
</div>
|
||||
</ACard>
|
||||
|
||||
@@ -200,7 +200,6 @@ onMounted(() => {
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<TableStats :total="sendPagination.total" />
|
||||
<a-table
|
||||
:columns="sendColumns"
|
||||
:data="sendData"
|
||||
@@ -213,6 +212,7 @@ onMounted(() => {
|
||||
row-key="id"
|
||||
/>
|
||||
<div class="table-footer">
|
||||
<TableStats :total="sendPagination.total" />
|
||||
<a-pagination
|
||||
v-model:current="sendPagination.current"
|
||||
v-model:page-size="sendPagination.pageSize"
|
||||
@@ -246,7 +246,6 @@ onMounted(() => {
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<TableStats :total="codePagination.total" />
|
||||
<a-table
|
||||
:columns="codeColumns"
|
||||
:data="codeData"
|
||||
@@ -259,6 +258,7 @@ onMounted(() => {
|
||||
row-key="id"
|
||||
/>
|
||||
<div class="table-footer">
|
||||
<TableStats :total="codePagination.total" />
|
||||
<a-pagination
|
||||
v-model:current="codePagination.current"
|
||||
v-model:page-size="codePagination.pageSize"
|
||||
|
||||
@@ -228,9 +228,9 @@ onMounted(() => { fetchStats(); fetchList() })
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<TableStats :total="pagination.total" />
|
||||
<ATable :columns="columns" :data="data" :loading="loading" row-key="id" :scroll="{ x: 1300 }" :pagination="false" />
|
||||
<div class="table-footer">
|
||||
<TableStats :total="pagination.total" />
|
||||
<APagination v-model:current="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-size-options="pagination.pageSizeOptions" show-page-size @change="fetchList" @page-size-change="(s: number) => { pagination.pageSize = s; pagination.current = 1; fetchList() }" />
|
||||
</div>
|
||||
</ACard>
|
||||
|
||||
@@ -326,9 +326,9 @@ onMounted(fetchList)
|
||||
<ASelect v-model="typeFilter" :options="typeOptions" style="width: 120px" allow-clear placeholder="类型筛选" @change="() => { pagination.current = 1; fetchList() }" />
|
||||
</div>
|
||||
</template>
|
||||
<TableStats :total="pagination.total" />
|
||||
<ATable :columns="columns" :data="messages" :loading="loading" row-key="id" :scroll="{ x: 1100 }" :pagination="false" />
|
||||
<div class="table-footer">
|
||||
<TableStats :total="pagination.total" />
|
||||
<APagination v-model:current="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-size-options="pagination.pageSizeOptions" show-page-size @change="fetchList" @page-size-change="(s: number) => { pagination.pageSize = s; pagination.current = 1; fetchList() }" />
|
||||
</div>
|
||||
</ACard>
|
||||
|
||||
@@ -151,9 +151,9 @@ onMounted(fetchTemplates)
|
||||
</PageHeader>
|
||||
|
||||
<ACard :bordered="false">
|
||||
<TableStats :total="pagination.total" />
|
||||
<ATable :columns="columns" :data="templates" :loading="loading" row-key="code" :scroll="{ x: 1000 }" :pagination="false" />
|
||||
<div class="table-footer">
|
||||
<TableStats :total="pagination.total" />
|
||||
<APagination v-model:current="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-size-options="pagination.pageSizeOptions" show-page-size @change="fetchTemplates" @page-size-change="(s: number) => { pagination.pageSize = s; pagination.current = 1; fetchTemplates() }" />
|
||||
</div>
|
||||
</ACard>
|
||||
|
||||
@@ -288,9 +288,9 @@ onMounted(fetchTickets)
|
||||
</ASpace>
|
||||
</div>
|
||||
</template>
|
||||
<TableStats :total="pagination.total" />
|
||||
<ATable :columns="columns" :data="tickets" :loading="loading" row-key="id" :scroll="{ x: 1300 }" :pagination="false" />
|
||||
<div class="table-footer">
|
||||
<TableStats :total="pagination.total" />
|
||||
<APagination v-model:current="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-size-options="pagination.pageSizeOptions" show-page-size @change="fetchTickets" @page-size-change="(s: number) => { pagination.pageSize = s; pagination.current = 1; fetchTickets() }" />
|
||||
</div>
|
||||
</ACard>
|
||||
|
||||
@@ -218,7 +218,6 @@ const { exporting, exportFile } = useExport({
|
||||
<!-- Tab 1: Operation Logs -->
|
||||
<ATabPane key="logs" title="操作日志">
|
||||
<ACard :bordered="false" class="mt-4">
|
||||
<TableStats :total="opPagination.total" />
|
||||
<div class="mb-4">
|
||||
<ASpace wrap>
|
||||
<AInput
|
||||
@@ -255,6 +254,7 @@ const { exporting, exportFile } = useExport({
|
||||
row-key="id"
|
||||
/>
|
||||
<div class="table-footer">
|
||||
<TableStats :total="opPagination.total" />
|
||||
<APagination
|
||||
v-model:current="opPagination.current"
|
||||
v-model:page-size="opPagination.pageSize"
|
||||
@@ -271,7 +271,6 @@ const { exporting, exportFile } = useExport({
|
||||
<!-- Tab 2: Sensitive Access Logs -->
|
||||
<ATabPane key="sensitive" title="敏感访问日志">
|
||||
<ACard :bordered="false" class="mt-4">
|
||||
<TableStats :total="sensPagination.total" />
|
||||
<div class="mb-4">
|
||||
<ASpace wrap>
|
||||
<AInput
|
||||
@@ -308,6 +307,7 @@ const { exporting, exportFile } = useExport({
|
||||
row-key="id"
|
||||
/>
|
||||
<div class="table-footer">
|
||||
<TableStats :total="sensPagination.total" />
|
||||
<APagination
|
||||
v-model:current="sensPagination.current"
|
||||
v-model:page-size="sensPagination.pageSize"
|
||||
|
||||
@@ -149,7 +149,6 @@ function onDateChange(dateString: string | undefined, type: 'start' | 'end') {
|
||||
</ACard>
|
||||
|
||||
<ACard :bordered="false">
|
||||
<TableStats :total="pagination.total" />
|
||||
<ATable
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
@@ -193,6 +192,7 @@ function onDateChange(dateString: string | undefined, type: 'start' | 'end') {
|
||||
</template>
|
||||
</ATable>
|
||||
<div class="table-footer">
|
||||
<TableStats :total="pagination.total" />
|
||||
<APagination
|
||||
v-model:current="pagination.current"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
|
||||
@@ -58,9 +58,6 @@ onMounted(fetchPermissions)
|
||||
</PageHeader>
|
||||
|
||||
<ACard :bordered="false">
|
||||
<TableStats :total="groups.length">
|
||||
<span>{{ groups.reduce((s, g) => s + g.permissions.length, 0) }} 个权限点</span>
|
||||
</TableStats>
|
||||
<ATable
|
||||
:columns="columns"
|
||||
:data="groups"
|
||||
@@ -70,6 +67,11 @@ onMounted(fetchPermissions)
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
/>
|
||||
<div class="table-footer">
|
||||
<TableStats :total="groups.length">
|
||||
<span>{{ groups.reduce((s, g) => s + g.permissions.length, 0) }} 个权限点</span>
|
||||
</TableStats>
|
||||
</div>
|
||||
</ACard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,6 +10,21 @@ import TableStats from '@/components/TableStats.vue'
|
||||
import ForwardingTracePanel from '@/components/ForwardingTracePanel.vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 日期辅助(native,避免引入 dayjs 依赖)
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
function toDateTimeStr(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
|
||||
}
|
||||
// 默认查询当天:起始时间为当天 0 点,截止时间留空(后端按「到现在」实时处理)。
|
||||
// 日期选择器里的「现在」仅用于展示;查询时截止仍为默认「现在」则不传 end_date,避免固定截止时间漏掉后续新记录。
|
||||
const defaultEnd = toDateTimeStr(new Date())
|
||||
function defaultTodayRange(): string[] {
|
||||
const d = new Date()
|
||||
return [`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} 00:00:00`, defaultEnd]
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -22,14 +37,12 @@ const pagination = reactive({
|
||||
pageSizeOptions: [10, 20, 50],
|
||||
})
|
||||
const filter = reactive({
|
||||
tenant_id: null as number | null,
|
||||
api_key_id: '',
|
||||
username: '',
|
||||
id: null as number | null,
|
||||
request_id: '',
|
||||
task_id: '',
|
||||
method: '',
|
||||
status_code: '',
|
||||
date_range: [] as string[],
|
||||
tenant_id: null as number | null,
|
||||
user_id: null as number | null,
|
||||
api_key_id: '',
|
||||
date_range: defaultTodayRange(),
|
||||
})
|
||||
|
||||
const tenantOptions = ref<{ label: string; value: number }[]>([])
|
||||
@@ -197,16 +210,17 @@ async function fetchData() {
|
||||
page: pagination.current,
|
||||
page_size: pagination.pageSize,
|
||||
}
|
||||
if (filter.tenant_id) params.tenant_id = filter.tenant_id
|
||||
if (filter.api_key_id) params.api_key_id = parseInt(filter.api_key_id)
|
||||
if (filter.username) params.username = filter.username
|
||||
if (filter.method) params.method = filter.method
|
||||
if (filter.id) params.id = filter.id
|
||||
if (filter.request_id) params.request_id = filter.request_id
|
||||
if (filter.task_id) params.task_id = filter.task_id
|
||||
if (filter.status_code) params.status_code = parseInt(filter.status_code)
|
||||
if (filter.tenant_id) params.tenant_id = filter.tenant_id
|
||||
if (filter.user_id) params.user_id = filter.user_id
|
||||
if (filter.api_key_id) params.api_key_id = parseInt(filter.api_key_id)
|
||||
if (filter.date_range.length === 2) {
|
||||
params.start_date = filter.date_range[0]
|
||||
params.end_date = filter.date_range[1]
|
||||
// 截止时间为默认「现在」时不传 end_date(后端按「到现在」实时处理),仅手动选择后才显式下发
|
||||
if (filter.date_range[1] && filter.date_range[1] !== defaultEnd) {
|
||||
params.end_date = filter.date_range[1]
|
||||
}
|
||||
}
|
||||
const res: any = await request.get('/admin/audit/request-logs', { params })
|
||||
const raw = res.data?.data
|
||||
@@ -246,27 +260,27 @@ function handleFilter() {
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filter.tenant_id = null
|
||||
filter.api_key_id = ''
|
||||
filter.username = ''
|
||||
filter.id = null
|
||||
filter.request_id = ''
|
||||
filter.task_id = ''
|
||||
filter.method = ''
|
||||
filter.status_code = ''
|
||||
filter.date_range = []
|
||||
filter.tenant_id = null
|
||||
filter.user_id = null
|
||||
filter.api_key_id = ''
|
||||
filter.date_range = defaultTodayRange()
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 刷新:清空所有筛选条件,仅按当天起始时间查询最新记录(截止留空 = 到现在)
|
||||
function handleRefresh() {
|
||||
handleReset()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchTenantOptions('')
|
||||
const route = useRoute()
|
||||
if (route.query.request_id) {
|
||||
filter.request_id = String(route.query.request_id)
|
||||
}
|
||||
if (route.query.task_id) {
|
||||
filter.task_id = String(route.query.task_id)
|
||||
}
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
@@ -277,6 +291,28 @@ onMounted(() => {
|
||||
|
||||
<a-card :bordered="false" class="mb-4">
|
||||
<a-space wrap>
|
||||
<a-range-picker
|
||||
v-model="filter.date_range"
|
||||
show-time
|
||||
style="width: 340px"
|
||||
@change="handleFilter"
|
||||
/>
|
||||
<a-input-number
|
||||
v-model="filter.id"
|
||||
placeholder="日志ID"
|
||||
:min="1"
|
||||
allow-clear
|
||||
style="width: 120px"
|
||||
@change="handleFilter"
|
||||
@clear="handleFilter"
|
||||
/>
|
||||
<a-input
|
||||
v-model="filter.request_id"
|
||||
placeholder="Request ID"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@keydown.enter="handleFilter"
|
||||
/>
|
||||
<a-select
|
||||
v-model="filter.tenant_id"
|
||||
:options="tenantOptions"
|
||||
@@ -289,6 +325,15 @@ onMounted(() => {
|
||||
@change="handleFilter"
|
||||
@clear="handleFilter"
|
||||
/>
|
||||
<a-input-number
|
||||
v-model="filter.user_id"
|
||||
placeholder="用户ID"
|
||||
:min="1"
|
||||
allow-clear
|
||||
style="width: 120px"
|
||||
@change="handleFilter"
|
||||
@clear="handleFilter"
|
||||
/>
|
||||
<a-input
|
||||
v-model="filter.api_key_id"
|
||||
placeholder="Key ID"
|
||||
@@ -296,57 +341,13 @@ onMounted(() => {
|
||||
style="width: 100px"
|
||||
@keydown.enter="handleFilter"
|
||||
/>
|
||||
<a-input
|
||||
v-model="filter.username"
|
||||
placeholder="用户名"
|
||||
allow-clear
|
||||
style="width: 120px"
|
||||
@keydown.enter="handleFilter"
|
||||
/>
|
||||
<a-input
|
||||
v-model="filter.request_id"
|
||||
placeholder="Request ID"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@keydown.enter="handleFilter"
|
||||
/>
|
||||
<a-input
|
||||
v-model="filter.task_id"
|
||||
placeholder="Task ID"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@keydown.enter="handleFilter"
|
||||
/>
|
||||
<a-select
|
||||
v-model="filter.method"
|
||||
placeholder="方法"
|
||||
allow-clear
|
||||
style="width: 100px"
|
||||
>
|
||||
<a-option value="GET">GET</a-option>
|
||||
<a-option value="POST">POST</a-option>
|
||||
<a-option value="PUT">PUT</a-option>
|
||||
<a-option value="DELETE">DELETE</a-option>
|
||||
</a-select>
|
||||
<a-input
|
||||
v-model="filter.status_code"
|
||||
placeholder="状态码"
|
||||
allow-clear
|
||||
style="width: 100px"
|
||||
@keydown.enter="handleFilter"
|
||||
/>
|
||||
<a-range-picker
|
||||
v-model="filter.date_range"
|
||||
style="width: 260px"
|
||||
format="YYYY-MM-DD"
|
||||
/>
|
||||
<a-button type="primary" @click="handleFilter">搜索</a-button>
|
||||
<a-button @click="handleReset">重置</a-button>
|
||||
<a-button @click="handleRefresh">刷新</a-button>
|
||||
</a-space>
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<TableStats :total="pagination.total" />
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
@@ -359,6 +360,7 @@ onMounted(() => {
|
||||
row-key="id"
|
||||
/>
|
||||
<div class="table-footer">
|
||||
<TableStats :total="pagination.total" />
|
||||
<a-pagination
|
||||
v-model:current="pagination.current"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
@@ -444,11 +446,17 @@ onMounted(() => {
|
||||
<style scoped>
|
||||
.table-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--color-border-light, #e5e6eb);
|
||||
}
|
||||
/* 统计栏移入底部后,去掉全局样式的下边距,与分页栏垂直居中 */
|
||||
.table-footer :deep(.table-stats) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.audit-body {
|
||||
background: var(--color-fill-2, #f7f8fa);
|
||||
padding: 12px;
|
||||
|
||||
@@ -158,7 +158,6 @@ onMounted(() => {
|
||||
</ACard>
|
||||
|
||||
<ACard :bordered="false">
|
||||
<TableStats :total="pagination.total" />
|
||||
<ATable
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
@@ -170,6 +169,7 @@ onMounted(() => {
|
||||
:pagination="false"
|
||||
/>
|
||||
<div class="table-footer">
|
||||
<TableStats :total="pagination.total" />
|
||||
<APagination
|
||||
v-model:current="pagination.current"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
|
||||
@@ -179,7 +179,6 @@ function onDateChange(dateString: string | undefined, type: 'start' | 'end') {
|
||||
</ACard>
|
||||
|
||||
<ACard :bordered="false">
|
||||
<TableStats :total="pagination.total" />
|
||||
<ATable
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
@@ -224,6 +223,7 @@ function onDateChange(dateString: string | undefined, type: 'start' | 'end') {
|
||||
</template>
|
||||
</ATable>
|
||||
<div class="table-footer">
|
||||
<TableStats :total="pagination.total" />
|
||||
<APagination
|
||||
v-model:current="pagination.current"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
|
||||
@@ -465,7 +465,6 @@ onMounted(() => {
|
||||
|
||||
<!-- 列表 -->
|
||||
<ACard :bordered="false">
|
||||
<TableStats :total="pagination.total" />
|
||||
<ASpace wrap style="margin-bottom:16px">
|
||||
<AInput v-model="filter.tenant_id" placeholder="租户ID" allow-clear style="width:110px" />
|
||||
<AInput v-model="filter.user_id" placeholder="上传者ID" allow-clear style="width:110px" />
|
||||
@@ -483,6 +482,7 @@ onMounted(() => {
|
||||
:pagination="false" row-key="id"
|
||||
/>
|
||||
<div class="table-footer">
|
||||
<TableStats :total="pagination.total" />
|
||||
<APagination
|
||||
v-model:current="pagination.current"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
|
||||
@@ -159,9 +159,9 @@ onMounted(() => { fetchCategories() })
|
||||
</Space>
|
||||
</div>
|
||||
</template>
|
||||
<TableStats :total="pagination.total" />
|
||||
<ATable :columns="columns" :data="articles" :loading="loading" row-key="id" :scroll="{ x: 1300 }" :pagination="false" />
|
||||
<div class="table-footer">
|
||||
<TableStats :total="pagination.total" />
|
||||
<APagination v-model:current="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" show-page-size @change="fetchList" @page-size-change="(s: number) => { pagination.pageSize = s; pagination.current = 1; fetchList() }" />
|
||||
</div>
|
||||
</ACard>
|
||||
|
||||
@@ -86,9 +86,9 @@ onMounted(fetchList)
|
||||
</template>
|
||||
</PageHeader>
|
||||
<ACard :bordered="false">
|
||||
<TableStats :total="pagination.total" />
|
||||
<ATable :columns="columns" :data="categories" :loading="loading" row-key="id" :scroll="{ x: 1100 }" :pagination="false" />
|
||||
<div class="table-footer">
|
||||
<TableStats :total="pagination.total" />
|
||||
<APagination v-model:current="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" show-page-size @change="fetchList" @page-size-change="(s: number) => { pagination.pageSize = s; pagination.current = 1; fetchList() }" />
|
||||
</div>
|
||||
</ACard>
|
||||
|
||||
@@ -305,7 +305,6 @@ onMounted(() => {
|
||||
|
||||
<!-- Table -->
|
||||
<ACard :bordered="false">
|
||||
<TableStats :total="data.length" />
|
||||
<ATable
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
@@ -319,6 +318,9 @@ onMounted(() => {
|
||||
<AEmpty v-if="!loading && data.length === 0" style="margin-top: 40px">
|
||||
<template #description>暂无已注册的插件</template>
|
||||
</AEmpty>
|
||||
<div class="table-footer">
|
||||
<TableStats :total="data.length" />
|
||||
</div>
|
||||
</ACard>
|
||||
|
||||
<!-- Config Modal -->
|
||||
|
||||
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user