M0+M1: 基建 + 用户/密钥/核心代理
后端 (Go/Gin/GORM): - 配置(viper+env)、SQLite/Postgres 迁移、argon2id、AES-GCM 渠道密钥、JWT+refresh cookie - 用户注册/登录/刷新/登出、API Key CRUD(仅存哈希、明文一次展示) - 代理网关: /v1/chat/completions、/v1/responses、/v1/models 直通 OpenAI 渠道 非流式+流式(SSE 零缓冲转发), 用量捕获(chat 末块/responses completed 嵌套), OpenAI 错误格式(401/402/404/502), 余额检查 - 异步批量记账 + 余额流水 + 日聚合, admin 用户/余额/配置 API - 单测: crypto/jwt/apikey/流式 usage 提取 前端 (Vue3+TS+Vite+Tailwind v4): - taste-skill 设计 tokens: 深色仪表盘, 石墨+信号铜色, Outfit+JetBrains Mono - Landing/登录/注册, 控制台(仪表盘图表/密钥管理/用量明细) - 基础组件 Button/Input/Badge/Modal, ECharts 用量图 部署: docker-compose(nginx+api+postgres), 双 Dockerfile, nginx SSE 反代 联调: scripts/mockupstream 本地 mock 上游, 端到端验证通过
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/openteam/server/internal/pkg/resp"
|
||||
"github.com/openteam/server/internal/store"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AdminUsers GET /api/v1/admin/users — 用户列表(搜索、分页)。
|
||||
func (h *Handler) AdminUsers(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
q := h.a.DB.Model(&store.User{})
|
||||
if kw := c.Query("q"); kw != "" {
|
||||
q = q.Where("username LIKE ? OR email LIKE ?", "%"+kw+"%", "%"+kw+"%")
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var users []store.User
|
||||
q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&users)
|
||||
out := make([]gin.H, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, h.publicUser(&u))
|
||||
}
|
||||
resp.OK(c, gin.H{"items": out, "total": total, "page": page, "page_size": size})
|
||||
}
|
||||
|
||||
// AdminPatchUser PATCH /api/v1/admin/users/:id — 角色/状态/余额。
|
||||
func (h *Handler) AdminPatchUser(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid user id")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Role *string `json:"role"`
|
||||
Status *string `json:"status"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||||
return
|
||||
}
|
||||
var u store.User
|
||||
if err := h.a.DB.First(&u, id).Error; err != nil {
|
||||
resp.Fail(c, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
updates := map[string]any{}
|
||||
if req.Role != nil {
|
||||
if *req.Role != store.RoleUser && *req.Role != store.RoleAdmin {
|
||||
resp.Fail(c, http.StatusBadRequest, "role must be user or admin")
|
||||
return
|
||||
}
|
||||
updates["role"] = *req.Role
|
||||
}
|
||||
if req.Status != nil {
|
||||
if *req.Status != store.UserStatusActive && *req.Status != store.UserStatusDisabled {
|
||||
resp.Fail(c, http.StatusBadRequest, "status must be active or disabled")
|
||||
return
|
||||
}
|
||||
updates["status"] = *req.Status
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
h.a.DB.Model(&u).Updates(updates)
|
||||
}
|
||||
resp.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminAdjustBalance POST /api/v1/admin/users/:id/balance — 调整余额并写流水。
|
||||
func (h *Handler) AdminAdjustBalance(c *gin.Context) {
|
||||
admin, _ := userFromContext(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid user id")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Change float64 `json:"change" binding:"required"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input: change is required")
|
||||
return
|
||||
}
|
||||
var u store.User
|
||||
if err := h.a.DB.First(&u, id).Error; err != nil {
|
||||
resp.Fail(c, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
newBalance := u.Balance + req.Change
|
||||
ref := fmt.Sprintf("admin:%d:%d", admin.ID, time.Now().UnixNano())
|
||||
err = h.a.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&store.User{}).Where("id = ?", u.ID).Update("balance", newBalance).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&store.BalanceLog{
|
||||
UserID: u.ID,
|
||||
Change: req.Change,
|
||||
BalanceAfter: newBalance,
|
||||
Type: store.BalanceTypeAdminAdjust,
|
||||
RefID: ref,
|
||||
Remark: req.Remark,
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to adjust balance")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"ok": true, "balance": newBalance})
|
||||
}
|
||||
|
||||
// AdminConfig GET /api/v1/admin/config
|
||||
func (h *Handler) AdminConfig(c *gin.Context) {
|
||||
var cfgs []store.SystemConfig
|
||||
h.a.DB.Find(&cfgs)
|
||||
m := map[string]any{}
|
||||
for _, cfg := range cfgs {
|
||||
var v any
|
||||
_ = json.Unmarshal([]byte(cfg.Value), &v)
|
||||
m[cfg.Key] = v
|
||||
}
|
||||
m["registration.mode"] = h.a.Cfg.Auth.RegistrationMode
|
||||
resp.OK(c, gin.H{"config": m})
|
||||
}
|
||||
|
||||
// AdminPutConfig PUT /api/v1/admin/config
|
||||
func (h *Handler) AdminPutConfig(c *gin.Context) {
|
||||
var req struct {
|
||||
Config map[string]any `json:"config"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||||
return
|
||||
}
|
||||
for k, v := range req.Config {
|
||||
if k == "registration.mode" {
|
||||
if v == "open" || v == "invite" {
|
||||
h.a.Cfg.Auth.RegistrationMode = v.(string)
|
||||
}
|
||||
continue
|
||||
}
|
||||
b, _ := json.Marshal(v)
|
||||
cfg := store.SystemConfig{Key: k, Value: string(b)}
|
||||
h.a.DB.Save(&cfg)
|
||||
}
|
||||
resp.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// Package api 管理 API(/api/v1):认证、用户、密钥、用量、管理后台。
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/openteam/server/internal/app"
|
||||
"github.com/openteam/server/internal/pkg/resp"
|
||||
"github.com/openteam/server/internal/store"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Handler 聚合所有管理 API。
|
||||
type Handler struct {
|
||||
a *app.App
|
||||
}
|
||||
|
||||
func NewHandler(a *app.App) *Handler { return &Handler{a: a} }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 认证
|
||||
|
||||
type registerReq struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=32"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=8,max=72"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
}
|
||||
|
||||
// Register POST /api/v1/auth/register
|
||||
func (h *Handler) Register(c *gin.Context) {
|
||||
var req registerReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error())
|
||||
return
|
||||
}
|
||||
if h.a.Cfg.Auth.RegistrationMode == "invite" {
|
||||
var ic string
|
||||
h.a.DB.Model(&store.SystemConfig{}).Where("key = ?", "invite_codes").Pluck("value", &ic)
|
||||
if !strings.Contains(ic, req.InviteCode) {
|
||||
resp.Fail(c, http.StatusForbidden, "valid invite code required")
|
||||
return
|
||||
}
|
||||
}
|
||||
var count int64
|
||||
h.a.DB.Model(&store.User{}).Where("username = ? OR email = ?", req.Username, req.Email).Count(&count)
|
||||
if count > 0 {
|
||||
resp.Fail(c, http.StatusConflict, "username or email already exists")
|
||||
return
|
||||
}
|
||||
hash, err := h.a.Hasher.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to hash password")
|
||||
return
|
||||
}
|
||||
u := store.User{
|
||||
Username: req.Username,
|
||||
Email: strings.ToLower(req.Email),
|
||||
PasswordHash: hash,
|
||||
Role: store.RoleUser,
|
||||
Balance: 5.0, // 新用户赠送体验余额(可通过 admin 调整)
|
||||
Status: store.UserStatusActive,
|
||||
}
|
||||
if req.InviteCode != "" {
|
||||
ic := req.InviteCode
|
||||
u.InviteCode = &ic
|
||||
}
|
||||
if err := h.a.DB.Create(&u).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to create user")
|
||||
return
|
||||
}
|
||||
resp.Created(c, gin.H{"id": u.ID, "username": u.Username})
|
||||
}
|
||||
|
||||
type loginReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// Login POST /api/v1/auth/login — 返回 access token,refresh token 写入 HttpOnly Cookie。
|
||||
func (h *Handler) Login(c *gin.Context) {
|
||||
var req loginReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||||
return
|
||||
}
|
||||
var u store.User
|
||||
err := h.a.DB.Where("username = ? OR email = ?", req.Username, strings.ToLower(req.Username)).First(&u).Error
|
||||
if err != nil || u.Status != store.UserStatusActive {
|
||||
resp.Fail(c, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
ok, err := h.a.Hasher.VerifyPassword(u.PasswordHash, req.Password)
|
||||
if err != nil || !ok {
|
||||
resp.Fail(c, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
h.a.DB.Model(&u).Update("last_login_at", now)
|
||||
|
||||
access, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "access")
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to issue token")
|
||||
return
|
||||
}
|
||||
refresh, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "refresh")
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to issue token")
|
||||
return
|
||||
}
|
||||
h.setRefreshCookie(c, refresh)
|
||||
resp.OK(c, gin.H{"access_token": access, "expires_in": int(h.a.JWT.AccessTTL().Seconds()), "user": h.publicUser(&u)})
|
||||
}
|
||||
|
||||
// Refresh POST /api/v1/auth/refresh — 用 refresh cookie 换新 access token。
|
||||
func (h *Handler) Refresh(c *gin.Context) {
|
||||
tok, err := c.Cookie(h.a.Cfg.JWT.CookieName)
|
||||
if err != nil || tok == "" {
|
||||
resp.Fail(c, http.StatusUnauthorized, "refresh token missing")
|
||||
return
|
||||
}
|
||||
claims, err := h.a.JWT.Parse(tok)
|
||||
if err != nil || claims.Subject != "refresh" {
|
||||
resp.Fail(c, http.StatusUnauthorized, "invalid refresh token")
|
||||
return
|
||||
}
|
||||
var u store.User
|
||||
if err := h.a.DB.First(&u, claims.UserID).Error; err != nil || u.Status != store.UserStatusActive {
|
||||
resp.Fail(c, http.StatusUnauthorized, "user not found or disabled")
|
||||
return
|
||||
}
|
||||
access, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "access")
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to issue token")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"access_token": access, "expires_in": int(h.a.JWT.AccessTTL().Seconds())})
|
||||
}
|
||||
|
||||
// Logout POST /api/v1/auth/logout — 清除 refresh cookie。
|
||||
func (h *Handler) Logout(c *gin.Context) {
|
||||
h.setRefreshCookie(c, "")
|
||||
resp.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// Me GET /api/v1/auth/me
|
||||
func (h *Handler) Me(c *gin.Context) {
|
||||
u := sessionUser(c)
|
||||
resp.OK(c, gin.H{"user": h.publicUser(u)})
|
||||
}
|
||||
|
||||
func (h *Handler) setRefreshCookie(c *gin.Context, value string) {
|
||||
maxAge := int(h.a.JWT.RefreshTTL().Seconds())
|
||||
if value == "" {
|
||||
maxAge = -1
|
||||
}
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: h.a.Cfg.JWT.CookieName,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: true,
|
||||
Secure: h.a.Cfg.JWT.CookieSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Domain: h.a.Cfg.JWT.CookieDomain,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) publicUser(u *store.User) gin.H {
|
||||
return gin.H{
|
||||
"id": u.ID,
|
||||
"username": u.Username,
|
||||
"email": u.Email,
|
||||
"role": u.Role,
|
||||
"balance": u.Balance,
|
||||
"status": u.Status,
|
||||
"created_at": u.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func sessionUser(c *gin.Context) *store.User {
|
||||
u, _ := c.Get("session_user")
|
||||
return u.(*store.User)
|
||||
}
|
||||
|
||||
func userFromContext(c *gin.Context) (*store.User, bool) {
|
||||
u, ok := c.Get("session_user")
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return u.(*store.User), true
|
||||
}
|
||||
|
||||
var _ = gorm.ErrRecordNotFound
|
||||
@@ -0,0 +1,181 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/openteam/server/internal/pkg/apikey"
|
||||
"github.com/openteam/server/internal/pkg/resp"
|
||||
"github.com/openteam/server/internal/store"
|
||||
)
|
||||
|
||||
type createKeyReq struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=64"`
|
||||
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day"`
|
||||
QuotaRequestsPerDay *int `json:"quota_requests_per_day"`
|
||||
AllowedModels []string `json:"allowed_models"`
|
||||
ExpiresAt *string `json:"expires_at"` // RFC3339
|
||||
}
|
||||
|
||||
// CreateKey POST /api/v1/keys — 创建密钥,明文仅此一次返回。
|
||||
func (h *Handler) CreateKey(c *gin.Context) {
|
||||
u, ok := userFromContext(c)
|
||||
if !ok {
|
||||
resp.Fail(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
var req createKeyReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error())
|
||||
return
|
||||
}
|
||||
plain, hash, prefix, err := apikey.Generate()
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to generate key")
|
||||
return
|
||||
}
|
||||
k := store.APIKey{
|
||||
UserID: u.ID,
|
||||
Name: req.Name,
|
||||
KeyHash: hash,
|
||||
KeyPrefix: prefix,
|
||||
QuotaTokensPerDay: req.QuotaTokensPerDay,
|
||||
QuotaRequestsPerDay: req.QuotaRequestsPerDay,
|
||||
AllowedModels: req.AllowedModels,
|
||||
Status: store.KeyStatusActive,
|
||||
}
|
||||
if req.ExpiresAt != nil {
|
||||
t, err := time.Parse(time.RFC3339, *req.ExpiresAt)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "expires_at must be RFC3339")
|
||||
return
|
||||
}
|
||||
k.ExpiresAt = &t
|
||||
}
|
||||
if err := h.a.DB.Create(&k).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to create key")
|
||||
return
|
||||
}
|
||||
resp.Created(c, gin.H{
|
||||
"id": k.ID,
|
||||
"name": k.Name,
|
||||
"key": plain, // 仅此一次
|
||||
"key_prefix": k.KeyPrefix,
|
||||
"created_at": k.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// ListKeys GET /api/v1/keys
|
||||
func (h *Handler) ListKeys(c *gin.Context) {
|
||||
u, ok := userFromContext(c)
|
||||
if !ok {
|
||||
resp.Fail(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
var keys []store.APIKey
|
||||
if err := h.a.DB.Where("user_id = ?", u.ID).Order("id DESC").Find(&keys).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to load keys")
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
out = append(out, gin.H{
|
||||
"id": k.ID,
|
||||
"name": k.Name,
|
||||
"key_prefix": k.KeyPrefix,
|
||||
"quota_tokens_per_day": k.QuotaTokensPerDay,
|
||||
"quota_requests_per_day": k.QuotaRequestsPerDay,
|
||||
"allowed_models": k.AllowedModels,
|
||||
"expires_at": k.ExpiresAt,
|
||||
"status": k.Status,
|
||||
"last_used_at": k.LastUsedAt,
|
||||
"created_at": k.CreatedAt,
|
||||
})
|
||||
}
|
||||
resp.OK(c, gin.H{"items": out})
|
||||
}
|
||||
|
||||
// PatchKey PATCH /api/v1/keys/:id — 改名、限额、白名单、启停。
|
||||
func (h *Handler) PatchKey(c *gin.Context) {
|
||||
u, ok := userFromContext(c)
|
||||
if !ok {
|
||||
resp.Fail(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid key id")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day"`
|
||||
QuotaRequestsPerDay *int `json:"quota_requests_per_day"`
|
||||
AllowedModels *[]string `json:"allowed_models"`
|
||||
Status *string `json:"status"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||||
return
|
||||
}
|
||||
var k store.APIKey
|
||||
if err := h.a.DB.Where("id = ? AND user_id = ?", id, u.ID).First(&k).Error; err != nil {
|
||||
resp.Fail(c, http.StatusNotFound, "key not found")
|
||||
return
|
||||
}
|
||||
updates := map[string]any{}
|
||||
if req.Name != nil {
|
||||
updates["name"] = *req.Name
|
||||
}
|
||||
if req.QuotaTokensPerDay != nil {
|
||||
updates["quota_tokens_per_day"] = *req.QuotaTokensPerDay
|
||||
}
|
||||
if req.QuotaRequestsPerDay != nil {
|
||||
updates["quota_requests_per_day"] = *req.QuotaRequestsPerDay
|
||||
}
|
||||
if req.AllowedModels != nil {
|
||||
updates["allowed_models"] = *req.AllowedModels
|
||||
}
|
||||
if req.Status != nil {
|
||||
if *req.Status != store.KeyStatusActive && *req.Status != store.KeyStatusRevoked {
|
||||
resp.Fail(c, http.StatusBadRequest, "status must be active or revoked")
|
||||
return
|
||||
}
|
||||
updates["status"] = *req.Status
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if err := h.a.DB.Model(&k).Updates(updates).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to update key")
|
||||
return
|
||||
}
|
||||
}
|
||||
resp.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// DeleteKey DELETE /api/v1/keys/:id — 吊销。
|
||||
func (h *Handler) DeleteKey(c *gin.Context) {
|
||||
u, ok := userFromContext(c)
|
||||
if !ok {
|
||||
resp.Fail(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid key id")
|
||||
return
|
||||
}
|
||||
res := h.a.DB.Model(&store.APIKey{}).
|
||||
Where("id = ? AND user_id = ?", id, u.ID).
|
||||
Update("status", store.KeyStatusRevoked)
|
||||
if res.Error != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to revoke key")
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
resp.Fail(c, http.StatusNotFound, "key not found")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Package middleware Gin 中间件:会话鉴权、admin 校验。
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/openteam/server/internal/app"
|
||||
"github.com/openteam/server/internal/pkg/resp"
|
||||
"github.com/openteam/server/internal/store"
|
||||
)
|
||||
|
||||
const CtxSessionUser = "session_user"
|
||||
|
||||
// SessionAuth 会话鉴权:Authorization: Bearer <access token>。
|
||||
func SessionAuth(a *app.App) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
auth := c.GetHeader("Authorization")
|
||||
if len(auth) < 8 || auth[:7] != "Bearer " {
|
||||
resp.Fail(c, http.StatusUnauthorized, "missing or invalid access token")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
claims, err := a.JWT.Parse(auth[7:])
|
||||
if err != nil || claims.Subject != "access" {
|
||||
resp.Fail(c, http.StatusUnauthorized, "invalid or expired access token")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
var u store.User
|
||||
if err := a.DB.First(&u, claims.UserID).Error; err != nil {
|
||||
resp.Fail(c, http.StatusUnauthorized, "user not found")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if u.Status != store.UserStatusActive {
|
||||
resp.Fail(c, http.StatusForbidden, "user account disabled")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(CtxSessionUser, &u)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AdminOnly 仅管理员。
|
||||
func AdminOnly(c *gin.Context) {
|
||||
u, ok := c.Get(CtxSessionUser)
|
||||
if !ok {
|
||||
resp.Fail(c, http.StatusUnauthorized, "authentication required")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if u.(*store.User).Role != store.RoleAdmin {
|
||||
resp.Fail(c, http.StatusForbidden, "admin permission required")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// CORS 开发期放开;生产按配置白名单(PLANNING §8)。
|
||||
func CORS() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin != "" {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Vary", "Origin")
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
}
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/openteam/server/internal/api/middleware"
|
||||
"github.com/openteam/server/internal/app"
|
||||
"github.com/openteam/server/internal/proxy"
|
||||
)
|
||||
|
||||
// NewRouter 装配所有路由:
|
||||
// - /v1/* 代理端点(Bearer API Key,OpenAI 格式错误体)
|
||||
// - /api/v1/* 管理 API(会话 JWT)
|
||||
func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
|
||||
if a.Cfg.Env == "production" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery(), middleware.CORS())
|
||||
|
||||
h := NewHandler(a)
|
||||
|
||||
// --- 代理端点(对外)---
|
||||
proxyGroup := r.Group("/v1")
|
||||
{
|
||||
proxyGroup.Any("/chat/completions", gw.Auth, gw.Handle)
|
||||
proxyGroup.Any("/responses", gw.Auth, gw.Handle)
|
||||
proxyGroup.Any("/models", gw.Auth, gw.Handle)
|
||||
}
|
||||
// 未匹配的 /v1/* 返回 OpenAI 风格 404(需先认证)
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
if len(c.Request.URL.Path) >= 3 && c.Request.URL.Path[:3] == "/v1" {
|
||||
gw.Auth(c)
|
||||
if !c.IsAborted() {
|
||||
gw.Handle(c)
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
})
|
||||
|
||||
// --- 管理 API ---
|
||||
api := r.Group("/api/v1")
|
||||
{
|
||||
auth := api.Group("/auth")
|
||||
{
|
||||
auth.POST("/register", h.Register)
|
||||
auth.POST("/login", h.Login)
|
||||
auth.POST("/refresh", h.Refresh)
|
||||
auth.POST("/logout", h.Logout)
|
||||
auth.GET("/me", middleware.SessionAuth(a), h.Me)
|
||||
}
|
||||
|
||||
user := api.Group("", middleware.SessionAuth(a))
|
||||
{
|
||||
user.GET("/user/profile", h.UserProfile)
|
||||
user.GET("/user/balance", h.UserBalance)
|
||||
user.GET("/user/models", h.UserModels)
|
||||
user.GET("/usage/summary", h.UsageSummary)
|
||||
user.GET("/usage/stats", h.UsageStats)
|
||||
user.GET("/usage/logs", h.UsageLogs)
|
||||
user.POST("/keys", h.CreateKey)
|
||||
user.GET("/keys", h.ListKeys)
|
||||
user.PATCH("/keys/:id", h.PatchKey)
|
||||
user.DELETE("/keys/:id", h.DeleteKey)
|
||||
}
|
||||
|
||||
admin := api.Group("/admin", middleware.SessionAuth(a), middleware.AdminOnly)
|
||||
{
|
||||
admin.GET("/users", h.AdminUsers)
|
||||
admin.PATCH("/users/:id", h.AdminPatchUser)
|
||||
admin.POST("/users/:id/balance", h.AdminAdjustBalance)
|
||||
admin.GET("/config", h.AdminConfig)
|
||||
admin.PUT("/config", h.AdminPutConfig)
|
||||
// 渠道/模型/用量管理(M4);充值审核(M5 预留)
|
||||
}
|
||||
}
|
||||
|
||||
r.GET("/healthz", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/openteam/server/internal/pkg/resp"
|
||||
"github.com/openteam/server/internal/store"
|
||||
)
|
||||
|
||||
// UserProfile GET /api/v1/user/profile
|
||||
func (h *Handler) UserProfile(c *gin.Context) {
|
||||
u, _ := userFromContext(c)
|
||||
resp.OK(c, gin.H{"user": h.publicUser(u)})
|
||||
}
|
||||
|
||||
// UserBalance GET /api/v1/user/balance — 余额 + 近 30 日消耗。
|
||||
func (h *Handler) UserBalance(c *gin.Context) {
|
||||
u, _ := userFromContext(c)
|
||||
var spent float64
|
||||
h.a.DB.Model(&store.UsageLog{}).
|
||||
Where("user_id = ? AND status = ? AND created_at >= ?", u.ID, store.UsageStatusSuccess, time.Now().Add(-30*24*time.Hour)).
|
||||
Select("COALESCE(SUM(cost),0)").Scan(&spent)
|
||||
resp.OK(c, gin.H{
|
||||
"balance": u.Balance,
|
||||
"spent_last_30d": spent,
|
||||
"today": h.todayUsage(c, u.ID),
|
||||
"models_available": h.availableModelCount(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) availableModelCount() int64 {
|
||||
var n int64
|
||||
h.a.DB.Model(&store.Model{}).Where("enabled = ?", true).Count(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
func (h *Handler) todayUsage(c *gin.Context, userID uint64) gin.H {
|
||||
var requests int64
|
||||
var tokens int64
|
||||
var cost float64
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
h.a.DB.Model(&store.UsageDaily{}).
|
||||
Where("user_id = ? AND date = ?", userID, today).
|
||||
Select("COALESCE(SUM(requests),0), COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens),0), COALESCE(SUM(cost),0)").
|
||||
Row().Scan(&requests, &tokens, &cost)
|
||||
return gin.H{"requests": requests, "tokens": tokens, "cost": cost}
|
||||
}
|
||||
|
||||
// UserModels GET /api/v1/user/models — 控制台可用模型列表(无需 API Key)。
|
||||
func (h *Handler) UserModels(c *gin.Context) {
|
||||
var ms []store.Model
|
||||
if err := h.a.DB.Where("enabled = ?", true).Order("sort ASC, id ASC").Find(&ms).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to load models")
|
||||
return
|
||||
}
|
||||
out := make([]string, 0, len(ms))
|
||||
for _, m := range ms {
|
||||
out = append(out, m.Name)
|
||||
}
|
||||
resp.OK(c, gin.H{"items": out})
|
||||
}
|
||||
|
||||
// UsageSummary GET /api/v1/usage/summary — 今日/本月汇总。
|
||||
func (h *Handler) UsageSummary(c *gin.Context) {
|
||||
u, _ := userFromContext(c)
|
||||
now := time.Now().UTC()
|
||||
today := now.Format("2006-01-02")
|
||||
month := now.Format("2006-01")
|
||||
var todayReq, monthReq int64
|
||||
var todayTok, monthTok int64
|
||||
var todayCost, monthCost float64
|
||||
h.a.DB.Model(&store.UsageDaily{}).Where("user_id = ? AND date = ?", u.ID, today).
|
||||
Select("COALESCE(SUM(requests),0), COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens),0), COALESCE(SUM(cost),0)").
|
||||
Row().Scan(&todayReq, &todayTok, &todayCost)
|
||||
h.a.DB.Model(&store.UsageDaily{}).Where("user_id = ? AND date LIKE ?", u.ID, month+"%").
|
||||
Select("COALESCE(SUM(requests),0), COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens),0), COALESCE(SUM(cost),0)").
|
||||
Row().Scan(&monthReq, &monthTok, &monthCost)
|
||||
resp.OK(c, gin.H{
|
||||
"today": gin.H{"requests": todayReq, "tokens": todayTok, "cost": todayCost},
|
||||
"month": gin.H{"requests": monthReq, "tokens": monthTok, "cost": monthCost},
|
||||
})
|
||||
}
|
||||
|
||||
// UsageStats GET /api/v1/usage/stats?from&to&group=day|model
|
||||
func (h *Handler) UsageStats(c *gin.Context) {
|
||||
u, _ := userFromContext(c)
|
||||
from := c.DefaultQuery("from", time.Now().Add(-30*24*time.Hour).Format("2006-01-02"))
|
||||
to := c.DefaultQuery("to", time.Now().Format("2006-01-02"))
|
||||
group := c.DefaultQuery("group", "day")
|
||||
|
||||
q := h.a.DB.Model(&store.UsageDaily{}).Where("user_id = ? AND date BETWEEN ? AND ?", u.ID, from, to)
|
||||
out := make([]gin.H, 0, 64)
|
||||
if group == "model" {
|
||||
var rows []struct {
|
||||
ModelID uint64
|
||||
Requests int64
|
||||
Tokens int64
|
||||
Cost float64
|
||||
}
|
||||
q.Select("model_id, SUM(requests) requests, SUM(input_tokens+output_tokens+cache_read_tokens) tokens, SUM(cost) cost").
|
||||
Group("model_id").Scan(&rows)
|
||||
for _, r := range rows {
|
||||
var m store.Model
|
||||
name := strconv.FormatUint(r.ModelID, 10)
|
||||
if h.a.DB.First(&m, r.ModelID).Error == nil {
|
||||
name = m.Name
|
||||
}
|
||||
out = append(out, gin.H{"model": name, "requests": r.Requests, "tokens": r.Tokens, "cost": r.Cost})
|
||||
}
|
||||
} else {
|
||||
var rows []struct {
|
||||
Date string
|
||||
Requests int64
|
||||
Tokens int64
|
||||
Cost float64
|
||||
}
|
||||
q.Select("date, SUM(requests) requests, SUM(input_tokens+output_tokens+cache_read_tokens) tokens, SUM(cost) cost").
|
||||
Group("date").Order("date").Scan(&rows)
|
||||
for _, r := range rows {
|
||||
out = append(out, gin.H{"date": r.Date, "requests": r.Requests, "tokens": r.Tokens, "cost": r.Cost})
|
||||
}
|
||||
}
|
||||
resp.OK(c, gin.H{"items": out})
|
||||
}
|
||||
|
||||
// UsageLogs GET /api/v1/usage/logs?from&to&page&page_size&model
|
||||
func (h *Handler) UsageLogs(c *gin.Context) {
|
||||
u, _ := userFromContext(c)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
q := h.a.DB.Model(&store.UsageLog{}).Where("user_id = ?", u.ID)
|
||||
if from := c.Query("from"); from != "" {
|
||||
q = q.Where("created_at >= ?", from+" 00:00:00")
|
||||
}
|
||||
if to := c.Query("to"); to != "" {
|
||||
q = q.Where("created_at <= ?", to+" 23:59:59")
|
||||
}
|
||||
if model := c.Query("model"); model != "" {
|
||||
q = q.Where("model_name = ?", model)
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var logs []store.UsageLog
|
||||
q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&logs)
|
||||
out := make([]gin.H, 0, len(logs))
|
||||
for _, l := range logs {
|
||||
out = append(out, gin.H{
|
||||
"id": l.ID, "request_id": l.RequestID, "model": l.ModelName, "protocol": l.Protocol,
|
||||
"input_tokens": l.InputTokens, "output_tokens": l.OutputTokens,
|
||||
"cache_read_tokens": l.CacheReadTokens, "cost": l.Cost,
|
||||
"latency_ms": l.LatencyMS, "status": l.Status, "error_code": l.ErrorCode,
|
||||
"created_at": l.CreatedAt,
|
||||
})
|
||||
}
|
||||
resp.OK(c, gin.H{"items": out, "total": total, "page": page, "page_size": size})
|
||||
}
|
||||
Reference in New Issue
Block a user