- 渠道支持分协议 base_url(chat/responses/messages 各一), 网关按协议选 base 直通 (如智谱三种格式不同 base, 一个渠道即可), UpstreamURL 按 proto 拼接 - 渠道模型改为下方抽屉: 当前绑定列表(内联改上游/解除)、从接口拉取(remote 预览+勾选添加)、手动添加 - 新增 /channels/:id/models/remote 预览接口; 操作按钮加 Phosphor 图标 - 修复 formats jsonb 更新未序列化问题; 手机端渠道卡片化 Co-Authored-By: Claude <noreply@anthropic.com>
266 lines
12 KiB
Go
266 lines
12 KiB
Go
// Package store 数据模型与仓储层(GORM)。
|
||
// 字段设计对应 PLANNING.md §6:金额/价格 numeric(20,8),token bigint,时间 UTC。
|
||
package store
|
||
|
||
import (
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// 角色 / 状态枚举(字符串存库,便于阅读与迁移)
|
||
const (
|
||
RoleUser = "user"
|
||
RoleAdmin = "admin"
|
||
|
||
UserStatusActive = "active"
|
||
UserStatusDisabled = "disabled"
|
||
|
||
KeyStatusActive = "active"
|
||
KeyStatusRevoked = "revoked"
|
||
|
||
ChannelProviderOpenAI = "openai"
|
||
ChannelProviderAnthropic = "anthropic"
|
||
ChannelProviderCompatible = "compatible"
|
||
ChannelHealthHealthy = "healthy"
|
||
ChannelHealthDegraded = "degraded"
|
||
ChannelHealthCooldown = "cooldown"
|
||
|
||
// 渠道原生支持的协议格式
|
||
FormatChat = "chat" // OpenAI Chat Completions
|
||
FormatResponses = "responses" // OpenAI Responses API
|
||
FormatMessages = "messages" // Anthropic Messages
|
||
|
||
UsageStatusSuccess = "success"
|
||
UsageStatusError = "error"
|
||
UsageStatusCanceled = "canceled"
|
||
|
||
BalanceTypeRecharge = "recharge"
|
||
BalanceTypeUsage = "usage"
|
||
BalanceTypeRefund = "refund"
|
||
BalanceTypeAdminAdjust = "admin_adjust"
|
||
|
||
RechargeStatusPending = "pending"
|
||
RechargeStatusCredited = "credited"
|
||
RechargeStatusRejected = "rejected"
|
||
RechargeMethodManual = "manual"
|
||
RechargeMethodOnline = "online"
|
||
)
|
||
|
||
// User 用户(PLANNING §6.1)
|
||
type User struct {
|
||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||
Username string `gorm:"uniqueIndex;size:64;not null" json:"username"`
|
||
Email string `gorm:"uniqueIndex;size:255;not null" json:"email"`
|
||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||
Role string `gorm:"size:16;not null;default:user" json:"role"`
|
||
Balance float64 `gorm:"type:numeric(20,8);not null;default:0" json:"balance"`
|
||
Status string `gorm:"size:16;not null;default:active" json:"status"`
|
||
AllowedModels []string `gorm:"type:jsonb;serializer:json" json:"allowed_models,omitempty"` // 用户级模型白名单(空=不限制)
|
||
DeniedModels []string `gorm:"type:jsonb;serializer:json" json:"denied_models,omitempty"` // 用户级模型黑名单
|
||
InviteCode *string `json:"invite_code,omitempty"`
|
||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
// APIKey 密钥(PLANNING §6.2):库中只存 SHA-256 哈希 + 展示前缀
|
||
type APIKey struct {
|
||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||
UserID uint64 `gorm:"index;not null" json:"user_id"`
|
||
Name string `gorm:"size:64;not null" json:"name"`
|
||
KeyHash string `gorm:"uniqueIndex;size:64;not null" json:"-"`
|
||
KeyPrefix string `gorm:"size:32;not null" json:"key_prefix"`
|
||
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day,omitempty"`
|
||
QuotaRequestsPerDay *int `json:"quota_requests_per_day,omitempty"`
|
||
AllowedModels []string `gorm:"type:jsonb;serializer:json" json:"allowed_models,omitempty"`
|
||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||
Status string `gorm:"size:16;not null;default:active" json:"status"`
|
||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
// Channel 上游渠道(PLANNING §6.3)
|
||
type Channel struct {
|
||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||
Name string `gorm:"uniqueIndex;size:64;not null" json:"name"`
|
||
Provider string `gorm:"size:16;not null" json:"provider"` // openai|anthropic|compatible(供应商/默认格式)
|
||
Formats []string `gorm:"type:jsonb;serializer:json" json:"formats,omitempty"` // 原生支持的协议格式 chat|responses|messages
|
||
BaseURL string `gorm:"size:255;not null" json:"base_url"`
|
||
BaseURLs map[string]string `gorm:"type:jsonb;serializer:json" json:"base_urls,omitempty"` // 分协议 base_url 覆盖(chat/responses/messages)
|
||
APIKeyEnc string `gorm:"size:1024;not null" json:"-"` // AES-GCM 密文
|
||
Weight int `gorm:"not null;default:1" json:"weight"`
|
||
Priority int `gorm:"not null;default:0" json:"priority"` // 数值小优先
|
||
TimeoutMS int `gorm:"not null;default:120000" json:"timeout_ms"`
|
||
MaxConcurrency int `gorm:"not null;default:16" json:"max_concurrency"`
|
||
HealthStatus string `gorm:"size:16;not null;default:healthy" json:"health_status"`
|
||
Enabled bool `gorm:"not null;default:true" json:"enabled"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
// FormatsEffective 返回渠道实际支持的原生协议;未显式配置时按 provider 推断。
|
||
func (c *Channel) FormatsEffective() []string {
|
||
if len(c.Formats) > 0 {
|
||
return c.Formats
|
||
}
|
||
switch c.Provider {
|
||
case ChannelProviderAnthropic:
|
||
return []string{FormatMessages}
|
||
case ChannelProviderOpenAI:
|
||
return []string{FormatChat, FormatResponses}
|
||
default: // compatible
|
||
return []string{FormatChat}
|
||
}
|
||
}
|
||
|
||
// versionSegRe 匹配末尾版本前缀,如 /v1、/v2、/v4。
|
||
var versionSegRe = regexp.MustCompile(`/v[0-9]+/?$`)
|
||
|
||
// UpstreamURL 按协议选 base_url(分协议覆盖优先),再按版本前缀拼资源路径(path 不含 /v1)。
|
||
// - proto 有 BaseURLs 覆盖时用覆盖值,否则用主 BaseURL
|
||
// - base 已以资源路径结尾 → 原样
|
||
// - base 含版本前缀(如 /v1、/v4) → base + path
|
||
// - 否则 → base + /v1 + path(默认补 OpenAI/Anthropic 的 /v1)
|
||
func (c *Channel) UpstreamURL(proto, path string) string {
|
||
base := c.BaseURL
|
||
if len(c.BaseURLs) > 0 && c.BaseURLs[proto] != "" {
|
||
base = c.BaseURLs[proto]
|
||
}
|
||
base = strings.TrimRight(base, "/")
|
||
if base == "" {
|
||
return path
|
||
}
|
||
if strings.HasSuffix(base, path) {
|
||
return base
|
||
}
|
||
if versionSegRe.MatchString(base) {
|
||
return base + path
|
||
}
|
||
return base + "/v1" + path
|
||
}
|
||
|
||
// Model 全局模型 + 定价(PLANNING §6.4,价格按每百万 token,USD)
|
||
type Model struct {
|
||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||
Name string `gorm:"uniqueIndex;size:128;not null" json:"name"`
|
||
DisplayName string `gorm:"size:128" json:"display_name"`
|
||
InputPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"input_price"`
|
||
OutputPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"output_price"`
|
||
CacheReadPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"cache_read_price"`
|
||
Enabled bool `gorm:"not null;default:true" json:"enabled"`
|
||
Sort int `gorm:"not null;default:0" json:"sort"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
// ChannelModelBinding 渠道↔模型绑定(多对多,PLANNING §6.4)
|
||
type ChannelModelBinding struct {
|
||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||
ChannelID uint64 `gorm:"index:idx_ch_model,unique;not null" json:"channel_id"`
|
||
ModelID uint64 `gorm:"index:idx_ch_model,unique;not null" json:"model_id"`
|
||
UpstreamModel string `gorm:"size:255;not null" json:"upstream_model"`
|
||
Weight int `gorm:"not null;default:1" json:"weight"`
|
||
Channel Channel `gorm:"foreignKey:ChannelID" json:"-"`
|
||
Model Model `gorm:"foreignKey:ModelID" json:"-"`
|
||
}
|
||
|
||
// UsageLog 请求级用量明细(PLANNING §6.5)
|
||
type UsageLog struct {
|
||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||
RequestID string `gorm:"size:128" json:"request_id"` // 上游 request id
|
||
TraceID string `gorm:"size:64;index" json:"trace_id"`
|
||
UserID uint64 `gorm:"index:idx_user_created;not null" json:"user_id"`
|
||
KeyID uint64 `json:"key_id"`
|
||
ChannelID uint64 `json:"channel_id"`
|
||
ModelID uint64 `json:"model_id"`
|
||
ModelName string `gorm:"size:128" json:"model_name"`
|
||
Protocol string `gorm:"size:32" json:"protocol"` // responses|chat|messages
|
||
InputTokens int64 `json:"input_tokens"`
|
||
OutputTokens int64 `json:"output_tokens"`
|
||
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||
CacheCreationTokens int64 `json:"cache_creation_tokens"`
|
||
InputPrice float64 `gorm:"type:numeric(20,8)" json:"input_price"` // 快照
|
||
OutputPrice float64 `gorm:"type:numeric(20,8)" json:"output_price"` // 快照
|
||
CacheReadPrice float64 `gorm:"type:numeric(20,8)" json:"cache_read_price"` // 快照
|
||
Cost float64 `gorm:"type:numeric(20,8)" json:"cost"`
|
||
LatencyMS int `json:"latency_ms"`
|
||
Status string `gorm:"size:16;not null" json:"status"`
|
||
ErrorCode *string `json:"error_code,omitempty"`
|
||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||
}
|
||
|
||
// UsageDaily 日粒度预聚合(PLANNING §6.6)
|
||
type UsageDaily struct {
|
||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||
UserID uint64 `gorm:"index:idx_daily_user_model,unique" json:"user_id"`
|
||
ModelID uint64 `gorm:"index:idx_daily_user_model,unique" json:"model_id"`
|
||
Date string `gorm:"size:10;index:idx_daily_user_model,unique" json:"date"` // YYYY-MM-DD (UTC)
|
||
Requests int64 `json:"requests"`
|
||
InputTokens int64 `json:"input_tokens"`
|
||
OutputTokens int64 `json:"output_tokens"`
|
||
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||
Cost float64 `gorm:"type:numeric(20,8)" json:"cost"`
|
||
}
|
||
|
||
// RechargeOrder 充值订单(PLANNING §6.7,预留:首版不做充值)
|
||
type RechargeOrder struct {
|
||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||
UserID uint64 `gorm:"index;not null" json:"user_id"`
|
||
Amount float64 `gorm:"type:numeric(20,8);not null" json:"amount"`
|
||
Status string `gorm:"size:16;not null;default:pending" json:"status"`
|
||
Method string `gorm:"size:16;not null;default:manual" json:"method"`
|
||
TransactionID string `gorm:"size:128" json:"transaction_id,omitempty"`
|
||
ReviewedBy *uint64 `json:"reviewed_by,omitempty"`
|
||
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
|
||
Remark string `gorm:"size:512" json:"remark,omitempty"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
// BalanceLog 余额流水(PLANNING §6.8,幂等:ref_id + type 唯一)
|
||
type BalanceLog struct {
|
||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||
UserID uint64 `gorm:"index:idx_balance_user;not null" json:"user_id"`
|
||
Change float64 `gorm:"type:numeric(20,8);not null" json:"change"`
|
||
BalanceAfter float64 `gorm:"type:numeric(20,8);not null" json:"balance_after"`
|
||
Type string `gorm:"size:16;not null" json:"type"`
|
||
RefID string `gorm:"size:128;index:idx_balance_ref,unique" json:"ref_id"`
|
||
Remark string `gorm:"size:512" json:"remark,omitempty"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
}
|
||
|
||
// Passkey WebAuthn 凭据(passkey 绑定/登录)
|
||
type Passkey struct {
|
||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||
UserID uint64 `gorm:"index;not null" json:"user_id"`
|
||
Name string `gorm:"size:64" json:"name"`
|
||
CredentialID []byte `gorm:"size:255;not null" json:"-"` // credential.ID
|
||
Credential []byte `gorm:"type:blob;not null" json:"-"` // json.Marshal(webauthn.Credential)
|
||
CreatedAt time.Time `json:"created_at"`
|
||
}
|
||
|
||
// SystemConfig 系统配置(PLANNING §6.9)
|
||
type SystemConfig struct {
|
||
Key string `gorm:"primaryKey;size:64" json:"key"`
|
||
Value string `gorm:"type:jsonb;not null" json:"value"`
|
||
}
|
||
|
||
// AllModels 迁移顺序(外键依赖在后)
|
||
func AllModels() []any {
|
||
return []any{
|
||
&User{},
|
||
&APIKey{},
|
||
&Channel{},
|
||
&Model{},
|
||
&ChannelModelBinding{},
|
||
&UsageLog{},
|
||
&UsageDaily{},
|
||
&RechargeOrder{},
|
||
&BalanceLog{},
|
||
&Passkey{},
|
||
&SystemConfig{},
|
||
}
|
||
}
|