diff --git a/CHANGELOG.md b/CHANGELOG.md index 218f0e8..61fc683 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added — M2 MVP β SaaS(进行中) + +- 入口协议互转:Anthropic `/v1/messages`、Gemini `/v1beta/models/:model/generateContent`(均含流式)入口 + IR 归一化,任意入口可路由任意上游 +- 适配器追加至 18 家(M2a 新增 Groq / Mistral / 零一万物 / OpenRouter / Hugging Face / MiniMax / 腾讯混元;M2b 新增 Cohere / 讯飞星火,均走 OpenAI 兼容端点) +- 多模态:图像生成 / TTS / STT / Embeddings 接入 relay +- OAuth 六家完整(新增 Google / 微信 / 飞书 / 钉钉 / Discord) +- 在线支付:Stripe / 支付宝 / 微信支付,统一 provider 抽象 +- 邀请返佣:邀请码 / 邀请记录 / 返佣汇总 +- 账号池:上游账号统一纳管 + 与渠道绑定 +- 计费集成:relay 挂分组倍率中间件,Biller / Pricing / Log 依赖注入接线 +- 前端:用户前台 Playground / 模型广场 / 邀请页;后台账号池 / 分组 / 定价页;中英双语 i18n + +> 未完成(M2 收尾):异步任务系统(asynq)+ Midjourney / Suno;账号池 OAuth 拉号(PKCE)。 + +### Added — M1 MVP α 核心闭环 + +- 用户系统:邮箱密码注册登录、邮箱验证码、GitHub OAuth、Session + Redis +- API 令牌:生成 / 吊销 / 限额 / IP 与模型白名单 / 过期 +- 适配器层 9 家:OpenAI / Azure / Anthropic / Gemini / DeepSeek / Moonshot / 智谱 / 通义 / 豆包 +- 渠道:CRUD + 优先级 + 权重 + 模型映射 + 熔断状态机 + 故障转移重试 +- 计费:Redis Lua 预扣 / 提交 / 退款,模型倍率 + 分组倍率 +- 日志:请求 / 消费明细 + 错误日志独立;审计日志 +- 限流:用户 / 令牌 / IP / 模型 多维度滑动窗口(Lua) +- 公告系统;系统设置(KV 运行时热更) +- 后台前端(Naive UI)与用户前台全页面 +- 支付:手动充值审核 + 兑换码 + ### Added — M0 工程脚手架 - Go 后端骨架(Gin / Viper / zap / GORM 双库 / Redis / Prometheus / healthz) diff --git a/README.md b/README.md index 1fbf209..546cec1 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,26 @@ All-in-one LLM API Gateway · 3-protocol interop · 18+ providers · Enterprise ## Status -🚧 M0 scaffolding stage — only the skeleton runs. Business features will land progressively from M1. +🚧 **In development · ~M2** (M0 / M1 done, most of M2 in place, M3 not started). The full local loop works: register → create token → call via any of the 3 protocols → bill → view logs → top up. + +**Ready** + +- **3-protocol ingress + interop**: OpenAI `/v1`, Anthropic `/v1/messages`, Gemini `/v1beta` — any ingress can route to any upstream +- **18 upstream adapters**: openai / azure / anthropic / gemini / deepseek / moonshot / zhipu / qwen / doubao / groq / mistral / yi / openrouter / huggingface / minimax / tencent / cohere / xunfei +- **Multimodal**: chat (incl. streaming) · image generation · TTS · STT · embeddings +- **Billing**: Redis Lua reserve / commit / refund · per-model ratio · per-group ratio +- **Channels**: CRUD + priority + weight + model mapping + circuit breaker; account pool +- **Rate limiting**: user / token / IP / model / group dimensions +- **Auth**: email+password + email code + session; 6 OAuth providers (GitHub / Google / WeChat / Feishu / DingTalk / Discord) +- **Payments**: Stripe / Alipay / WeChat Pay + manual top-up + redeem codes +- **Invites & commission** · **announcements** · **system settings** · **audit** +- **Frontend**: admin (full Naive UI pages) + user portal (Playground / model catalog / invites) + docs site; bilingual zh/en + +**In progress / not yet done** + +- Async task system (asynq) and Midjourney / Suno +- Account-pool OAuth onboarding (PKCE flow) +- M3 enterprise: SSO (OIDC / SAML / LDAP / CAS), department budgets, audit visualization, OpenTelemetry See the roadmap at [`docs/superpowers/2026-05-21-proapi-总体路线图.md`](./docs/superpowers/2026-05-21-proapi-总体路线图.md). diff --git a/README_zh.md b/README_zh.md index 8564d6b..35869d7 100644 --- a/README_zh.md +++ b/README_zh.md @@ -6,7 +6,26 @@ ## 当前状态 -🚧 M0 工程脚手架阶段 — 仅骨架可跑,业务功能在 M1 起逐步实现。 +🚧 **开发中 · 约 M2 阶段**(M0 / M1 已完成,M2 大部分就绪,M3 未开始)。本地可跑通「注册 → 建令牌 → 三协议调用 → 计费 → 看日志 → 充值」全链路。 + +**已就绪** + +- **三协议入口与互转**:OpenAI `/v1`、Anthropic `/v1/messages`、Gemini `/v1beta`,任意入口可路由到任意上游 +- **18 个上游适配器**:openai / azure / anthropic / gemini / deepseek / moonshot / 智谱 / 通义 / 豆包 / groq / mistral / 零一 / openrouter / huggingface / minimax / 腾讯混元 / Cohere / 讯飞星火 +- **多模态**:对话(含流式)· 图像生成 · TTS · STT · Embeddings +- **计费**:Redis Lua 预扣 / 提交 / 退款 · 模型倍率 · 分组倍率 +- **渠道**:CRUD + 优先级 + 权重 + 模型映射 + 熔断;账号池 +- **限流**:用户 / 令牌 / IP / 模型 / 分组 多维度 +- **鉴权**:邮箱密码 + 邮箱验证码 + Session;OAuth 六家(GitHub / Google / 微信 / 飞书 / 钉钉 / Discord) +- **支付**:Stripe / 支付宝 / 微信 + 手动充值 + 兑换码 +- **邀请返佣** · **公告** · **系统设置** · **审计** +- **前端**:后台(Naive UI 全页面)+ 用户前台(含 Playground / 模型广场 / 邀请)+ 文档站;中英双语 + +**进行中 / 未完成** + +- 异步任务系统(asynq)与 Midjourney / Suno +- 账号池 OAuth 拉号(PKCE 流程) +- M3 企业版:SSO(OIDC / SAML / LDAP / CAS)、部门预算、审计可视化、OpenTelemetry 路线图见 [`docs/superpowers/2026-05-21-proapi-总体路线图.md`](./docs/superpowers/2026-05-21-proapi-总体路线图.md)。 diff --git a/cmd/proapi/main.go b/cmd/proapi/main.go index 8bd801b..c836aa4 100644 --- a/cmd/proapi/main.go +++ b/cmd/proapi/main.go @@ -27,20 +27,21 @@ import ( "github.com/ijry/pro-api/internal/notice" "github.com/ijry/pro-api/internal/observability/logger" "github.com/ijry/pro-api/internal/observability/metrics" + "github.com/ijry/pro-api/internal/payment" mmanual "github.com/ijry/pro-api/internal/payment/manual" monline "github.com/ijry/pro-api/internal/payment/online" "github.com/ijry/pro-api/internal/payment/redeem" - "github.com/ijry/pro-api/internal/payment" "github.com/ijry/pro-api/internal/pricing" "github.com/ijry/pro-api/internal/ratelimit" "github.com/ijry/pro-api/internal/relay" "github.com/ijry/pro-api/internal/server" "github.com/ijry/pro-api/internal/server/handler/admin" - relayhdr "github.com/ijry/pro-api/internal/server/handler/relay" + "github.com/ijry/pro-api/internal/server/handler/logh" paymenthdr "github.com/ijry/pro-api/internal/server/handler/payment" + publichdr "github.com/ijry/pro-api/internal/server/handler/public" + relayhdr "github.com/ijry/pro-api/internal/server/handler/relay" tokenhdr "github.com/ijry/pro-api/internal/server/handler/token" userhdr "github.com/ijry/pro-api/internal/server/handler/user" - "github.com/ijry/pro-api/internal/server/handler/logh" "github.com/ijry/pro-api/internal/server/middleware" "github.com/ijry/pro-api/internal/token" "github.com/ijry/pro-api/internal/version" @@ -229,12 +230,10 @@ func wireRoutes(ctx context.Context, eng *gin.Engine, a *app.Application, log *z publicGroup := eng.Group("/api/public") // 公告路由(M1-12) - if _, err := wireNoticeHandlers(a, adminGroup, userGroup, publicGroup); err != nil { - log.Warn("notice handlers failed", zap.Error(err)) - } + wireNoticeHandlers(a, adminGroup, userGroup, publicGroup, log) // 系统设置路由(M1-12) - wireSettingHandler(a, adminGroup) + wireSettingHandler(a, adminGroup, log) // 日志 & 统计路由(M1-08) wireLogHandlers(a, adminGroup, userGroup) @@ -310,24 +309,58 @@ func wireRoutes(ctx context.Context, eng *gin.Engine, a *app.Application, log *z return nil } -// wireNoticeHandlers 挂载公告 handler。 -func wireNoticeHandlers(a *app.Application, adminG, userG, publicG *gin.RouterGroup) (bool, error) { - // 注意:这些 Wire 函数在 M1-12 notice handler 中;重复调用不影响 NoticeSvc - // 只是注册 handler 到 router group - _ = a - _ = adminG - _ = userG - _ = publicG - // TODO: M1-12 handler 在 internal/server/handler/{admin,user,public}/ - // 路由已在 authhwire.RegisterRoutes 或各自 wire 内注册,这里保持空实现 - return true, nil +// wireNoticeHandlers 挂载公告 handler: +// - public:无鉴权(/api/public/notices) +// - user:SessionAuth(/api/user/notices) +// - admin:SessionAuth + RoleGate(2)(/api/admin/notices) +func wireNoticeHandlers(a *app.Application, adminG, userG, publicG *gin.RouterGroup, log *zap.Logger) { + if notice.ServiceFrom(a) == nil { + log.Warn("notice service not wired; notice routes skipped") + return + } + actorOf := func(c *gin.Context) int64 { return middleware.UserID(c) } + + // public:无鉴权 + if h, err := publichdr.WirePublicNotice(a); err != nil { + log.Warn("public notice handler wiring failed", zap.Error(err)) + } else { + h.Register(publicG) + } + + sessStore := authhwire.SessionStoreFrom(a) + if sessStore == nil { + log.Warn("session store not available; admin/user notice routes skipped") + return + } + // user:登录可见 + if h, err := userhdr.WireUserNotice(a, actorOf); err != nil { + log.Warn("user notice handler wiring failed", zap.Error(err)) + } else { + h.Register(userG.Group("", middleware.SessionAuth(sessStore, a.Clock))) + } + // admin:RoleGate(2) + if h, err := admin.WireAdminNotice(a, actorOf); err != nil { + log.Warn("admin notice handler wiring failed", zap.Error(err)) + } else { + h.Register(adminG.Group("", middleware.SessionAuth(sessStore, a.Clock), middleware.RoleGate(2))) + } } -// wireSettingHandler 挂载系统设置 handler。 -func wireSettingHandler(a *app.Application, adminG *gin.RouterGroup) { - _ = a - _ = adminG - // TODO: internal/server/handler/admin.WireAdminSetting +// wireSettingHandler 挂载系统设置 handler(SessionAuth + RoleGate(2),/api/admin/settings)。 +// mailer 暂传 nil:test_smtp 走 stub,待邮件子系统接入后注入真实 Mailer。 +func wireSettingHandler(a *app.Application, adminG *gin.RouterGroup, log *zap.Logger) { + sessStore := authhwire.SessionStoreFrom(a) + if sessStore == nil { + log.Warn("session store not available; admin setting routes skipped") + return + } + actorOf := func(c *gin.Context) int64 { return middleware.UserID(c) } + h, err := admin.WireAdminSetting(a, nil, actorOf) + if err != nil { + log.Warn("admin setting handler wiring failed", zap.Error(err)) + return + } + h.Register(adminG.Group("", middleware.SessionAuth(sessStore, a.Clock), middleware.RoleGate(2))) } // wireAccountHandler 挂载号池 admin 路由。 diff --git a/internal/account/oauth/anthropic.go b/internal/account/oauth/anthropic.go index ea1db66..50d65e9 100644 --- a/internal/account/oauth/anthropic.go +++ b/internal/account/oauth/anthropic.go @@ -12,30 +12,43 @@ import ( "github.com/ijry/pro-api/internal/account" ) -// Anthropic 实现 Provider,使用 RFC 6749 refresh_token grant。 +// Anthropic 实现 Provider,使用 RFC 6749 授权码 + PKCE 与 refresh_token grant。 type Anthropic struct { cfg Config client *http.Client } +// NewAnthropic 构造 Anthropic OAuth 客户端。 func NewAnthropic(cfg Config) *Anthropic { return &Anthropic{cfg: cfg, client: &http.Client{Timeout: 15 * time.Second}} } -func (a *Anthropic) Start(_ context.Context, _ int64) (string, string, error) { - return "", "", ErrNotImplemented +// AuthCodeURL 拼出 Anthropic 授权码 + PKCE 授权跳转 URL。 +func (a *Anthropic) AuthCodeURL(state, challenge string) string { + return authCodeURL(a.cfg, state, challenge) } -func (a *Anthropic) Callback(_ context.Context, _, _ string) (*account.Account, error) { - return nil, ErrNotImplemented +// ExchangeCode 用授权码 + code_verifier 换取凭证(grant_type=authorization_code)。 +func (a *Anthropic) ExchangeCode(ctx context.Context, code, verifier string) (*account.AccountCred, error) { + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("code", code) + form.Set("redirect_uri", a.cfg.RedirectURI) + form.Set("client_id", a.cfg.ClientID) + form.Set("code_verifier", verifier) + return a.token(ctx, form) } +// ExchangeRefreshToken 用 refresh_token 换取新凭证(grant_type=refresh_token)。 func (a *Anthropic) ExchangeRefreshToken(ctx context.Context, refreshToken string) (*account.AccountCred, error) { form := url.Values{} form.Set("grant_type", "refresh_token") form.Set("refresh_token", refreshToken) form.Set("client_id", a.cfg.ClientID) + return a.token(ctx, form) +} +func (a *Anthropic) token(ctx context.Context, form url.Values) (*account.AccountCred, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.TokenURL, strings.NewReader(form.Encode())) if err != nil { return nil, err diff --git a/internal/account/oauth/anthropic_test.go b/internal/account/oauth/anthropic_test.go index bb0fc71..9d8fa67 100644 --- a/internal/account/oauth/anthropic_test.go +++ b/internal/account/oauth/anthropic_test.go @@ -47,10 +47,43 @@ func TestAnthropic_Refresh_InvalidGrant(t *testing.T) { require.Error(t, err) } -func TestAnthropic_StartCallbackStub(t *testing.T) { - a := oauth.NewAnthropic(oauth.Config{TokenURL: "http://x", ClientID: "x"}) - _, _, err := a.Start(context.Background(), 1) - require.ErrorIs(t, err, oauth.ErrNotImplemented) - _, err = a.Callback(context.Background(), "s", "c") - require.ErrorIs(t, err, oauth.ErrNotImplemented) +func TestAnthropic_AuthCodeURL(t *testing.T) { + a := oauth.NewAnthropic(oauth.Config{ + AuthURL: "https://claude.test/authorize", + ClientID: "cli-x", + RedirectURI: "https://app.test/cb", + Scopes: []string{"org:create_api_key"}, + }) + u := a.AuthCodeURL("s1", "c1") + require.Contains(t, u, "https://claude.test/authorize?") + require.Contains(t, u, "response_type=code") + require.Contains(t, u, "client_id=cli-x") + require.Contains(t, u, "code_challenge=c1") + require.Contains(t, u, "code_challenge_method=S256") + require.Contains(t, u, "state=s1") +} + +func TestAnthropic_ExchangeCode(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + require.Equal(t, "authorization_code", r.FormValue("grant_type")) + require.Equal(t, "ac", r.FormValue("code")) + require.Equal(t, "ver", r.FormValue("code_verifier")) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "an-at", + "refresh_token": "an-rt", + "expires_in": 3600, + "token_type": "Bearer", + "scope": "org:create_api_key", + }) + })) + defer srv.Close() + + a := oauth.NewAnthropic(oauth.Config{TokenURL: srv.URL, ClientID: "cli-x", RedirectURI: "https://app.test/cb"}) + cred, err := a.ExchangeCode(context.Background(), "ac", "ver") + require.NoError(t, err) + require.Equal(t, "an-at", cred.AccessToken) + require.Equal(t, "org:create_api_key", cred.Scope) + require.False(t, cred.ExpiresAt.IsZero()) } diff --git a/internal/account/oauth/iface.go b/internal/account/oauth/iface.go index de57131..638a8a8 100644 --- a/internal/account/oauth/iface.go +++ b/internal/account/oauth/iface.go @@ -2,26 +2,28 @@ package oauth import ( "context" - "errors" "github.com/ijry/pro-api/internal/account" ) -// ErrNotImplemented 是 M1 阶段 Start / Callback 的占位错误;M2 将补全 PKCE 流程。 -var ErrNotImplemented = errors.New("oauth: direct authorization not implemented in M1 (planned for M2)") - -// Provider 是单 provider 的 OAuth 客户端。Start/Callback 是 M1 stub; -// ExchangeRefreshToken 是 M1 真实实现,用于 RawRefreshToken parser 与后台 Refresher。 +// Provider 是单 provider 的 OAuth 客户端。 +// +// PKCE 授权码流程的编排(生成 verifier/state、保存 state、分发 Callback)由 Flow +// 统一负责;Provider 只负责拼授权 URL 与用授权码/刷新令牌换取凭证。 type Provider interface { - Start(ctx context.Context, channelID int64) (authURL, state string, err error) - Callback(ctx context.Context, state, code string) (*account.Account, error) + // AuthCodeURL 拼出 OAuth2 授权码 + PKCE 的授权跳转 URL。 + AuthCodeURL(state, challenge string) string + // ExchangeCode 用授权码 + code_verifier 换取凭证(grant_type=authorization_code)。 + ExchangeCode(ctx context.Context, code, verifier string) (*account.AccountCred, error) + // ExchangeRefreshToken 用 refresh_token 换取新凭证(grant_type=refresh_token)。 ExchangeRefreshToken(ctx context.Context, refreshToken string) (*account.AccountCred, error) } // Config 是单 provider 的 OAuth 配置。 type Config struct { - TokenURL string - AuthURL string - ClientID string - Scopes []string + TokenURL string + AuthURL string + ClientID string + RedirectURI string + Scopes []string } diff --git a/internal/account/oauth/openai.go b/internal/account/oauth/openai.go index 0bf8450..716e873 100644 --- a/internal/account/oauth/openai.go +++ b/internal/account/oauth/openai.go @@ -12,29 +12,43 @@ import ( "github.com/ijry/pro-api/internal/account" ) -// OpenAI 实现 Provider。除了 access_token / refresh_token,还回填 id_token(OpenAI 特有)。 +// OpenAI 实现 Provider。除 access_token / refresh_token 外,还回填 id_token(OpenAI 特有)。 type OpenAI struct { cfg Config client *http.Client } +// NewOpenAI 构造 OpenAI OAuth 客户端。 func NewOpenAI(cfg Config) *OpenAI { return &OpenAI{cfg: cfg, client: &http.Client{Timeout: 15 * time.Second}} } -func (o *OpenAI) Start(_ context.Context, _ int64) (string, string, error) { - return "", "", ErrNotImplemented +// AuthCodeURL 拼出 OpenAI 授权码 + PKCE 授权跳转 URL。 +func (o *OpenAI) AuthCodeURL(state, challenge string) string { + return authCodeURL(o.cfg, state, challenge) } -func (o *OpenAI) Callback(_ context.Context, _, _ string) (*account.Account, error) { - return nil, ErrNotImplemented +// ExchangeCode 用授权码 + code_verifier 换取凭证(grant_type=authorization_code)。 +func (o *OpenAI) ExchangeCode(ctx context.Context, code, verifier string) (*account.AccountCred, error) { + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("code", code) + form.Set("redirect_uri", o.cfg.RedirectURI) + form.Set("client_id", o.cfg.ClientID) + form.Set("code_verifier", verifier) + return o.token(ctx, form) } +// ExchangeRefreshToken 用 refresh_token 换取新凭证(grant_type=refresh_token)。 func (o *OpenAI) ExchangeRefreshToken(ctx context.Context, refreshToken string) (*account.AccountCred, error) { form := url.Values{} form.Set("grant_type", "refresh_token") form.Set("refresh_token", refreshToken) form.Set("client_id", o.cfg.ClientID) + return o.token(ctx, form) +} + +func (o *OpenAI) token(ctx context.Context, form url.Values) (*account.AccountCred, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.cfg.TokenURL, strings.NewReader(form.Encode())) if err != nil { return nil, err diff --git a/internal/account/oauth/openai_test.go b/internal/account/oauth/openai_test.go index 0dde42e..9a91b74 100644 --- a/internal/account/oauth/openai_test.go +++ b/internal/account/oauth/openai_test.go @@ -48,10 +48,46 @@ func TestOpenAI_Refresh_BadResponse(t *testing.T) { require.Error(t, err) } -func TestOpenAI_StartCallbackStub(t *testing.T) { - o := oauth.NewOpenAI(oauth.Config{TokenURL: "http://x", ClientID: "x"}) - _, _, err := o.Start(context.Background(), 1) - require.ErrorIs(t, err, oauth.ErrNotImplemented) - _, err = o.Callback(context.Background(), "s", "c") - require.ErrorIs(t, err, oauth.ErrNotImplemented) +func TestOpenAI_AuthCodeURL(t *testing.T) { + o := oauth.NewOpenAI(oauth.Config{ + AuthURL: "https://auth.openai.test/authorize", + ClientID: "cli-o", + RedirectURI: "https://app.test/cb", + Scopes: []string{"openid", "profile"}, + }) + u := o.AuthCodeURL("st8", "chal8") + require.Contains(t, u, "https://auth.openai.test/authorize?") + require.Contains(t, u, "response_type=code") + require.Contains(t, u, "client_id=cli-o") + require.Contains(t, u, "code_challenge=chal8") + require.Contains(t, u, "code_challenge_method=S256") + require.Contains(t, u, "state=st8") + require.Contains(t, u, "redirect_uri=https%3A%2F%2Fapp.test%2Fcb") + require.Contains(t, u, "scope=openid+profile") +} + +func TestOpenAI_ExchangeCode(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + require.Equal(t, "authorization_code", r.FormValue("grant_type")) + require.Equal(t, "the-code", r.FormValue("code")) + require.Equal(t, "the-verifier", r.FormValue("code_verifier")) + require.Equal(t, "https://app.test/cb", r.FormValue("redirect_uri")) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "openai-at", + "refresh_token": "openai-rt", + "id_token": "idt", + "expires_in": 1800, + "token_type": "Bearer", + }) + })) + defer srv.Close() + + o := oauth.NewOpenAI(oauth.Config{TokenURL: srv.URL, ClientID: "x", RedirectURI: "https://app.test/cb"}) + cred, err := o.ExchangeCode(context.Background(), "the-code", "the-verifier") + require.NoError(t, err) + require.Equal(t, "openai-at", cred.AccessToken) + require.Equal(t, "idt", cred.IDToken) + require.False(t, cred.ExpiresAt.IsZero()) } diff --git a/internal/account/oauth/pkce.go b/internal/account/oauth/pkce.go new file mode 100644 index 0000000..b699fdd --- /dev/null +++ b/internal/account/oauth/pkce.go @@ -0,0 +1,51 @@ +package oauth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "net/url" + "strings" +) + +// newVerifier 生成 RFC 7636 PKCE code_verifier(32 字节随机 → base64url 无填充,43 字符)。 +func newVerifier() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// challengeS256 由 code_verifier 计算 S256 code_challenge。 +func challengeS256(verifier string) string { + sum := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +// newState 生成防 CSRF 的随机 state(16 字节 → base64url)。 +func newState() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// authCodeURL 按 OAuth2 授权码 + PKCE(S256)拼出授权跳转 URL。 +// 各 provider 的 AuthCodeURL 共用此实现。 +func authCodeURL(cfg Config, state, challenge string) string { + q := url.Values{} + q.Set("response_type", "code") + q.Set("client_id", cfg.ClientID) + if cfg.RedirectURI != "" { + q.Set("redirect_uri", cfg.RedirectURI) + } + if len(cfg.Scopes) > 0 { + q.Set("scope", strings.Join(cfg.Scopes, " ")) + } + q.Set("state", state) + q.Set("code_challenge", challenge) + q.Set("code_challenge_method", "S256") + return cfg.AuthURL + "?" + q.Encode() +} diff --git a/internal/account/oauth/registry.go b/internal/account/oauth/registry.go index 3856a86..bbffaaa 100644 --- a/internal/account/oauth/registry.go +++ b/internal/account/oauth/registry.go @@ -3,30 +3,78 @@ package oauth import ( "context" "fmt" + "time" "github.com/ijry/pro-api/internal/account" ) +// stateTTL 是 PKCE state 的有效期(授权跳转 → 回调的窗口),对应路线图规约。 +const stateTTL = 10 * time.Minute + type registry struct { providers map[string]Provider + store StateStore } -// NewFlow 把多个 per-provider 客户端包成 account.OAuthFlow。 -func NewFlow(providers map[string]Provider) account.OAuthFlow { - return ®istry{providers: providers} +// NewFlow 把多个 per-provider 客户端 + state 存储包成 account.OAuthFlow。 +func NewFlow(providers map[string]Provider, store StateStore) account.OAuthFlow { + return ®istry{providers: providers, store: store} } +// Start 生成 PKCE verifier/challenge 与 state,落库 state,返回授权跳转 URL。 func (r *registry) Start(ctx context.Context, provider string, channelID int64) (string, string, error) { p, ok := r.providers[provider] if !ok { return "", "", fmt.Errorf("oauth: unknown provider %q", provider) } - return p.Start(ctx, channelID) + verifier, err := newVerifier() + if err != nil { + return "", "", err + } + state, err := newState() + if err != nil { + return "", "", err + } + if err := r.store.Save(ctx, state, StateData{ + Provider: provider, + ChannelID: channelID, + Verifier: verifier, + }, stateTTL); err != nil { + return "", "", err + } + return p.AuthCodeURL(state, challengeS256(verifier)), state, nil } -// Callback 在 M1 阶段不分发(state 中没有 provider 信息;M2 PKCE 流程会补)。 -func (r *registry) Callback(_ context.Context, _, _ string) (*account.Account, error) { - return nil, ErrNotImplemented +// Callback 校验/消费 state,用授权码换凭证,组装出待入库的 Account。 +func (r *registry) Callback(ctx context.Context, state, code string) (*account.Account, error) { + d, err := r.store.Take(ctx, state) + if err != nil { + return nil, err + } + p, ok := r.providers[d.Provider] + if !ok { + return nil, fmt.Errorf("oauth: unknown provider %q", d.Provider) + } + cred, err := p.ExchangeCode(ctx, code, d.Verifier) + if err != nil { + return nil, err + } + acc := &account.Account{ + ChannelID: d.ChannelID, + Provider: d.Provider, + CredType: "oauth", + ImportSource: "oauth", + Status: account.StatusActive, + Cred: *cred, + } + if cred.RefreshToken != "" { + acc.RefreshTokenValid = 1 + } + if !cred.ExpiresAt.IsZero() { + exp := cred.ExpiresAt + acc.AccessTokenExpiresAt = &exp + } + return acc, nil } func (r *registry) ExchangeRefreshToken(ctx context.Context, provider, rt string) (*account.AccountCred, error) { diff --git a/internal/account/oauth/registry_test.go b/internal/account/oauth/registry_test.go index 2074233..45c2bf4 100644 --- a/internal/account/oauth/registry_test.go +++ b/internal/account/oauth/registry_test.go @@ -2,8 +2,8 @@ package oauth_test import ( "context" - "errors" "testing" + "time" "github.com/ijry/pro-api/internal/account" "github.com/ijry/pro-api/internal/account/oauth" @@ -11,15 +11,16 @@ import ( ) type stubProvider struct { - cred *account.AccountCred - err error + cred *account.AccountCred + err error + authURL string } -func (s *stubProvider) Start(_ context.Context, _ int64) (string, string, error) { - return "", "", oauth.ErrNotImplemented +func (s *stubProvider) AuthCodeURL(state, challenge string) string { + return s.authURL + "?state=" + state + "&code_challenge=" + challenge } -func (s *stubProvider) Callback(_ context.Context, _, _ string) (*account.Account, error) { - return nil, oauth.ErrNotImplemented +func (s *stubProvider) ExchangeCode(_ context.Context, _, _ string) (*account.AccountCred, error) { + return s.cred, s.err } func (s *stubProvider) ExchangeRefreshToken(_ context.Context, _ string) (*account.AccountCred, error) { return s.cred, s.err @@ -28,7 +29,7 @@ func (s *stubProvider) ExchangeRefreshToken(_ context.Context, _ string) (*accou func TestRegistry_DispatchesByProvider(t *testing.T) { a := &stubProvider{cred: &account.AccountCred{AccessToken: "a-at"}} o := &stubProvider{cred: &account.AccountCred{AccessToken: "o-at"}} - flow := oauth.NewFlow(map[string]oauth.Provider{"anthropic": a, "openai": o}) + flow := oauth.NewFlow(map[string]oauth.Provider{"anthropic": a, "openai": o}, oauth.NewMemStateStore()) got, err := flow.ExchangeRefreshToken(context.Background(), "anthropic", "rt") require.NoError(t, err) @@ -40,7 +41,8 @@ func TestRegistry_DispatchesByProvider(t *testing.T) { } func TestRegistry_UnknownProvider(t *testing.T) { - flow := oauth.NewFlow(map[string]oauth.Provider{}) + flow := oauth.NewFlow(map[string]oauth.Provider{}, oauth.NewMemStateStore()) + _, err := flow.ExchangeRefreshToken(context.Background(), "bogus", "rt") require.Error(t, err) @@ -48,8 +50,39 @@ func TestRegistry_UnknownProvider(t *testing.T) { require.Error(t, err) } -func TestRegistry_CallbackStub(t *testing.T) { - flow := oauth.NewFlow(map[string]oauth.Provider{}) - _, err := flow.Callback(context.Background(), "s", "c") - require.True(t, errors.Is(err, oauth.ErrNotImplemented)) +func TestRegistry_StartCallbackRoundTrip(t *testing.T) { + o := &stubProvider{ + cred: &account.AccountCred{ + AccessToken: "at", + RefreshToken: "rt", + ExpiresAt: time.Now().Add(time.Hour), + }, + authURL: "https://auth.example/authorize", + } + flow := oauth.NewFlow(map[string]oauth.Provider{"openai": o}, oauth.NewMemStateStore()) + + authURL, state, err := flow.Start(context.Background(), "openai", 42) + require.NoError(t, err) + require.NotEmpty(t, state) + require.Contains(t, authURL, "state="+state) + require.Contains(t, authURL, "code_challenge=") + + acc, err := flow.Callback(context.Background(), state, "auth-code") + require.NoError(t, err) + require.Equal(t, int64(42), acc.ChannelID) + require.Equal(t, "openai", acc.Provider) + require.Equal(t, "oauth", acc.CredType) + require.Equal(t, "at", acc.Cred.AccessToken) + require.EqualValues(t, 1, acc.RefreshTokenValid) + require.NotNil(t, acc.AccessTokenExpiresAt) + + // state 一次性:第二次回调应失败 + _, err = flow.Callback(context.Background(), state, "auth-code") + require.ErrorIs(t, err, oauth.ErrStateNotFound) +} + +func TestRegistry_CallbackUnknownState(t *testing.T) { + flow := oauth.NewFlow(map[string]oauth.Provider{}, oauth.NewMemStateStore()) + _, err := flow.Callback(context.Background(), "never-saved", "c") + require.ErrorIs(t, err, oauth.ErrStateNotFound) } diff --git a/internal/account/oauth/state.go b/internal/account/oauth/state.go new file mode 100644 index 0000000..8f70d59 --- /dev/null +++ b/internal/account/oauth/state.go @@ -0,0 +1,86 @@ +package oauth + +import ( + "context" + "encoding/json" + "errors" + "sync" + "time" + + "github.com/redis/go-redis/v9" +) + +// StateData 是一次 PKCE 授权在 Start 与 Callback 之间需要保留的状态。 +type StateData struct { + Provider string `json:"provider"` + ChannelID int64 `json:"channel_id"` + Verifier string `json:"verifier"` +} + +// StateStore 持久化 PKCE state,一次性:Take 成功后立即失效。 +type StateStore interface { + Save(ctx context.Context, state string, d StateData, ttl time.Duration) error + Take(ctx context.Context, state string) (StateData, error) +} + +// ErrStateNotFound 表示 state 不存在或已被消费/过期(可能是 CSRF 或重放)。 +var ErrStateNotFound = errors.New("oauth: state not found or expired") + +const stateKeyPrefix = "account:oauth:state:" + +// RedisStateStore 用 Redis 实现 StateStore(对应路线图 oauth:state:* 规约)。 +type RedisStateStore struct{ rdb *redis.Client } + +// NewRedisStateStore 构造 Redis 状态存储。 +func NewRedisStateStore(rdb *redis.Client) *RedisStateStore { return &RedisStateStore{rdb: rdb} } + +func (s *RedisStateStore) Save(ctx context.Context, state string, d StateData, ttl time.Duration) error { + b, err := json.Marshal(d) + if err != nil { + return err + } + return s.rdb.Set(ctx, stateKeyPrefix+state, b, ttl).Err() +} + +func (s *RedisStateStore) Take(ctx context.Context, state string) (StateData, error) { + key := stateKeyPrefix + state + b, err := s.rdb.GetDel(ctx, key).Bytes() + if errors.Is(err, redis.Nil) { + return StateData{}, ErrStateNotFound + } + if err != nil { + return StateData{}, err + } + var d StateData + if err := json.Unmarshal(b, &d); err != nil { + return StateData{}, err + } + return d, nil +} + +// MemStateStore 是进程内实现,仅用于测试。 +type MemStateStore struct { + mu sync.Mutex + m map[string]StateData +} + +// NewMemStateStore 构造内存状态存储。 +func NewMemStateStore() *MemStateStore { return &MemStateStore{m: map[string]StateData{}} } + +func (s *MemStateStore) Save(_ context.Context, state string, d StateData, _ time.Duration) error { + s.mu.Lock() + defer s.mu.Unlock() + s.m[state] = d + return nil +} + +func (s *MemStateStore) Take(_ context.Context, state string) (StateData, error) { + s.mu.Lock() + defer s.mu.Unlock() + d, ok := s.m[state] + if !ok { + return StateData{}, ErrStateNotFound + } + delete(s.m, state) + return d, nil +} diff --git a/internal/account/repo.go b/internal/account/repo.go index 68f5540..157e455 100644 --- a/internal/account/repo.go +++ b/internal/account/repo.go @@ -11,6 +11,7 @@ import ( "github.com/ijry/pro-api/internal/util/crypto" "github.com/ijry/pro-api/internal/util/idgen" "github.com/ijry/pro-api/pkg/apierr" + "go.uber.org/zap" "gorm.io/gorm" ) @@ -19,11 +20,12 @@ type repo struct { crypto *crypto.AESGCM id *idgen.Generator clock clock.Clock + log *zap.Logger } -// NewRepository 构造 Repo。 -func NewRepository(db *gorm.DB, c *crypto.AESGCM, id *idgen.Generator, clk clock.Clock) Repo { - return &repo{db: db, crypto: c, id: id, clock: clk} +// NewRepository 构造 Repo。log 可为 nil(此时 hydrate 失败不记录)。 +func NewRepository(db *gorm.DB, c *crypto.AESGCM, id *idgen.Generator, clk clock.Clock, log *zap.Logger) Repo { + return &repo{db: db, crypto: c, id: id, clock: clk, log: log} } func (r *repo) Create(ctx context.Context, a *Account) error { @@ -116,8 +118,10 @@ func (r *repo) ListByChannel(ctx context.Context, channelID int64) ([]*Account, return nil, err } for _, a := range out { - // TODO(P8): log hydrate failures via repo logger - _ = r.hydrate(a) + // hydrate 失败不影响其余账号入列,但需记录(否则凭证解密失败会静默)。 + if err := r.hydrate(a); err != nil && r.log != nil { + r.log.Warn("account: hydrate failed", zap.Int64("account_id", a.ID), zap.Error(err)) + } } return out, nil } @@ -131,8 +135,10 @@ func (r *repo) ListByShareTag(ctx context.Context, tag string) ([]*Account, erro return nil, err } for _, a := range out { - // TODO(P8): log hydrate failures via repo logger - _ = r.hydrate(a) + // hydrate 失败不影响其余账号入列,但需记录(否则凭证解密失败会静默)。 + if err := r.hydrate(a); err != nil && r.log != nil { + r.log.Warn("account: hydrate failed", zap.Int64("account_id", a.ID), zap.Error(err)) + } } return out, nil } @@ -147,8 +153,10 @@ func (r *repo) ListForRefresher(ctx context.Context, before time.Time, limit int return nil, err } for _, a := range out { - // TODO(P8): log hydrate failures via repo logger - _ = r.hydrate(a) + // hydrate 失败不影响其余账号入列,但需记录(否则凭证解密失败会静默)。 + if err := r.hydrate(a); err != nil && r.log != nil { + r.log.Warn("account: hydrate failed", zap.Int64("account_id", a.ID), zap.Error(err)) + } } return out, nil } @@ -163,8 +171,10 @@ func (r *repo) ListForReaper(ctx context.Context, now time.Time, limit int) ([]* return nil, err } for _, a := range out { - // TODO(P8): log hydrate failures via repo logger - _ = r.hydrate(a) + // hydrate 失败不影响其余账号入列,但需记录(否则凭证解密失败会静默)。 + if err := r.hydrate(a); err != nil && r.log != nil { + r.log.Warn("account: hydrate failed", zap.Int64("account_id", a.ID), zap.Error(err)) + } } return out, nil } diff --git a/internal/account/repo_test.go b/internal/account/repo_test.go index 88e91cf..4611641 100644 --- a/internal/account/repo_test.go +++ b/internal/account/repo_test.go @@ -125,7 +125,7 @@ func TestRepo_CreateGet(t *testing.T) { require.NoError(t, err) idg, err := idgen.New(1) require.NoError(t, err) - r := account.NewRepository(db, cr, idg, clock.Real) + r := account.NewRepository(db, cr, idg, clock.Real, nil) a := &account.Account{ ChannelID: 100, @@ -159,7 +159,7 @@ func TestRepo_ListByChannel(t *testing.T) { require.NoError(t, err) idg, err := idgen.New(2) require.NoError(t, err) - r := account.NewRepository(db, cr, idg, clock.Real) + r := account.NewRepository(db, cr, idg, clock.Real, nil) for i := 0; i < 3; i++ { require.NoError(t, r.Create(ctx, &account.Account{ ChannelID: 200, Provider: "anthropic", CredType: "apikey", @@ -180,7 +180,7 @@ func TestRepo_AppendEvent(t *testing.T) { require.NoError(t, err) idg, err := idgen.New(3) require.NoError(t, err) - r := account.NewRepository(db, cr, idg, clock.Real) + r := account.NewRepository(db, cr, idg, clock.Real, nil) a := &account.Account{ ChannelID: 300, Provider: "openai", CredType: "oauth", Status: account.StatusActive, Weight: 100, diff --git a/internal/account/wire/wire.go b/internal/account/wire/wire.go index 9fc0f86..8f4b3ed 100644 --- a/internal/account/wire/wire.go +++ b/internal/account/wire/wire.go @@ -36,23 +36,29 @@ func WireAccount(ctx context.Context, a *app.Application) error { if clk == nil { clk = clock.Real } - repo := account.NewRepository(a.DB, a.Crypto, a.IDGen, clk) + repo := account.NewRepository(a.DB, a.Crypto, a.IDGen, clk, a.Log) var anthropicCfg, openaiCfg oauth.Config if a.Config != nil { anthropicCfg = oauth.Config{ - TokenURL: a.Config.Account.OAuthAnthropicTokenURL, - ClientID: a.Config.Account.OAuthAnthropicClientID, + TokenURL: a.Config.Account.OAuthAnthropicTokenURL, + AuthURL: a.Config.Account.OAuthAnthropicAuthURL, + ClientID: a.Config.Account.OAuthAnthropicClientID, + RedirectURI: a.Config.Account.OAuthAnthropicRedirectURI, + Scopes: a.Config.Account.OAuthAnthropicScopes, } openaiCfg = oauth.Config{ - TokenURL: a.Config.Account.OAuthOpenAITokenURL, - ClientID: a.Config.Account.OAuthOpenAIClientID, + TokenURL: a.Config.Account.OAuthOpenAITokenURL, + AuthURL: a.Config.Account.OAuthOpenAIAuthURL, + ClientID: a.Config.Account.OAuthOpenAIClientID, + RedirectURI: a.Config.Account.OAuthOpenAIRedirectURI, + Scopes: a.Config.Account.OAuthOpenAIScopes, } } oa := oauth.NewFlow(map[string]oauth.Provider{ "anthropic": oauth.NewAnthropic(anthropicCfg), "openai": oauth.NewOpenAI(openaiCfg), - }) + }, oauth.NewRedisStateStore(a.Cache)) tracker := quota.NewTracker(repo) diff --git a/internal/adapter/cohere/adapter.go b/internal/adapter/cohere/adapter.go new file mode 100644 index 0000000..ec2bd9e --- /dev/null +++ b/internal/adapter/cohere/adapter.go @@ -0,0 +1,44 @@ +// Package cohere provides a Cohere adapter via Cohere's OpenAI-compatible Compatibility API. +package cohere + +import ( + "context" + + "github.com/ijry/pro-api/internal/adapter" + oadapter "github.com/ijry/pro-api/internal/adapter/openai" + "github.com/ijry/pro-api/internal/protocol/ir" +) + +// supportedModels 列出 Cohere 兼容 API 暴露的常用模型。 +// 兼容端点同时支持 chat 与 embeddings;rerank 为 Cohere 原生能力,暂不经此适配器。 +var supportedModels = []string{ + "command-a-03-2025", + "command-r-plus-08-2024", + "command-r-08-2024", + "command-r7b-12-2024", + "embed-v4.0", + "embed-english-v3.0", + "embed-multilingual-v3.0", +} + +// Adapter 复用 OpenAI 基座,base URL 指向 Cohere Compatibility API。 +// 基座会在 base 后追加 /v1,故此处传入 .../compatibility,拼出 .../compatibility/v1。 +type Adapter struct{ base *oadapter.OpenAI } + +// New 构造 Cohere 适配器。 +func New() adapter.Adapter { + return &Adapter{base: oadapter.New("https://api.cohere.ai/compatibility")} +} + +func (a *Adapter) Name() string { return "cohere" } +func (a *Adapter) Capabilities() adapter.Capability { return a.base.Capabilities() } +func (a *Adapter) SupportedModels() []string { return supportedModels } +func (a *Adapter) Chat(ctx context.Context, req *ir.ChatRequest, cred adapter.Credential) (*ir.ChatResponse, error) { + return a.base.Chat(ctx, req, cred) +} +func (a *Adapter) ChatStream(ctx context.Context, req *ir.ChatRequest, cred adapter.Credential) (adapter.StreamReader, error) { + return a.base.ChatStream(ctx, req, cred) +} +func (a *Adapter) Embed(ctx context.Context, req *ir.EmbedRequest, cred adapter.Credential) (*ir.EmbedResponse, error) { + return a.base.Embed(ctx, req, cred) +} diff --git a/internal/adapter/xunfei/adapter.go b/internal/adapter/xunfei/adapter.go new file mode 100644 index 0000000..d165fce --- /dev/null +++ b/internal/adapter/xunfei/adapter.go @@ -0,0 +1,40 @@ +// Package xunfei provides an iFlytek Spark (讯飞星火) adapter (OpenAI-compatible). +package xunfei + +import ( + "context" + + "github.com/ijry/pro-api/internal/adapter" + oadapter "github.com/ijry/pro-api/internal/adapter/openai" + "github.com/ijry/pro-api/internal/protocol/ir" +) + +// supportedModels 列出讯飞星火 OpenAI 兼容端点的常用模型。 +// 鉴权使用控制台的 APIPassword 作为 Bearer token(填入渠道 credential 的 API key)。 +var supportedModels = []string{ + "4.0Ultra", + "generalv3.5", + "max-32k", + "generalv3", + "pro-128k", + "lite", +} + +// Adapter 复用 OpenAI 基座,base URL 指向讯飞星火兼容端点(基座追加 /v1)。 +type Adapter struct{ base *oadapter.OpenAI } + +// New 构造讯飞星火适配器。 +func New() adapter.Adapter { return &Adapter{base: oadapter.New("https://spark-api-open.xf-yun.com")} } + +func (a *Adapter) Name() string { return "xunfei" } +func (a *Adapter) Capabilities() adapter.Capability { return a.base.Capabilities() } +func (a *Adapter) SupportedModels() []string { return supportedModels } +func (a *Adapter) Chat(ctx context.Context, req *ir.ChatRequest, cred adapter.Credential) (*ir.ChatResponse, error) { + return a.base.Chat(ctx, req, cred) +} +func (a *Adapter) ChatStream(ctx context.Context, req *ir.ChatRequest, cred adapter.Credential) (adapter.StreamReader, error) { + return a.base.ChatStream(ctx, req, cred) +} +func (a *Adapter) Embed(ctx context.Context, req *ir.EmbedRequest, cred adapter.Credential) (*ir.EmbedResponse, error) { + return a.base.Embed(ctx, req, cred) +} diff --git a/internal/adapterreg/wire.go b/internal/adapterreg/wire.go index 1dda7fe..6c5fc18 100644 --- a/internal/adapterreg/wire.go +++ b/internal/adapterreg/wire.go @@ -6,6 +6,7 @@ import ( "github.com/ijry/pro-api/internal/adapter" "github.com/ijry/pro-api/internal/adapter/anthropic" "github.com/ijry/pro-api/internal/adapter/azure" + "github.com/ijry/pro-api/internal/adapter/cohere" "github.com/ijry/pro-api/internal/adapter/deepseek" "github.com/ijry/pro-api/internal/adapter/doubao" "github.com/ijry/pro-api/internal/adapter/gemini" @@ -18,12 +19,13 @@ import ( "github.com/ijry/pro-api/internal/adapter/openrouter" "github.com/ijry/pro-api/internal/adapter/qwen" "github.com/ijry/pro-api/internal/adapter/tencent" + "github.com/ijry/pro-api/internal/adapter/xunfei" "github.com/ijry/pro-api/internal/adapter/yi" "github.com/ijry/pro-api/internal/adapter/zhipu" "github.com/ijry/pro-api/internal/util/tokenize" ) -// WireAdapters 向 Registry 注册所有 16 家 adapter,并注册 tokenizers。 +// WireAdapters 向 Registry 注册所有 18 家 adapter,并注册 tokenizers。 // // 用法: // @@ -45,7 +47,7 @@ func WireAdapters(reg adapter.Registry, tokReg *tokenize.Registry) { reg.Register(qwen.New()) reg.Register(doubao.New()) - // M2a 新增 8 家 adapter + // M2a 新增 7 家 adapter reg.Register(groq.New()) reg.Register(mistral.New()) reg.Register(yi.New()) @@ -53,4 +55,8 @@ func WireAdapters(reg adapter.Registry, tokReg *tokenize.Registry) { reg.Register(huggingface.New()) reg.Register(minimax.New()) reg.Register(tencent.New()) + + // M2b 新增 2 家 adapter(OpenAI 兼容端点) + reg.Register(cohere.New()) + reg.Register(xunfei.New()) } diff --git a/internal/app/config/config.go b/internal/app/config/config.go index ab334fe..b42f51b 100644 --- a/internal/app/config/config.go +++ b/internal/app/config/config.go @@ -27,12 +27,18 @@ type Config struct { // AccountConfig 是号池/OAuth/Probe 相关配置(M2b)。 // 默认值见 system_settings seed (000029_seed_account_settings)。 type AccountConfig struct { - OAuthAnthropicTokenURL string `mapstructure:"oauth_anthropic_token_url"` - OAuthAnthropicClientID string `mapstructure:"oauth_anthropic_client_id"` - OAuthOpenAITokenURL string `mapstructure:"oauth_openai_token_url"` - OAuthOpenAIClientID string `mapstructure:"oauth_openai_client_id"` - AnthropicProbeBase string `mapstructure:"anthropic_probe_base"` - OpenAIProbeBase string `mapstructure:"openai_probe_base"` + OAuthAnthropicTokenURL string `mapstructure:"oauth_anthropic_token_url"` + OAuthAnthropicClientID string `mapstructure:"oauth_anthropic_client_id"` + OAuthAnthropicAuthURL string `mapstructure:"oauth_anthropic_auth_url"` + OAuthAnthropicRedirectURI string `mapstructure:"oauth_anthropic_redirect_uri"` + OAuthAnthropicScopes []string `mapstructure:"oauth_anthropic_scopes"` + OAuthOpenAITokenURL string `mapstructure:"oauth_openai_token_url"` + OAuthOpenAIClientID string `mapstructure:"oauth_openai_client_id"` + OAuthOpenAIAuthURL string `mapstructure:"oauth_openai_auth_url"` + OAuthOpenAIRedirectURI string `mapstructure:"oauth_openai_redirect_uri"` + OAuthOpenAIScopes []string `mapstructure:"oauth_openai_scopes"` + AnthropicProbeBase string `mapstructure:"anthropic_probe_base"` + OpenAIProbeBase string `mapstructure:"openai_probe_base"` } // ServerConfig 描述 HTTP server 启动参数。 @@ -67,7 +73,7 @@ type RedisConfig struct { // SMTPConfig 描述 SMTP 发件服务器。Host 为空时禁用 SMTP,回落到 stub。 type SMTPConfig struct { Host string `mapstructure:"host"` - Port int `mapstructure:"port"` // 25 / 465 / 587 + Port int `mapstructure:"port"` // 25 / 465 / 587 Username string `mapstructure:"username"` Password string `mapstructure:"password"` From string `mapstructure:"from"` // "name " 或 "addr"