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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ frontend/out/

# Go
backend/bin/
/node_modules/
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,31 @@

Go 实现的 OpenAI 兼容 API 网关(多租户 API Key、额度、兑换/邀请、渠道路由)。本仓库为 **monorepo**:**`backend/`** 为网关服务,**`frontend/`** 为管理控制台(Next.js + Ant Design)。

设计说明见 `docs/` 与 OpenSpec:**后端** `openspec/changes/gateway-foundation-invite-billing/`;**管理控制台前端提案** `openspec/changes/gateway-admin-console-frontend/`。
设计说明见 `docs/` 与 OpenSpec:**后端** `openspec/changes/gateway-foundation-invite-billing/`;**管理控制台前端** `openspec/changes/gateway-admin-console-frontend/`;**租户门户(蓝移类产品)** 已归档至 `openspec/changes/archive/2026-05-27-gateway-tenant-portal-platform/`,现行规格见 `openspec/specs/portal-*/`(需求来源:`zhencai/lanyiapi-site-audit/IMPLEMENTATION_PROMPT.md`)

## 目录结构

| 路径 | 说明 |
|------|------|
| `backend/` | Go 网关:`go.mod`、`cmd/server`、`internal/`、`migrations/` |
| `frontend/` | 管理控制台(`npm run dev`,详见 `frontend/README.md`) |
| `portal/` | 租户门户 Semi UI(`npm run dev`,默认端口 3001,详见 `portal/README.md`) |
| `docker-compose.yml` | 本地 Postgres + Redis(仓库根,与后端 `.env` 配合) |
| `openspec/` | 变更提案与规格 |

## 运行(后端)

1. 在仓库根执行:`docker compose up -d`(Postgres + Redis,若已自备可跳过)
2. 应用迁移:`backend/migrations/001_init.sql`
2. 应用迁移:`backend/migrations/001_init.sql`;门户另需 `003`–`006` 迁移脚本(见 `portal/README.md`)
3. 若启用消费返利:追加 `backend/migrations/002_rebate_tasks.sql`,并设置 `REBATE_ENABLED=true`、`REBATE_BPS`(万分比,如 100=1%)
4. 复制 `backend/.env.example` 为 `backend/.env` 并按需填写
5. `cd backend && go run ./cmd/server`

## 运行(租户门户)

1. `cd portal && cp .env.example .env.local`,设置 `NEXT_PUBLIC_GATEWAY_API_URL`
2. `npm install && npm run dev` → [http://localhost:3001](http://localhost:3001)

## 运行(管理控制台)

1. `cd frontend && cp .env.example .env.local`,设置 `NEXT_PUBLIC_GATEWAY_API_URL`(与网关监听地址一致,无尾斜杠)
Expand All @@ -46,3 +52,5 @@ Go 实现的 OpenAI 兼容 API 网关(多租户 API Key、额度、兑换/邀
归档前在仓库根目录执行:

`npx @fission-ai/openspec@latest status --change gateway-foundation-invite-billing`

租户门户 change 已于 2026-05-27 归档(`openspec/changes/archive/2026-05-27-gateway-tenant-portal-platform/`)。现行能力规格:`openspec/specs/portal-public-site/` 等 10 个 `portal-*` spec。
7 changes: 7 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,10 @@ QUOTA_RECONCILE_INTERVAL_SEC=120
# REBATE_ENABLED=false
# REBATE_BPS=0
# REBATE_POLL_SEC=10

# Portal wallet / invite
PORTAL_ORIGIN=http://localhost:3001
# JSON: [{"name":"主站","url":"http://localhost:8080","region":"local"},...]
PORTAL_API_NODES_JSON=
AFFILIATE_RECHARGE_BPS=1000
# RECHARGE_ENABLED=true
1 change: 1 addition & 0 deletions backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
Expand Down
2 changes: 2 additions & 0 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
Expand Down
16 changes: 16 additions & 0 deletions backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ type Config struct {
RebateBPS int
// RebatePollSec is worker poll interval when RebateEnabled; 0 uses 10.
RebatePollSec int
// PortalAPINodesJSON is JSON array of {name,url,region} for dashboard node card.
PortalAPINodesJSON string
// RechargeEnabled allows mock online recharge API (dev only by default).
RechargeEnabled bool
// PortalOrigin is used to build invite URLs in wallet API.
PortalOrigin string
// AffiliateRechargeBPS is inviter share on invitee recharge (e.g. 1000 = 10%).
AffiliateRechargeBPS int
}

func Load() (*Config, error) {
Expand All @@ -43,6 +51,10 @@ func Load() (*Config, error) {
viper.SetDefault("REBATE_ENABLED", false)
viper.SetDefault("REBATE_BPS", 0)
viper.SetDefault("REBATE_POLL_SEC", 10)
viper.SetDefault("PORTAL_API_NODES_JSON", "")
viper.SetDefault("RECHARGE_ENABLED", false)
viper.SetDefault("PORTAL_ORIGIN", "http://localhost:3001")
viper.SetDefault("AFFILIATE_RECHARGE_BPS", 1000)
_ = viper.ReadInConfig()

return &Config{
Expand All @@ -59,5 +71,9 @@ func Load() (*Config, error) {
RebateEnabled: viper.GetBool("REBATE_ENABLED"),
RebateBPS: viper.GetInt("REBATE_BPS"),
RebatePollSec: viper.GetInt("REBATE_POLL_SEC"),
PortalAPINodesJSON: viper.GetString("PORTAL_API_NODES_JSON"),
RechargeEnabled: viper.GetBool("RECHARGE_ENABLED"),
PortalOrigin: strings.TrimRight(viper.GetString("PORTAL_ORIGIN"), "/"),
AffiliateRechargeBPS: viper.GetInt("AFFILIATE_RECHARGE_BPS"),
}, nil
}
40 changes: 40 additions & 0 deletions backend/internal/handler/announcement.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package handler

import (
"net/http"
"time"

"github.com/gin-gonic/gin"
"gorm.io/gorm"

"github.com/leno23/ai-api-gateway/internal/model"
)

type AnnouncementDeps struct {
DB *gorm.DB
}

func ListAnnouncements(deps AnnouncementDeps) gin.HandlerFunc {
return func(c *gin.Context) {
placement := c.DefaultQuery("placement", "home")
now := time.Now()
var rows []model.Announcement
q := deps.DB.Where("status = ? AND placement = ?", 1, placement)
q = q.Where("(starts_at IS NULL OR starts_at <= ?)", now).
Where("(ends_at IS NULL OR ends_at >= ?)", now)
if err := q.Order("id desc").Limit(20).Find(&rows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "announcements"})
return
}
items := make([]gin.H, 0, len(rows))
for _, a := range rows {
items = append(items, gin.H{
"id": a.ID,
"title": a.Title,
"content": a.Content,
"level": a.Level,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": items})
}
}
87 changes: 83 additions & 4 deletions backend/internal/handler/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package handler
import (
"errors"
"net/http"
"strings"
"time"

"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -136,8 +137,16 @@ func Register(deps AuthDeps) gin.HandlerFunc {
}

type loginReq struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
Email string `json:"email"`
Username string `json:"username"`
Password string `json:"password" binding:"required"`
}

func loginIdentifier(req loginReq) string {
if req.Email != "" {
return req.Email
}
return req.Username
}

func Login(deps AuthDeps) gin.HandlerFunc {
Expand All @@ -147,8 +156,17 @@ func Login(deps AuthDeps) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
id := loginIdentifier(req)
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "email or username required"})
return
}
var u model.User
if err := deps.DB.Where("email = ?", req.Email).First(&u).Error; err != nil {
q := deps.DB.Where("email = ?", id)
if !strings.Contains(id, "@") {
q = deps.DB.Where("username = ?", id)
}
if err := q.First(&u).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
Expand All @@ -165,8 +183,69 @@ func Login(deps AuthDeps) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token"})
return
}
c.JSON(http.StatusOK, gin.H{"access_token": tok, "token_type": "Bearer", "expires_in": int(jwtTTL.Seconds())})
groupSlug := userTokenGroupSlug(deps.DB, &u)
c.JSON(http.StatusOK, gin.H{
"access_token": tok,
"token_type": "Bearer",
"expires_in": int(jwtTTL.Seconds()),
"success": true,
"data": gin.H{
"id": u.ID,
"username": u.Username,
"display_name": u.Username,
"email": u.Email,
"role": u.Role,
"group": groupSlug,
"status": u.Status,
},
})
}
}

func UserSelf(deps AuthDeps) gin.HandlerFunc {
return func(c *gin.Context) {
uid, ok := c.Get(middleware.CtxUserID)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
var u model.User
if err := deps.DB.First(&u, uid.(int64)).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
groupSlug := userTokenGroupSlug(deps.DB, &u)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"id": u.ID,
"username": u.Username,
"display_name": u.Username,
"email": u.Email,
"role": u.Role,
"group": groupSlug,
"status": u.Status,
"quota": u.Quota,
"used_quota": u.UsedQuota,
"invite_code": u.InviteCode,
"affiliate_pending": u.AffiliatePending,
},
})
}
}

func userTokenGroupSlug(db *gorm.DB, u *model.User) string {
if u.TokenGroupID != nil {
var g model.TokenGroup
if err := db.First(&g, *u.TokenGroupID).Error; err == nil {
return g.Slug
}
}
var g model.TokenGroup
if err := db.Where("slug = ?", "default").First(&g).Error; err == nil {
return g.Slug
}
return "default"
}

type createKeyReq struct {
Expand Down
Loading