Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
21 changes: 20 additions & 1 deletion README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。

Expand Down
79 changes: 56 additions & 23 deletions cmd/proapi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 路由。
Expand Down
23 changes: 18 additions & 5 deletions internal/account/oauth/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,30 +12,43 @@
"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
Expand All @@ -45,7 +58,7 @@
if err != nil {
return nil, err
}
defer resp.Body.Close()

Check failure on line 61 in internal/account/oauth/anthropic.go

View workflow job for this annotation

GitHub Actions / lint-go

Error return value of `resp.Body.Close` is not checked (errcheck)

Check failure on line 61 in internal/account/oauth/anthropic.go

View workflow job for this annotation

GitHub Actions / lint-go

Error return value of `resp.Body.Close` is not checked (errcheck)
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("anthropic token exchange: status %d", resp.StatusCode)
}
Expand Down
45 changes: 39 additions & 6 deletions internal/account/oauth/anthropic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
26 changes: 14 additions & 12 deletions internal/account/oauth/iface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
24 changes: 19 additions & 5 deletions internal/account/oauth/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,43 @@
"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
Expand All @@ -44,7 +58,7 @@
if err != nil {
return nil, err
}
defer resp.Body.Close()

Check failure on line 61 in internal/account/oauth/openai.go

View workflow job for this annotation

GitHub Actions / lint-go

Error return value of `resp.Body.Close` is not checked (errcheck)

Check failure on line 61 in internal/account/oauth/openai.go

View workflow job for this annotation

GitHub Actions / lint-go

Error return value of `resp.Body.Close` is not checked (errcheck)
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("openai token exchange: status %d", resp.StatusCode)
}
Expand Down
Loading
Loading