Compare commits

...

25 Commits

Author SHA1 Message Date
shaw
c15b419c4c feat(backend): 添加 event_logging 接口直接返回200
将原本在nginx处理的遥测日志请求移至后端,
忽略Claude Code客户端发送的日志数据。
2025-12-19 09:39:57 +08:00
shaw
5bd27a5d17 fix(frontend): 优化分组表单中订阅模式的字段显示逻辑
- 订阅模式下隐藏 Exclusive 字段并默认为开启状态
- 编辑分组时禁用计费类型字段,防止修改
- 移除编辑表单中无用的 subscription_type watch
2025-12-19 08:41:30 +08:00
Wesley Liddick
0e7b8aab8c Merge pull request #4 from NepetaLemon/refactor/backend-wire-provider-sets
refactor(backend): 拆分 Wire ProviderSet
2025-12-18 19:27:49 -05:00
Forest
236908c03d refactor(backend): 拆分 Wire ProviderSet 2025-12-19 00:03:29 +08:00
shaw
67d028cf50 fix: 修复用户修改密码接口404问题
将后端路由与前端API调用对齐:
- /user/profile -> /users/me
- PUT /user/password -> POST /users/me/password
2025-12-18 22:59:49 +08:00
shaw
66ba487697 fix: 修复前端github项目地址 2025-12-18 22:47:42 +08:00
Wesley Liddick
8c7875aa4d Merge pull request #3 from NepetaLemon/refactor/backend-wire-bootstrap
refactor(backend): 引入 Wire 重构服务启动与依赖组装
2025-12-18 09:12:15 -05:00
shaw
145171464f fix: 修复前端多个 bug
1. 版本号闪烁问题
   - 将版本信息缓存到 Pinia store,避免每次路由切换都重新请求
   - 添加加载占位符,版本为空时显示骨架屏

2. 管理员登录跳转问题
   - 管理员登录后现在正确跳转到 /admin/dashboard
   - 普通用户仍跳转到 /dashboard

3. Dashboard 页面空白报错
   - 修复 API 返回 null 时访问 .length 导致的 TypeError
   - 为 computed 属性添加可选链操作符保护
   - 为数据赋值添加空数组默认值
2025-12-18 22:11:29 +08:00
Forest
e5aa676853 refactor(backend): 引入 Wire 重构服务启动与依赖组装 2025-12-18 22:07:17 +08:00
shaw
9b4fc42457 feat: 实现后台在线更新功能
- 前端添加更新和重启按钮,支持一键更新 Release 构建
- 修复条件判断优先级问题,确保错误/成功状态正确显示
- 后端使用原子文件替换模式,确保更新过程安全可靠
- 在可执行文件同目录创建临时文件,保证 rename 原子性
- 删除未使用的 copyFile 函数,保持代码整洁
2025-12-18 21:15:10 +08:00
shaw
caae7e4603 feat: 改进安装脚本的交互体验和自动化流程
- 修复 curl | bash 管道模式下无法交互式输入的问题
  - 使用 /dev/tty 检测终端可用性替代 stdin 检测
  - 所有 read 命令从 /dev/tty 读取用户输入
- 安装完成后自动启动服务和启用开机自启
- 使用 ipinfo.io API 获取公网 IP 用于显示访问地址
- 简化安装完成后的输出信息
2025-12-18 20:53:29 +08:00
shaw
a26db8b3e2 fix: 修复前端页面刷新时偶发空白渲染的竞态条件问题
使用 router.isReady() 等待路由器完成初始导航后再挂载应用,
避免 RouterView 在路由未就绪时渲染空的 Comment 节点。
2025-12-18 20:45:56 +08:00
shaw
8e81e395b3 refactor: 使用行业标准方案重构服务重启逻辑
重构内容:
- 移除复杂的 sudo systemctl restart 方案
- 改用 os.Exit(0) + systemd Restart=always 的标准做法
- 删除 sudoers 配置及相关代码
- 删除 sub2api-sudoers 文件

优势:
- 代码从 85+ 行简化到 47 行
- 无需 sudo 权限配置
- 无需特殊用户 shell 配置
- 更简单、更可靠
- 符合行业最佳实践(Docker/K8s 等均采用此方案)

工作原理:
- 服务调用 os.Exit(0) 优雅退出
- systemd 检测到退出后自动重启(Restart=always)
2025-12-18 20:32:24 +08:00
shaw
f0e89992f7 fix: 使用 setsid 确保重启命令独立于父进程执行
问题原因:
- cmd.Start() 启动的子进程与父进程在同一会话中
- 当 systemctl restart 发送 SIGTERM 给父进程时
- 子进程可能也会被终止,导致重启命令无法完成

修复内容:
- 使用 setsid 创建新会话,子进程完全独立于父进程
- 分离标准输入/输出/错误流
- 确保即使父进程被 kill,重启命令仍能执行完成
2025-12-18 20:00:53 +08:00
shaw
4eaa0cf14a fix: 使用完整路径执行 sudo 和 systemctl 命令
问题原因:
- systemd 服务的 PATH 环境变量可能受限
- 直接使用 "sudo" 可能找不到可执行文件

修复内容:
- 添加 findExecutable 函数动态查找可执行文件路径
- 先尝试 exec.LookPath,再检查常见系统路径
- 添加日志显示实际使用的路径,方便调试
- 兼容不同 Linux 发行版的路径差异
2025-12-18 19:58:25 +08:00
shaw
e9ec2280ec fix: 修复 sudo 在非交互模式下无法执行的问题
问题原因:
- sudo 命令没有 -n 选项
- 在后台服务中,sudo 会尝试从终端读取密码
- 由于没有终端,命令静默失败

修复内容:
- 添加 sudo -n 选项强制非交互模式
- 如果需要密码会立即失败并返回错误,而不是挂起
2025-12-18 19:37:41 +08:00
Wesley Liddick
bb7bfb6980 Merge pull request #1 from 7836246/fix/concurrent-proxy-race-condition
fix: 修复并发请求时共享httpClient.Transport导致的竞态条件
2025-12-18 06:37:22 -05:00
shaw
b66f97c100 fix: 修复 install.sh 优先使用旧 sudoers 文件的问题
问题原因:
- install.sh 优先从 tar.gz 复制 sudoers 文件
- 旧版 Release 中的 sudoers 文件没有 /usr/bin/systemctl 路径
- 即使脚本更新了,仍然会使用旧的配置

修复内容:
- 移除对 tar.gz 中 sudoers 文件的依赖
- 总是使用脚本中内嵌的最新配置
- 确保新版脚本立即生效,无需等待新 Release
2025-12-18 19:27:47 +08:00
shaw
b51ad0d893 fix: 修复 sudoers 中 systemctl 路径不兼容的问题
问题原因:
- sudoers 只配置了 /bin/systemctl 路径
- 部分系统(如 Ubuntu 22.04+)的 systemctl 位于 /usr/bin/systemctl
- 路径不匹配导致 sudo 仍然需要密码

修复内容:
- 同时支持 /bin/systemctl 和 /usr/bin/systemctl 两个路径
- 兼容 Debian/Ubuntu 和 RHEL/CentOS 等不同发行版
2025-12-18 19:17:05 +08:00
shaw
4eb22d8ee9 fix: 修复服务用户 shell 导致无法执行 sudo 重启的问题
问题原因:
- 服务用户 sub2api 的 shell 被设置为 /bin/false
- 导致无法执行 sudo systemctl restart 命令
- 安装/升级后服务无法自动重启

修复内容:
- 新安装时使用 /bin/sh 替代 /bin/false
- 升级时自动检测并修复旧版本用户的 shell 配置
- 修复失败时给出警告和手动修复命令,不中断安装流程
2025-12-18 19:07:33 +08:00
江西小徐
2392e7cf99 fix: 修复并发请求时共享httpClient.Transport导致的竞态条件
问题描述:
当多个请求并发执行且使用不同代理配置时,它们会同时修改共享的
s.httpClient.Transport,导致请求可能使用错误的代理(数据泄露风险)
或意外失败。

修复方案:
为需要代理的请求创建独立的http.Client,而不是修改共享的httpClient.Transport。

改动内容:
- 新增 buildUpstreamRequestResult 结构体,返回请求和可选的独立client
- 修改 buildUpstreamRequest 方法,配置代理时创建独立client
- 更新 Forward 方法,根据是否有代理选择合适的client
2025-12-18 18:14:48 +08:00
shaw
8e4bd42e8c fix: 修复安装/升级无法重启服务的问题 2025-12-18 17:44:49 +08:00
shaw
ef3199f0ca fix: 修复脚本的一些参数问题 2025-12-18 17:25:26 +08:00
shaw
e440530acc fix: release error 2025-12-18 17:12:57 +08:00
shaw
e2ae9fe50b fix: release error 2025-12-18 17:02:21 +08:00
49 changed files with 1702 additions and 839 deletions

View File

@@ -85,11 +85,25 @@ jobs:
go-version: '1.24' go-version: '1.24'
cache-dependency-path: backend/go.sum cache-dependency-path: backend/go.sum
- name: Fetch tags with annotations
run: |
# 确保获取完整的 annotated tag 信息
git fetch --tags --force
- name: Get tag message - name: Get tag message
id: tag_message id: tag_message
run: | run: |
TAG_NAME=${GITHUB_REF#refs/tags/}
echo "Processing tag: $TAG_NAME"
# 获取完整的 tag message跳过第一行标题 # 获取完整的 tag message跳过第一行标题
TAG_MESSAGE=$(git tag -l --format='%(contents:body)' ${GITHUB_REF#refs/tags/}) TAG_MESSAGE=$(git tag -l --format='%(contents:body)' "$TAG_NAME")
# 调试输出
echo "Tag message length: ${#TAG_MESSAGE}"
echo "Tag message preview:"
echo "$TAG_MESSAGE" | head -10
# 使用 EOF 分隔符处理多行内容 # 使用 EOF 分隔符处理多行内容
echo "message<<EOF" >> $GITHUB_OUTPUT echo "message<<EOF" >> $GITHUB_OUTPUT
echo "$TAG_MESSAGE" >> $GITHUB_OUTPUT echo "$TAG_MESSAGE" >> $GITHUB_OUTPUT

3
.gitignore vendored
View File

@@ -91,4 +91,5 @@ backend/data/
# =================== # ===================
tests tests
CLAUDE.md CLAUDE.md
.claude .claude
scripts

View File

@@ -16,6 +16,14 @@ English | [中文](README_CN.md)
--- ---
## Demo
Try Sub2API online: **https://v2.pincc.ai/**
| Email | Password |
|-------|----------|
| admin@sub2api.com | admin123 |
## Overview ## Overview
Sub2API is an AI API gateway platform designed to distribute and manage API quotas from AI product subscriptions (like Claude Code $200/month). Users can access upstream AI services through platform-generated API Keys, while the platform handles authentication, billing, load balancing, and request forwarding. Sub2API is an AI API gateway platform designed to distribute and manage API quotas from AI product subscriptions (like Claude Code $200/month). Users can access upstream AI services through platform-generated API Keys, while the platform handles authentication, billing, load balancing, and request forwarding.
@@ -107,7 +115,7 @@ sudo journalctl -u sub2api -f
sudo systemctl restart sub2api sudo systemctl restart sub2api
# Uninstall # Uninstall
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash -s uninstall curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash -s -- uninstall -y
``` ```
--- ---

View File

@@ -16,6 +16,14 @@
--- ---
## 在线体验
体验地址:**https://v2.pincc.ai/**
| 邮箱 | 密码 |
|------|------|
| admin@sub2api.com | admin123 |
## 项目概述 ## 项目概述
Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅(如 Claude Code $200/月)的 API 配额。用户通过平台生成的 API Key 调用上游 AI 服务,平台负责鉴权、计费、负载均衡和请求转发。 Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅(如 Claude Code $200/月)的 API 配额。用户通过平台生成的 API Key 调用上游 AI 服务,平台负责鉴权、计费、负载均衡和请求转发。
@@ -107,7 +115,7 @@ sudo journalctl -u sub2api -f
sudo systemctl restart sub2api sudo systemctl restart sub2api
# 卸载 # 卸载
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash -s uninstall curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash -s -- uninstall -y
``` ```
--- ---

6
backend/Makefile Normal file
View File

@@ -0,0 +1,6 @@
.PHONY: wire
wire:
@echo "生成 Wire 代码..."
@cd cmd/server && go generate
@echo "Wire 代码生成完成"

View File

@@ -1,8 +1,11 @@
package main package main
//go:generate go run github.com/google/wire/cmd/wire
import ( import (
"context" "context"
_ "embed" _ "embed"
"errors"
"flag" "flag"
"log" "log"
"net/http" "net/http"
@@ -12,21 +15,12 @@ import (
"syscall" "syscall"
"time" "time"
"sub2api/internal/config"
"sub2api/internal/handler" "sub2api/internal/handler"
"sub2api/internal/middleware" "sub2api/internal/middleware"
"sub2api/internal/model"
"sub2api/internal/pkg/timezone"
"sub2api/internal/repository"
"sub2api/internal/service"
"sub2api/internal/setup" "sub2api/internal/setup"
"sub2api/internal/web" "sub2api/internal/web"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
) )
//go:embed VERSION //go:embed VERSION
@@ -110,78 +104,25 @@ func runSetupServer() {
} }
func runMainServer() { func runMainServer() {
// 加载配置
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// 初始化时区(类似 PHP 的 date_default_timezone_set
if err := timezone.Init(cfg.Timezone); err != nil {
log.Fatalf("Failed to initialize timezone: %v", err)
}
// 初始化数据库
db, err := initDB(cfg)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
// 初始化Redis
rdb := initRedis(cfg)
// 初始化Repository
repos := repository.NewRepositories(db)
// 初始化Service
services := service.NewServices(repos, rdb, cfg)
// 初始化Handler
buildInfo := handler.BuildInfo{ buildInfo := handler.BuildInfo{
Version: Version, Version: Version,
BuildType: BuildType, BuildType: BuildType,
} }
handlers := handler.NewHandlers(services, repos, rdb, buildInfo)
// 设置Gin模式 app, err := initializeApplication(buildInfo)
if cfg.Server.Mode == "release" { if err != nil {
gin.SetMode(gin.ReleaseMode) log.Fatalf("Failed to initialize application: %v", err)
}
// 创建路由
r := gin.New()
r.Use(gin.Recovery())
r.Use(middleware.Logger())
r.Use(middleware.CORS())
// 注册路由
registerRoutes(r, handlers, services, repos)
// Serve embedded frontend if available
if web.HasEmbeddedFrontend() {
r.Use(web.ServeEmbeddedFrontend())
} }
defer app.Cleanup()
// 启动服务器 // 启动服务器
srv := &http.Server{
Addr: cfg.Server.Address(),
Handler: r,
// ReadHeaderTimeout: 读取请求头的超时时间,防止慢速请求头攻击
ReadHeaderTimeout: time.Duration(cfg.Server.ReadHeaderTimeout) * time.Second,
// IdleTimeout: 空闲连接超时时间,释放不活跃的连接资源
IdleTimeout: time.Duration(cfg.Server.IdleTimeout) * time.Second,
// 注意:不设置 WriteTimeout因为流式响应可能持续十几分钟
// 不设置 ReadTimeout因为大请求体可能需要较长时间读取
}
// 优雅关闭
go func() { go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := app.Server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("Failed to start server: %v", err) log.Fatalf("Failed to start server: %v", err)
} }
}() }()
log.Printf("Server started on %s", cfg.Server.Address()) log.Printf("Server started on %s", app.Server.Addr)
// 等待中断信号 // 等待中断信号
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)
@@ -193,277 +134,9 @@ func runMainServer() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
if err := srv.Shutdown(ctx); err != nil { if err := app.Server.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err) log.Fatalf("Server forced to shutdown: %v", err)
} }
log.Println("Server exited") log.Println("Server exited")
} }
func initDB(cfg *config.Config) (*gorm.DB, error) {
gormConfig := &gorm.Config{}
if cfg.Server.Mode == "debug" {
gormConfig.Logger = logger.Default.LogMode(logger.Info)
}
// 使用带时区的 DSN 连接数据库
db, err := gorm.Open(postgres.Open(cfg.Database.DSNWithTimezone(cfg.Timezone)), gormConfig)
if err != nil {
return nil, err
}
// 自动迁移(始终执行,确保数据库结构与代码同步)
// GORM 的 AutoMigrate 只会添加新字段,不会删除或修改已有字段,是安全的
if err := model.AutoMigrate(db); err != nil {
return nil, err
}
return db, nil
}
func initRedis(cfg *config.Config) *redis.Client {
return redis.NewClient(&redis.Options{
Addr: cfg.Redis.Address(),
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
})
}
func registerRoutes(r *gin.Engine, h *handler.Handlers, s *service.Services, repos *repository.Repositories) {
// 健康检查
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
// API v1
v1 := r.Group("/api/v1")
{
// 公开接口
auth := v1.Group("/auth")
{
auth.POST("/register", h.Auth.Register)
auth.POST("/login", h.Auth.Login)
auth.POST("/send-verify-code", h.Auth.SendVerifyCode)
}
// 公开设置(无需认证)
settings := v1.Group("/settings")
{
settings.GET("/public", h.Setting.GetPublicSettings)
}
// 需要认证的接口
authenticated := v1.Group("")
authenticated.Use(middleware.JWTAuth(s.Auth, repos.User))
{
// 当前用户信息
authenticated.GET("/auth/me", h.Auth.GetCurrentUser)
// 用户接口
user := authenticated.Group("/user")
{
user.GET("/profile", h.User.GetProfile)
user.PUT("/password", h.User.ChangePassword)
}
// API Key管理
keys := authenticated.Group("/keys")
{
keys.GET("", h.APIKey.List)
keys.GET("/:id", h.APIKey.GetByID)
keys.POST("", h.APIKey.Create)
keys.PUT("/:id", h.APIKey.Update)
keys.DELETE("/:id", h.APIKey.Delete)
}
// 用户可用分组(非管理员接口)
groups := authenticated.Group("/groups")
{
groups.GET("/available", h.APIKey.GetAvailableGroups)
}
// 使用记录
usage := authenticated.Group("/usage")
{
usage.GET("", h.Usage.List)
usage.GET("/:id", h.Usage.GetByID)
usage.GET("/stats", h.Usage.Stats)
// User dashboard endpoints
usage.GET("/dashboard/stats", h.Usage.DashboardStats)
usage.GET("/dashboard/trend", h.Usage.DashboardTrend)
usage.GET("/dashboard/models", h.Usage.DashboardModels)
usage.POST("/dashboard/api-keys-usage", h.Usage.DashboardApiKeysUsage)
}
// 卡密兑换
redeem := authenticated.Group("/redeem")
{
redeem.POST("", h.Redeem.Redeem)
redeem.GET("/history", h.Redeem.GetHistory)
}
// 用户订阅
subscriptions := authenticated.Group("/subscriptions")
{
subscriptions.GET("", h.Subscription.List)
subscriptions.GET("/active", h.Subscription.GetActive)
subscriptions.GET("/progress", h.Subscription.GetProgress)
subscriptions.GET("/summary", h.Subscription.GetSummary)
}
}
// 管理员接口
admin := v1.Group("/admin")
admin.Use(middleware.JWTAuth(s.Auth, repos.User), middleware.AdminOnly())
{
// 仪表盘
dashboard := admin.Group("/dashboard")
{
dashboard.GET("/stats", h.Admin.Dashboard.GetStats)
dashboard.GET("/realtime", h.Admin.Dashboard.GetRealtimeMetrics)
dashboard.GET("/trend", h.Admin.Dashboard.GetUsageTrend)
dashboard.GET("/models", h.Admin.Dashboard.GetModelStats)
dashboard.GET("/api-keys-trend", h.Admin.Dashboard.GetApiKeyUsageTrend)
dashboard.GET("/users-trend", h.Admin.Dashboard.GetUserUsageTrend)
dashboard.POST("/users-usage", h.Admin.Dashboard.GetBatchUsersUsage)
dashboard.POST("/api-keys-usage", h.Admin.Dashboard.GetBatchApiKeysUsage)
}
// 用户管理
users := admin.Group("/users")
{
users.GET("", h.Admin.User.List)
users.GET("/:id", h.Admin.User.GetByID)
users.POST("", h.Admin.User.Create)
users.PUT("/:id", h.Admin.User.Update)
users.DELETE("/:id", h.Admin.User.Delete)
users.POST("/:id/balance", h.Admin.User.UpdateBalance)
users.GET("/:id/api-keys", h.Admin.User.GetUserAPIKeys)
users.GET("/:id/usage", h.Admin.User.GetUserUsage)
}
// 分组管理
groups := admin.Group("/groups")
{
groups.GET("", h.Admin.Group.List)
groups.GET("/all", h.Admin.Group.GetAll)
groups.GET("/:id", h.Admin.Group.GetByID)
groups.POST("", h.Admin.Group.Create)
groups.PUT("/:id", h.Admin.Group.Update)
groups.DELETE("/:id", h.Admin.Group.Delete)
groups.GET("/:id/stats", h.Admin.Group.GetStats)
groups.GET("/:id/api-keys", h.Admin.Group.GetGroupAPIKeys)
}
// 账号管理
accounts := admin.Group("/accounts")
{
accounts.GET("", h.Admin.Account.List)
accounts.GET("/:id", h.Admin.Account.GetByID)
accounts.POST("", h.Admin.Account.Create)
accounts.PUT("/:id", h.Admin.Account.Update)
accounts.DELETE("/:id", h.Admin.Account.Delete)
accounts.POST("/:id/test", h.Admin.Account.Test)
accounts.POST("/:id/refresh", h.Admin.Account.Refresh)
accounts.GET("/:id/stats", h.Admin.Account.GetStats)
accounts.POST("/:id/clear-error", h.Admin.Account.ClearError)
accounts.GET("/:id/usage", h.Admin.Account.GetUsage)
accounts.GET("/:id/today-stats", h.Admin.Account.GetTodayStats)
accounts.POST("/:id/clear-rate-limit", h.Admin.Account.ClearRateLimit)
accounts.POST("/:id/schedulable", h.Admin.Account.SetSchedulable)
accounts.POST("/batch", h.Admin.Account.BatchCreate)
// OAuth routes
accounts.POST("/generate-auth-url", h.Admin.OAuth.GenerateAuthURL)
accounts.POST("/generate-setup-token-url", h.Admin.OAuth.GenerateSetupTokenURL)
accounts.POST("/exchange-code", h.Admin.OAuth.ExchangeCode)
accounts.POST("/exchange-setup-token-code", h.Admin.OAuth.ExchangeSetupTokenCode)
accounts.POST("/cookie-auth", h.Admin.OAuth.CookieAuth)
accounts.POST("/setup-token-cookie-auth", h.Admin.OAuth.SetupTokenCookieAuth)
}
// 代理管理
proxies := admin.Group("/proxies")
{
proxies.GET("", h.Admin.Proxy.List)
proxies.GET("/all", h.Admin.Proxy.GetAll)
proxies.GET("/:id", h.Admin.Proxy.GetByID)
proxies.POST("", h.Admin.Proxy.Create)
proxies.PUT("/:id", h.Admin.Proxy.Update)
proxies.DELETE("/:id", h.Admin.Proxy.Delete)
proxies.POST("/:id/test", h.Admin.Proxy.Test)
proxies.GET("/:id/stats", h.Admin.Proxy.GetStats)
proxies.GET("/:id/accounts", h.Admin.Proxy.GetProxyAccounts)
proxies.POST("/batch", h.Admin.Proxy.BatchCreate)
}
// 卡密管理
codes := admin.Group("/redeem-codes")
{
codes.GET("", h.Admin.Redeem.List)
codes.GET("/stats", h.Admin.Redeem.GetStats)
codes.GET("/export", h.Admin.Redeem.Export)
codes.GET("/:id", h.Admin.Redeem.GetByID)
codes.POST("/generate", h.Admin.Redeem.Generate)
codes.DELETE("/:id", h.Admin.Redeem.Delete)
codes.POST("/batch-delete", h.Admin.Redeem.BatchDelete)
codes.POST("/:id/expire", h.Admin.Redeem.Expire)
}
// 系统设置
adminSettings := admin.Group("/settings")
{
adminSettings.GET("", h.Admin.Setting.GetSettings)
adminSettings.PUT("", h.Admin.Setting.UpdateSettings)
adminSettings.POST("/test-smtp", h.Admin.Setting.TestSmtpConnection)
adminSettings.POST("/send-test-email", h.Admin.Setting.SendTestEmail)
}
// 系统管理
system := admin.Group("/system")
{
system.GET("/version", h.Admin.System.GetVersion)
system.GET("/check-updates", h.Admin.System.CheckUpdates)
system.POST("/update", h.Admin.System.PerformUpdate)
system.POST("/rollback", h.Admin.System.Rollback)
system.POST("/restart", h.Admin.System.RestartService)
}
// 订阅管理
subscriptions := admin.Group("/subscriptions")
{
subscriptions.GET("", h.Admin.Subscription.List)
subscriptions.GET("/:id", h.Admin.Subscription.GetByID)
subscriptions.GET("/:id/progress", h.Admin.Subscription.GetProgress)
subscriptions.POST("/assign", h.Admin.Subscription.Assign)
subscriptions.POST("/bulk-assign", h.Admin.Subscription.BulkAssign)
subscriptions.POST("/:id/extend", h.Admin.Subscription.Extend)
subscriptions.DELETE("/:id", h.Admin.Subscription.Revoke)
}
// 分组下的订阅列表
admin.GET("/groups/:id/subscriptions", h.Admin.Subscription.ListByGroup)
// 用户下的订阅列表
admin.GET("/users/:id/subscriptions", h.Admin.Subscription.ListByUser)
// 使用记录管理
usage := admin.Group("/usage")
{
usage.GET("", h.Admin.Usage.List)
usage.GET("/stats", h.Admin.Usage.Stats)
usage.GET("/search-users", h.Admin.Usage.SearchUsers)
usage.GET("/search-api-keys", h.Admin.Usage.SearchApiKeys)
}
}
}
// API网关Claude API兼容
gateway := r.Group("/v1")
gateway.Use(middleware.ApiKeyAuthWithSubscription(s.ApiKey, s.Subscription))
{
gateway.POST("/messages", h.Gateway.Messages)
gateway.GET("/models", h.Gateway.Models)
gateway.GET("/usage", h.Gateway.Usage)
}
}

103
backend/cmd/server/wire.go Normal file
View File

@@ -0,0 +1,103 @@
//go:build wireinject
// +build wireinject
package main
import (
"sub2api/internal/config"
"sub2api/internal/handler"
"sub2api/internal/infrastructure"
"sub2api/internal/repository"
"sub2api/internal/server"
"sub2api/internal/service"
"context"
"log"
"net/http"
"time"
"github.com/google/wire"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
type Application struct {
Server *http.Server
Cleanup func()
}
func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
wire.Build(
// 基础设施层 ProviderSets
config.ProviderSet,
infrastructure.ProviderSet,
// 业务层 ProviderSets
repository.ProviderSet,
service.ProviderSet,
handler.ProviderSet,
// 服务器层 ProviderSet
server.ProviderSet,
// 清理函数提供者
provideCleanup,
// 应用程序结构体
wire.Struct(new(Application), "Server", "Cleanup"),
)
return nil, nil
}
func provideCleanup(
db *gorm.DB,
rdb *redis.Client,
services *service.Services,
) func() {
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Cleanup steps in reverse dependency order
cleanupSteps := []struct {
name string
fn func() error
}{
{"PricingService", func() error {
services.Pricing.Stop()
return nil
}},
{"EmailQueueService", func() error {
services.EmailQueue.Stop()
return nil
}},
{"Redis", func() error {
return rdb.Close()
}},
{"Database", func() error {
sqlDB, err := db.DB()
if err != nil {
return err
}
return sqlDB.Close()
}},
}
for _, step := range cleanupSteps {
if err := step.fn(); err != nil {
log.Printf("[Cleanup] %s failed: %v", step.name, err)
// Continue with remaining cleanup steps even if one fails
} else {
log.Printf("[Cleanup] %s succeeded", step.name)
}
}
// Check if context timed out
select {
case <-ctx.Done():
log.Printf("[Cleanup] Warning: cleanup timed out after 10 seconds")
default:
log.Printf("[Cleanup] All cleanup steps completed")
}
}
}

View File

@@ -0,0 +1,201 @@
// Code generated by Wire. DO NOT EDIT.
//go:generate go run -mod=mod github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject
package main
import (
"context"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"log"
"net/http"
"sub2api/internal/config"
"sub2api/internal/handler"
"sub2api/internal/handler/admin"
"sub2api/internal/infrastructure"
"sub2api/internal/repository"
"sub2api/internal/server"
"sub2api/internal/service"
"time"
)
import (
_ "embed"
)
// Injectors from wire.go:
func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
configConfig, err := config.ProvideConfig()
if err != nil {
return nil, err
}
db, err := infrastructure.ProvideDB(configConfig)
if err != nil {
return nil, err
}
userRepository := repository.NewUserRepository(db)
settingRepository := repository.NewSettingRepository(db)
settingService := service.NewSettingService(settingRepository, configConfig)
client := infrastructure.ProvideRedis(configConfig)
emailService := service.NewEmailService(settingRepository, client)
turnstileService := service.NewTurnstileService(settingService)
emailQueueService := service.ProvideEmailQueueService(emailService)
authService := service.NewAuthService(userRepository, configConfig, settingService, emailService, turnstileService, emailQueueService)
authHandler := handler.NewAuthHandler(authService)
userService := service.NewUserService(userRepository, configConfig)
userHandler := handler.NewUserHandler(userService)
apiKeyRepository := repository.NewApiKeyRepository(db)
groupRepository := repository.NewGroupRepository(db)
userSubscriptionRepository := repository.NewUserSubscriptionRepository(db)
apiKeyService := service.NewApiKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, client, configConfig)
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
usageLogRepository := repository.NewUsageLogRepository(db)
usageService := service.NewUsageService(usageLogRepository, userRepository)
usageHandler := handler.NewUsageHandler(usageService, usageLogRepository, apiKeyService)
redeemCodeRepository := repository.NewRedeemCodeRepository(db)
accountRepository := repository.NewAccountRepository(db)
proxyRepository := repository.NewProxyRepository(db)
repositories := &repository.Repositories{
User: userRepository,
ApiKey: apiKeyRepository,
Group: groupRepository,
Account: accountRepository,
Proxy: proxyRepository,
RedeemCode: redeemCodeRepository,
UsageLog: usageLogRepository,
Setting: settingRepository,
UserSubscription: userSubscriptionRepository,
}
billingCacheService := service.NewBillingCacheService(client, userRepository, userSubscriptionRepository)
subscriptionService := service.NewSubscriptionService(repositories, billingCacheService)
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, client, billingCacheService)
redeemHandler := handler.NewRedeemHandler(redeemService)
subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService)
adminService := service.NewAdminService(repositories, billingCacheService)
dashboardHandler := admin.NewDashboardHandler(adminService, usageLogRepository)
adminUserHandler := admin.NewUserHandler(adminService)
groupHandler := admin.NewGroupHandler(adminService)
oAuthService := service.NewOAuthService(proxyRepository)
rateLimitService := service.NewRateLimitService(repositories, configConfig)
accountUsageService := service.NewAccountUsageService(repositories, oAuthService)
accountTestService := service.NewAccountTestService(repositories, oAuthService)
accountHandler := admin.NewAccountHandler(adminService, oAuthService, rateLimitService, accountUsageService, accountTestService)
oAuthHandler := admin.NewOAuthHandler(oAuthService, adminService)
proxyHandler := admin.NewProxyHandler(adminService)
adminRedeemHandler := admin.NewRedeemHandler(adminService)
settingHandler := admin.NewSettingHandler(settingService, emailService)
systemHandler := handler.ProvideSystemHandler(client, buildInfo)
adminSubscriptionHandler := admin.NewSubscriptionHandler(subscriptionService)
adminUsageHandler := admin.NewUsageHandler(usageLogRepository, apiKeyRepository, usageService, adminService)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, proxyHandler, adminRedeemHandler, settingHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler)
pricingService, err := service.ProvidePricingService(configConfig)
if err != nil {
return nil, err
}
billingService := service.NewBillingService(configConfig, pricingService)
identityService := service.NewIdentityService(client)
gatewayService := service.NewGatewayService(repositories, client, configConfig, oAuthService, billingService, rateLimitService, billingCacheService, identityService)
concurrencyService := service.NewConcurrencyService(client)
gatewayHandler := handler.NewGatewayHandler(gatewayService, userService, concurrencyService, billingCacheService)
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo)
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, adminHandlers, gatewayHandler, handlerSettingHandler)
groupService := service.NewGroupService(groupRepository)
accountService := service.NewAccountService(accountRepository, groupRepository)
proxyService := service.NewProxyService(proxyRepository)
services := &service.Services{
Auth: authService,
User: userService,
ApiKey: apiKeyService,
Group: groupService,
Account: accountService,
Proxy: proxyService,
Redeem: redeemService,
Usage: usageService,
Pricing: pricingService,
Billing: billingService,
BillingCache: billingCacheService,
Admin: adminService,
Gateway: gatewayService,
OAuth: oAuthService,
RateLimit: rateLimitService,
AccountUsage: accountUsageService,
AccountTest: accountTestService,
Setting: settingService,
Email: emailService,
EmailQueue: emailQueueService,
Turnstile: turnstileService,
Subscription: subscriptionService,
Concurrency: concurrencyService,
Identity: identityService,
}
engine := server.ProvideRouter(configConfig, handlers, services, repositories)
httpServer := server.ProvideHTTPServer(configConfig, engine)
v := provideCleanup(db, client, services)
application := &Application{
Server: httpServer,
Cleanup: v,
}
return application, nil
}
// wire.go:
type Application struct {
Server *http.Server
Cleanup func()
}
func provideCleanup(
db *gorm.DB,
rdb *redis.Client,
services *service.Services,
) func() {
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cleanupSteps := []struct {
name string
fn func() error
}{
{"PricingService", func() error {
services.Pricing.Stop()
return nil
}},
{"EmailQueueService", func() error {
services.EmailQueue.Stop()
return nil
}},
{"Redis", func() error {
return rdb.Close()
}},
{"Database", func() error {
sqlDB, err := db.DB()
if err != nil {
return err
}
return sqlDB.Close()
}},
}
for _, step := range cleanupSteps {
if err := step.fn(); err != nil {
log.Printf("[Cleanup] %s failed: %v", step.name, err)
} else {
log.Printf("[Cleanup] %s succeeded", step.name)
}
}
select {
case <-ctx.Done():
log.Printf("[Cleanup] Warning: cleanup timed out after 10 seconds")
default:
log.Printf("[Cleanup] All cleanup steps completed")
}
}
}

View File

@@ -13,6 +13,7 @@ require (
github.com/redis/go-redis/v9 v9.3.0 github.com/redis/go-redis/v9 v9.3.0
github.com/spf13/viper v1.18.2 github.com/spf13/viper v1.18.2
golang.org/x/crypto v0.44.0 golang.org/x/crypto v0.44.0
golang.org/x/net v0.47.0
golang.org/x/term v0.37.0 golang.org/x/term v0.37.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/postgres v1.5.4 gorm.io/driver/postgres v1.5.4
@@ -33,6 +34,8 @@ require (
github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-querystring v1.1.0 // indirect
github.com/google/subcommands v1.2.0 // indirect
github.com/google/wire v0.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/icholy/digest v1.1.0 // indirect github.com/icholy/digest v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -50,6 +53,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.56.0 // indirect github.com/quic-go/quic-go v0.56.0 // indirect
github.com/refraction-networking/utls v1.8.1 // indirect github.com/refraction-networking/utls v1.8.1 // indirect
@@ -66,9 +70,11 @@ require (
go.uber.org/multierr v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.3.0 // indirect golang.org/x/arch v0.3.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.47.0 // indirect golang.org/x/mod v0.29.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect golang.org/x/text v0.31.0 // indirect
golang.org/x/tools v0.38.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
) )

View File

@@ -48,8 +48,12 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 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/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 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/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
@@ -154,8 +158,12 @@ golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
@@ -166,6 +174,8 @@ golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=

View File

@@ -0,0 +1,13 @@
package config
import "github.com/google/wire"
// ProviderSet 提供配置层的依赖
var ProviderSet = wire.NewSet(
ProvideConfig,
)
// ProvideConfig 提供应用配置
func ProvideConfig() (*Config, error) {
return Load()
}

View File

@@ -2,8 +2,10 @@ package admin
import ( import (
"net/http" "net/http"
"time"
"sub2api/internal/pkg/response" "sub2api/internal/pkg/response"
"sub2api/internal/pkg/sysutil"
"sub2api/internal/service" "sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -72,10 +74,14 @@ func (h *SystemHandler) Rollback(c *gin.Context) {
// RestartService restarts the systemd service // RestartService restarts the systemd service
// POST /api/v1/admin/system/restart // POST /api/v1/admin/system/restart
func (h *SystemHandler) RestartService(c *gin.Context) { func (h *SystemHandler) RestartService(c *gin.Context) {
if err := h.updateSvc.RestartService(); err != nil { // Schedule service restart in background after sending response
response.Error(c, http.StatusInternalServerError, err.Error()) // This ensures the client receives the success response before the service restarts
return go func() {
} // Wait a moment to ensure the response is sent
time.Sleep(500 * time.Millisecond)
sysutil.RestartServiceAsync()
}()
response.Success(c, gin.H{ response.Success(c, gin.H{
"message": "Service restart initiated", "message": "Service restart initiated",
}) })

View File

@@ -2,10 +2,6 @@ package handler
import ( import (
"sub2api/internal/handler/admin" "sub2api/internal/handler/admin"
"sub2api/internal/repository"
"sub2api/internal/service"
"github.com/redis/go-redis/v9"
) )
// AdminHandlers contains all admin-related HTTP handlers // AdminHandlers contains all admin-related HTTP handlers
@@ -41,30 +37,3 @@ type BuildInfo struct {
Version string Version string
BuildType string // "source" for manual builds, "release" for CI builds BuildType string // "source" for manual builds, "release" for CI builds
} }
// NewHandlers creates a new Handlers instance with all handlers initialized
func NewHandlers(services *service.Services, repos *repository.Repositories, rdb *redis.Client, buildInfo BuildInfo) *Handlers {
return &Handlers{
Auth: NewAuthHandler(services.Auth),
User: NewUserHandler(services.User),
APIKey: NewAPIKeyHandler(services.ApiKey),
Usage: NewUsageHandler(services.Usage, repos.UsageLog, services.ApiKey),
Redeem: NewRedeemHandler(services.Redeem),
Subscription: NewSubscriptionHandler(services.Subscription),
Admin: &AdminHandlers{
Dashboard: admin.NewDashboardHandler(services.Admin, repos.UsageLog),
User: admin.NewUserHandler(services.Admin),
Group: admin.NewGroupHandler(services.Admin),
Account: admin.NewAccountHandler(services.Admin, services.OAuth, services.RateLimit, services.AccountUsage, services.AccountTest),
OAuth: admin.NewOAuthHandler(services.OAuth, services.Admin),
Proxy: admin.NewProxyHandler(services.Admin),
Redeem: admin.NewRedeemHandler(services.Admin),
Setting: admin.NewSettingHandler(services.Setting, services.Email),
System: admin.NewSystemHandler(rdb, buildInfo.Version, buildInfo.BuildType),
Subscription: admin.NewSubscriptionHandler(services.Subscription),
Usage: admin.NewUsageHandler(repos.UsageLog, repos.ApiKey, services.Usage, services.Admin),
},
Gateway: NewGatewayHandler(services.Gateway, services.User, services.Concurrency, services.BillingCache),
Setting: NewSettingHandler(services.Setting, buildInfo.Version),
}
}

View File

@@ -0,0 +1,103 @@
package handler
import (
"sub2api/internal/handler/admin"
"sub2api/internal/service"
"github.com/google/wire"
"github.com/redis/go-redis/v9"
)
// ProvideAdminHandlers creates the AdminHandlers struct
func ProvideAdminHandlers(
dashboardHandler *admin.DashboardHandler,
userHandler *admin.UserHandler,
groupHandler *admin.GroupHandler,
accountHandler *admin.AccountHandler,
oauthHandler *admin.OAuthHandler,
proxyHandler *admin.ProxyHandler,
redeemHandler *admin.RedeemHandler,
settingHandler *admin.SettingHandler,
systemHandler *admin.SystemHandler,
subscriptionHandler *admin.SubscriptionHandler,
usageHandler *admin.UsageHandler,
) *AdminHandlers {
return &AdminHandlers{
Dashboard: dashboardHandler,
User: userHandler,
Group: groupHandler,
Account: accountHandler,
OAuth: oauthHandler,
Proxy: proxyHandler,
Redeem: redeemHandler,
Setting: settingHandler,
System: systemHandler,
Subscription: subscriptionHandler,
Usage: usageHandler,
}
}
// ProvideSystemHandler creates admin.SystemHandler with BuildInfo parameters
func ProvideSystemHandler(rdb *redis.Client, buildInfo BuildInfo) *admin.SystemHandler {
return admin.NewSystemHandler(rdb, buildInfo.Version, buildInfo.BuildType)
}
// ProvideSettingHandler creates SettingHandler with version from BuildInfo
func ProvideSettingHandler(settingService *service.SettingService, buildInfo BuildInfo) *SettingHandler {
return NewSettingHandler(settingService, buildInfo.Version)
}
// ProvideHandlers creates the Handlers struct
func ProvideHandlers(
authHandler *AuthHandler,
userHandler *UserHandler,
apiKeyHandler *APIKeyHandler,
usageHandler *UsageHandler,
redeemHandler *RedeemHandler,
subscriptionHandler *SubscriptionHandler,
adminHandlers *AdminHandlers,
gatewayHandler *GatewayHandler,
settingHandler *SettingHandler,
) *Handlers {
return &Handlers{
Auth: authHandler,
User: userHandler,
APIKey: apiKeyHandler,
Usage: usageHandler,
Redeem: redeemHandler,
Subscription: subscriptionHandler,
Admin: adminHandlers,
Gateway: gatewayHandler,
Setting: settingHandler,
}
}
// ProviderSet is the Wire provider set for all handlers
var ProviderSet = wire.NewSet(
// Top-level handlers
NewAuthHandler,
NewUserHandler,
NewAPIKeyHandler,
NewUsageHandler,
NewRedeemHandler,
NewSubscriptionHandler,
NewGatewayHandler,
ProvideSettingHandler,
// Admin handlers
admin.NewDashboardHandler,
admin.NewUserHandler,
admin.NewGroupHandler,
admin.NewAccountHandler,
admin.NewOAuthHandler,
admin.NewProxyHandler,
admin.NewRedeemHandler,
admin.NewSettingHandler,
ProvideSystemHandler,
admin.NewSubscriptionHandler,
admin.NewUsageHandler,
// AdminHandlers and Handlers constructors
ProvideAdminHandlers,
ProvideHandlers,
)

View File

@@ -0,0 +1,38 @@
package infrastructure
import (
"sub2api/internal/config"
"sub2api/internal/model"
"sub2api/internal/pkg/timezone"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// InitDB 初始化数据库连接
func InitDB(cfg *config.Config) (*gorm.DB, error) {
// 初始化时区(在数据库连接之前,确保时区设置正确)
if err := timezone.Init(cfg.Timezone); err != nil {
return nil, err
}
gormConfig := &gorm.Config{}
if cfg.Server.Mode == "debug" {
gormConfig.Logger = logger.Default.LogMode(logger.Info)
}
// 使用带时区的 DSN 连接数据库
db, err := gorm.Open(postgres.Open(cfg.Database.DSNWithTimezone(cfg.Timezone)), gormConfig)
if err != nil {
return nil, err
}
// 自动迁移(始终执行,确保数据库结构与代码同步)
// GORM 的 AutoMigrate 只会添加新字段,不会删除或修改已有字段,是安全的
if err := model.AutoMigrate(db); err != nil {
return nil, err
}
return db, nil
}

View File

@@ -0,0 +1,16 @@
package infrastructure
import (
"sub2api/internal/config"
"github.com/redis/go-redis/v9"
)
// InitRedis 初始化 Redis 客户端
func InitRedis(cfg *config.Config) *redis.Client {
return redis.NewClient(&redis.Options{
Addr: cfg.Redis.Address(),
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
})
}

View File

@@ -0,0 +1,25 @@
package infrastructure
import (
"sub2api/internal/config"
"github.com/google/wire"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// ProviderSet 提供基础设施层的依赖
var ProviderSet = wire.NewSet(
ProvideDB,
ProvideRedis,
)
// ProvideDB 提供数据库连接
func ProvideDB(cfg *config.Config) (*gorm.DB, error) {
return InitDB(cfg)
}
// ProvideRedis 提供 Redis 客户端
func ProvideRedis(cfg *config.Config) *redis.Client {
return InitRedis(cfg)
}

View File

@@ -0,0 +1,47 @@
package sysutil
import (
"log"
"os"
"runtime"
"time"
)
// RestartService triggers a service restart by gracefully exiting.
//
// This relies on systemd's Restart=always configuration to automatically
// restart the service after it exits. This is the industry-standard approach:
// - Simple and reliable
// - No sudo permissions needed
// - No complex process management
// - Leverages systemd's native restart capability
//
// Prerequisites:
// - Linux OS with systemd
// - Service configured with Restart=always in systemd unit file
func RestartService() error {
if runtime.GOOS != "linux" {
log.Println("Service restart via exit only works on Linux with systemd")
return nil
}
log.Println("Initiating service restart by graceful exit...")
log.Println("systemd will automatically restart the service (Restart=always)")
// Give a moment for logs to flush and response to be sent
go func() {
time.Sleep(100 * time.Millisecond)
os.Exit(0)
}()
return nil
}
// RestartServiceAsync is a fire-and-forget version of RestartService.
// It logs errors instead of returning them, suitable for goroutine usage.
func RestartServiceAsync() {
if err := RestartService(); err != nil {
log.Printf("Service restart failed: %v", err)
log.Println("Please restart the service manually: sudo systemctl restart sub2api")
}
}

View File

@@ -1,9 +1,5 @@
package repository package repository
import (
"gorm.io/gorm"
)
// Repositories 所有仓库的集合 // Repositories 所有仓库的集合
type Repositories struct { type Repositories struct {
User *UserRepository User *UserRepository
@@ -17,21 +13,6 @@ type Repositories struct {
UserSubscription *UserSubscriptionRepository UserSubscription *UserSubscriptionRepository
} }
// NewRepositories 创建所有仓库
func NewRepositories(db *gorm.DB) *Repositories {
return &Repositories{
User: NewUserRepository(db),
ApiKey: NewApiKeyRepository(db),
Group: NewGroupRepository(db),
Account: NewAccountRepository(db),
Proxy: NewProxyRepository(db),
RedeemCode: NewRedeemCodeRepository(db),
UsageLog: NewUsageLogRepository(db),
Setting: NewSettingRepository(db),
UserSubscription: NewUserSubscriptionRepository(db),
}
}
// PaginationParams 分页参数 // PaginationParams 分页参数
type PaginationParams struct { type PaginationParams struct {
Page int Page int

View File

@@ -0,0 +1,19 @@
package repository
import (
"github.com/google/wire"
)
// ProviderSet is the Wire provider set for all repositories
var ProviderSet = wire.NewSet(
NewUserRepository,
NewApiKeyRepository,
NewGroupRepository,
NewAccountRepository,
NewProxyRepository,
NewRedeemCodeRepository,
NewUsageLogRepository,
NewSettingRepository,
NewUserSubscriptionRepository,
wire.Struct(new(Repositories), "*"),
)

View File

@@ -0,0 +1,45 @@
package server
import (
"net/http"
"sub2api/internal/config"
"sub2api/internal/handler"
"sub2api/internal/repository"
"sub2api/internal/service"
"time"
"github.com/gin-gonic/gin"
"github.com/google/wire"
)
// ProviderSet 提供服务器层的依赖
var ProviderSet = wire.NewSet(
ProvideRouter,
ProvideHTTPServer,
)
// ProvideRouter 提供路由器
func ProvideRouter(cfg *config.Config, handlers *handler.Handlers, services *service.Services, repos *repository.Repositories) *gin.Engine {
if cfg.Server.Mode == "release" {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
r.Use(gin.Recovery())
return SetupRouter(r, cfg, handlers, services, repos)
}
// ProvideHTTPServer 提供 HTTP 服务器
func ProvideHTTPServer(cfg *config.Config, router *gin.Engine) *http.Server {
return &http.Server{
Addr: cfg.Server.Address(),
Handler: router,
// ReadHeaderTimeout: 读取请求头的超时时间,防止慢速请求头攻击
ReadHeaderTimeout: time.Duration(cfg.Server.ReadHeaderTimeout) * time.Second,
// IdleTimeout: 空闲连接超时时间,释放不活跃的连接资源
IdleTimeout: time.Duration(cfg.Server.IdleTimeout) * time.Second,
// 注意:不设置 WriteTimeout因为流式响应可能持续十几分钟
// 不设置 ReadTimeout因为大请求体可能需要较长时间读取
}
}

View File

@@ -0,0 +1,287 @@
package server
import (
"net/http"
"sub2api/internal/config"
"sub2api/internal/handler"
"sub2api/internal/middleware"
"sub2api/internal/repository"
"sub2api/internal/service"
"sub2api/internal/web"
"github.com/gin-gonic/gin"
)
// SetupRouter 配置路由器中间件和路由
func SetupRouter(r *gin.Engine, cfg *config.Config, handlers *handler.Handlers, services *service.Services, repos *repository.Repositories) *gin.Engine {
// 应用中间件
r.Use(middleware.Logger())
r.Use(middleware.CORS())
// 注册路由
registerRoutes(r, handlers, services, repos)
// Serve embedded frontend if available
if web.HasEmbeddedFrontend() {
r.Use(web.ServeEmbeddedFrontend())
}
return r
}
// registerRoutes 注册所有 HTTP 路由
func registerRoutes(r *gin.Engine, h *handler.Handlers, s *service.Services, repos *repository.Repositories) {
// 健康检查
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
// Claude Code 遥测日志忽略直接返回200
r.POST("/api/event_logging/batch", func(c *gin.Context) {
c.Status(http.StatusOK)
})
// Setup status endpoint (always returns needs_setup: false in normal mode)
// This is used by the frontend to detect when the service has restarted after setup
r.GET("/setup/status", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"data": gin.H{
"needs_setup": false,
"step": "completed",
},
})
})
// API v1
v1 := r.Group("/api/v1")
{
// 公开接口
auth := v1.Group("/auth")
{
auth.POST("/register", h.Auth.Register)
auth.POST("/login", h.Auth.Login)
auth.POST("/send-verify-code", h.Auth.SendVerifyCode)
}
// 公开设置(无需认证)
settings := v1.Group("/settings")
{
settings.GET("/public", h.Setting.GetPublicSettings)
}
// 需要认证的接口
authenticated := v1.Group("")
authenticated.Use(middleware.JWTAuth(s.Auth, repos.User))
{
// 当前用户信息
authenticated.GET("/auth/me", h.Auth.GetCurrentUser)
// 用户接口
user := authenticated.Group("/user")
{
user.GET("/profile", h.User.GetProfile)
user.PUT("/password", h.User.ChangePassword)
}
// API Key管理
keys := authenticated.Group("/keys")
{
keys.GET("", h.APIKey.List)
keys.GET("/:id", h.APIKey.GetByID)
keys.POST("", h.APIKey.Create)
keys.PUT("/:id", h.APIKey.Update)
keys.DELETE("/:id", h.APIKey.Delete)
}
// 用户可用分组(非管理员接口)
groups := authenticated.Group("/groups")
{
groups.GET("/available", h.APIKey.GetAvailableGroups)
}
// 使用记录
usage := authenticated.Group("/usage")
{
usage.GET("", h.Usage.List)
usage.GET("/:id", h.Usage.GetByID)
usage.GET("/stats", h.Usage.Stats)
// User dashboard endpoints
usage.GET("/dashboard/stats", h.Usage.DashboardStats)
usage.GET("/dashboard/trend", h.Usage.DashboardTrend)
usage.GET("/dashboard/models", h.Usage.DashboardModels)
usage.POST("/dashboard/api-keys-usage", h.Usage.DashboardApiKeysUsage)
}
// 卡密兑换
redeem := authenticated.Group("/redeem")
{
redeem.POST("", h.Redeem.Redeem)
redeem.GET("/history", h.Redeem.GetHistory)
}
// 用户订阅
subscriptions := authenticated.Group("/subscriptions")
{
subscriptions.GET("", h.Subscription.List)
subscriptions.GET("/active", h.Subscription.GetActive)
subscriptions.GET("/progress", h.Subscription.GetProgress)
subscriptions.GET("/summary", h.Subscription.GetSummary)
}
}
// 管理员接口
admin := v1.Group("/admin")
admin.Use(middleware.JWTAuth(s.Auth, repos.User), middleware.AdminOnly())
{
// 仪表盘
dashboard := admin.Group("/dashboard")
{
dashboard.GET("/stats", h.Admin.Dashboard.GetStats)
dashboard.GET("/realtime", h.Admin.Dashboard.GetRealtimeMetrics)
dashboard.GET("/trend", h.Admin.Dashboard.GetUsageTrend)
dashboard.GET("/models", h.Admin.Dashboard.GetModelStats)
dashboard.GET("/api-keys-trend", h.Admin.Dashboard.GetApiKeyUsageTrend)
dashboard.GET("/users-trend", h.Admin.Dashboard.GetUserUsageTrend)
dashboard.POST("/users-usage", h.Admin.Dashboard.GetBatchUsersUsage)
dashboard.POST("/api-keys-usage", h.Admin.Dashboard.GetBatchApiKeysUsage)
}
// 用户管理
users := admin.Group("/users")
{
users.GET("", h.Admin.User.List)
users.GET("/:id", h.Admin.User.GetByID)
users.POST("", h.Admin.User.Create)
users.PUT("/:id", h.Admin.User.Update)
users.DELETE("/:id", h.Admin.User.Delete)
users.POST("/:id/balance", h.Admin.User.UpdateBalance)
users.GET("/:id/api-keys", h.Admin.User.GetUserAPIKeys)
users.GET("/:id/usage", h.Admin.User.GetUserUsage)
}
// 分组管理
groups := admin.Group("/groups")
{
groups.GET("", h.Admin.Group.List)
groups.GET("/all", h.Admin.Group.GetAll)
groups.GET("/:id", h.Admin.Group.GetByID)
groups.POST("", h.Admin.Group.Create)
groups.PUT("/:id", h.Admin.Group.Update)
groups.DELETE("/:id", h.Admin.Group.Delete)
groups.GET("/:id/stats", h.Admin.Group.GetStats)
groups.GET("/:id/api-keys", h.Admin.Group.GetGroupAPIKeys)
}
// 账号管理
accounts := admin.Group("/accounts")
{
accounts.GET("", h.Admin.Account.List)
accounts.GET("/:id", h.Admin.Account.GetByID)
accounts.POST("", h.Admin.Account.Create)
accounts.PUT("/:id", h.Admin.Account.Update)
accounts.DELETE("/:id", h.Admin.Account.Delete)
accounts.POST("/:id/test", h.Admin.Account.Test)
accounts.POST("/:id/refresh", h.Admin.Account.Refresh)
accounts.GET("/:id/stats", h.Admin.Account.GetStats)
accounts.POST("/:id/clear-error", h.Admin.Account.ClearError)
accounts.GET("/:id/usage", h.Admin.Account.GetUsage)
accounts.GET("/:id/today-stats", h.Admin.Account.GetTodayStats)
accounts.POST("/:id/clear-rate-limit", h.Admin.Account.ClearRateLimit)
accounts.POST("/:id/schedulable", h.Admin.Account.SetSchedulable)
accounts.POST("/batch", h.Admin.Account.BatchCreate)
// OAuth routes
accounts.POST("/generate-auth-url", h.Admin.OAuth.GenerateAuthURL)
accounts.POST("/generate-setup-token-url", h.Admin.OAuth.GenerateSetupTokenURL)
accounts.POST("/exchange-code", h.Admin.OAuth.ExchangeCode)
accounts.POST("/exchange-setup-token-code", h.Admin.OAuth.ExchangeSetupTokenCode)
accounts.POST("/cookie-auth", h.Admin.OAuth.CookieAuth)
accounts.POST("/setup-token-cookie-auth", h.Admin.OAuth.SetupTokenCookieAuth)
}
// 代理管理
proxies := admin.Group("/proxies")
{
proxies.GET("", h.Admin.Proxy.List)
proxies.GET("/all", h.Admin.Proxy.GetAll)
proxies.GET("/:id", h.Admin.Proxy.GetByID)
proxies.POST("", h.Admin.Proxy.Create)
proxies.PUT("/:id", h.Admin.Proxy.Update)
proxies.DELETE("/:id", h.Admin.Proxy.Delete)
proxies.POST("/:id/test", h.Admin.Proxy.Test)
proxies.GET("/:id/stats", h.Admin.Proxy.GetStats)
proxies.GET("/:id/accounts", h.Admin.Proxy.GetProxyAccounts)
proxies.POST("/batch", h.Admin.Proxy.BatchCreate)
}
// 卡密管理
codes := admin.Group("/redeem-codes")
{
codes.GET("", h.Admin.Redeem.List)
codes.GET("/stats", h.Admin.Redeem.GetStats)
codes.GET("/export", h.Admin.Redeem.Export)
codes.GET("/:id", h.Admin.Redeem.GetByID)
codes.POST("/generate", h.Admin.Redeem.Generate)
codes.DELETE("/:id", h.Admin.Redeem.Delete)
codes.POST("/batch-delete", h.Admin.Redeem.BatchDelete)
codes.POST("/:id/expire", h.Admin.Redeem.Expire)
}
// 系统设置
adminSettings := admin.Group("/settings")
{
adminSettings.GET("", h.Admin.Setting.GetSettings)
adminSettings.PUT("", h.Admin.Setting.UpdateSettings)
adminSettings.POST("/test-smtp", h.Admin.Setting.TestSmtpConnection)
adminSettings.POST("/send-test-email", h.Admin.Setting.SendTestEmail)
}
// 系统管理
system := admin.Group("/system")
{
system.GET("/version", h.Admin.System.GetVersion)
system.GET("/check-updates", h.Admin.System.CheckUpdates)
system.POST("/update", h.Admin.System.PerformUpdate)
system.POST("/rollback", h.Admin.System.Rollback)
system.POST("/restart", h.Admin.System.RestartService)
}
// 订阅管理
subscriptions := admin.Group("/subscriptions")
{
subscriptions.GET("", h.Admin.Subscription.List)
subscriptions.GET("/:id", h.Admin.Subscription.GetByID)
subscriptions.GET("/:id/progress", h.Admin.Subscription.GetProgress)
subscriptions.POST("/assign", h.Admin.Subscription.Assign)
subscriptions.POST("/bulk-assign", h.Admin.Subscription.BulkAssign)
subscriptions.POST("/:id/extend", h.Admin.Subscription.Extend)
subscriptions.DELETE("/:id", h.Admin.Subscription.Revoke)
}
// 分组下的订阅列表
admin.GET("/groups/:id/subscriptions", h.Admin.Subscription.ListByGroup)
// 用户下的订阅列表
admin.GET("/users/:id/subscriptions", h.Admin.Subscription.ListByUser)
// 使用记录管理
usage := admin.Group("/usage")
{
usage.GET("", h.Admin.Usage.List)
usage.GET("/stats", h.Admin.Usage.Stats)
usage.GET("/search-users", h.Admin.Usage.SearchUsers)
usage.GET("/search-api-keys", h.Admin.Usage.SearchApiKeys)
}
}
}
// API网关Claude API兼容
gateway := r.Group("/v1")
gateway.Use(middleware.ApiKeyAuthWithSubscription(s.ApiKey, s.Subscription))
{
gateway.POST("/messages", h.Gateway.Messages)
gateway.GET("/models", h.Gateway.Models)
gateway.GET("/usage", h.Gateway.Usage)
}
}

View File

@@ -92,9 +92,9 @@ func createTestPayload() map[string]interface{} {
"metadata": map[string]string{ "metadata": map[string]string{
"user_id": generateSessionString(), "user_id": generateSessionString(),
}, },
"max_tokens": 1024, "max_tokens": 1024,
"temperature": 1, "temperature": 1,
"stream": true, "stream": true,
} }
} }
@@ -310,5 +310,5 @@ func (s *AccountTestService) sendEvent(c *gin.Context, event TestEvent) {
func (s *AccountTestService) sendErrorAndEnd(c *gin.Context, errorMsg string) error { func (s *AccountTestService) sendErrorAndEnd(c *gin.Context, errorMsg string) error {
log.Printf("Account test error: %s", errorMsg) log.Printf("Account test error: %s", errorMsg)
s.sendEvent(c, TestEvent{Type: "error", Error: errorMsg}) s.sendEvent(c, TestEvent{Type: "error", Error: errorMsg})
return fmt.Errorf(errorMsg) return fmt.Errorf("%s", errorMsg)
} }

View File

@@ -191,24 +191,17 @@ type adminServiceImpl struct {
} }
// NewAdminService creates a new AdminService // NewAdminService creates a new AdminService
func NewAdminService(repos *repository.Repositories) AdminService { func NewAdminService(repos *repository.Repositories, billingCacheService *BillingCacheService) AdminService {
return &adminServiceImpl{ return &adminServiceImpl{
userRepo: repos.User, userRepo: repos.User,
groupRepo: repos.Group, groupRepo: repos.Group,
accountRepo: repos.Account, accountRepo: repos.Account,
proxyRepo: repos.Proxy, proxyRepo: repos.Proxy,
apiKeyRepo: repos.ApiKey, apiKeyRepo: repos.ApiKey,
redeemCodeRepo: repos.RedeemCode, redeemCodeRepo: repos.RedeemCode,
usageLogRepo: repos.UsageLog, usageLogRepo: repos.UsageLog,
userSubRepo: repos.UserSubscription, userSubRepo: repos.UserSubscription,
} billingCacheService: billingCacheService,
}
// SetBillingCacheService 设置计费缓存服务(用于缓存失效)
// 注意AdminService是接口需要类型断言
func SetAdminServiceBillingCache(adminService AdminService, billingCacheService *BillingCacheService) {
if impl, ok := adminService.(*adminServiceImpl); ok {
impl.billingCacheService = billingCacheService
} }
} }

View File

@@ -16,13 +16,13 @@ import (
) )
var ( var (
ErrInvalidCredentials = errors.New("invalid email or password") ErrInvalidCredentials = errors.New("invalid email or password")
ErrUserNotActive = errors.New("user is not active") ErrUserNotActive = errors.New("user is not active")
ErrEmailExists = errors.New("email already exists") ErrEmailExists = errors.New("email already exists")
ErrInvalidToken = errors.New("invalid token") ErrInvalidToken = errors.New("invalid token")
ErrTokenExpired = errors.New("token has expired") ErrTokenExpired = errors.New("token has expired")
ErrEmailVerifyRequired = errors.New("email verification is required") ErrEmailVerifyRequired = errors.New("email verification is required")
ErrRegDisabled = errors.New("registration is currently disabled") ErrRegDisabled = errors.New("registration is currently disabled")
) )
// JWTClaims JWT载荷数据 // JWTClaims JWT载荷数据
@@ -44,33 +44,24 @@ type AuthService struct {
} }
// NewAuthService 创建认证服务实例 // NewAuthService 创建认证服务实例
func NewAuthService(userRepo *repository.UserRepository, cfg *config.Config) *AuthService { func NewAuthService(
userRepo *repository.UserRepository,
cfg *config.Config,
settingService *SettingService,
emailService *EmailService,
turnstileService *TurnstileService,
emailQueueService *EmailQueueService,
) *AuthService {
return &AuthService{ return &AuthService{
userRepo: userRepo, userRepo: userRepo,
cfg: cfg, cfg: cfg,
settingService: settingService,
emailService: emailService,
turnstileService: turnstileService,
emailQueueService: emailQueueService,
} }
} }
// SetSettingService 设置系统设置服务(用于检查注册开关和邮件验证)
func (s *AuthService) SetSettingService(settingService *SettingService) {
s.settingService = settingService
}
// SetEmailService 设置邮件服务(用于邮件验证)
func (s *AuthService) SetEmailService(emailService *EmailService) {
s.emailService = emailService
}
// SetTurnstileService 设置Turnstile服务用于验证码校验
func (s *AuthService) SetTurnstileService(turnstileService *TurnstileService) {
s.turnstileService = turnstileService
}
// SetEmailQueueService 设置邮件队列服务(用于异步发送邮件)
func (s *AuthService) SetEmailQueueService(emailQueueService *EmailQueueService) {
s.emailQueueService = emailQueueService
}
// Register 用户注册返回token和用户 // Register 用户注册返回token和用户
func (s *AuthService) Register(ctx context.Context, email, password string) (string, *model.User, error) { func (s *AuthService) Register(ctx context.Context, email, password string) (string, *model.User, error) {
return s.RegisterWithVerification(ctx, email, password, "") return s.RegisterWithVerification(ctx, email, password, "")

View File

@@ -35,25 +35,25 @@ const (
// allowedHeaders 白名单headers参考CRS项目 // allowedHeaders 白名单headers参考CRS项目
var allowedHeaders = map[string]bool{ var allowedHeaders = map[string]bool{
"accept": true, "accept": true,
"x-stainless-retry-count": true, "x-stainless-retry-count": true,
"x-stainless-timeout": true, "x-stainless-timeout": true,
"x-stainless-lang": true, "x-stainless-lang": true,
"x-stainless-package-version": true, "x-stainless-package-version": true,
"x-stainless-os": true, "x-stainless-os": true,
"x-stainless-arch": true, "x-stainless-arch": true,
"x-stainless-runtime": true, "x-stainless-runtime": true,
"x-stainless-runtime-version": true, "x-stainless-runtime-version": true,
"x-stainless-helper-method": true, "x-stainless-helper-method": true,
"anthropic-dangerous-direct-browser-access": true, "anthropic-dangerous-direct-browser-access": true,
"anthropic-version": true, "anthropic-version": true,
"x-app": true, "x-app": true,
"anthropic-beta": true, "anthropic-beta": true,
"accept-language": true, "accept-language": true,
"sec-fetch-mode": true, "sec-fetch-mode": true,
"accept-encoding": true, "accept-encoding": true,
"user-agent": true, "user-agent": true,
"content-type": true, "content-type": true,
} }
// ClaudeUsage 表示Claude API返回的usage信息 // ClaudeUsage 表示Claude API返回的usage信息
@@ -418,13 +418,19 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *m
} }
// 构建上游请求 // 构建上游请求
upstreamReq, err := s.buildUpstreamRequest(ctx, c, account, body, token, tokenType) upstreamResult, err := s.buildUpstreamRequest(ctx, c, account, body, token, tokenType)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 选择使用的client如果有代理则使用独立的client否则使用共享的httpClient
httpClient := s.httpClient
if upstreamResult.Client != nil {
httpClient = upstreamResult.Client
}
// 发送请求 // 发送请求
resp, err := s.httpClient.Do(upstreamReq) resp, err := httpClient.Do(upstreamResult.Request)
if err != nil { if err != nil {
return nil, fmt.Errorf("upstream request failed: %w", err) return nil, fmt.Errorf("upstream request failed: %w", err)
} }
@@ -437,11 +443,16 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *m
if err != nil { if err != nil {
return nil, fmt.Errorf("token refresh failed: %w", err) return nil, fmt.Errorf("token refresh failed: %w", err)
} }
upstreamReq, err = s.buildUpstreamRequest(ctx, c, account, body, token, tokenType) upstreamResult, err = s.buildUpstreamRequest(ctx, c, account, body, token, tokenType)
if err != nil { if err != nil {
return nil, err return nil, err
} }
resp, err = s.httpClient.Do(upstreamReq) // 重试时也需要使用正确的client
httpClient = s.httpClient
if upstreamResult.Client != nil {
httpClient = upstreamResult.Client
}
resp, err = httpClient.Do(upstreamResult.Request)
if err != nil { if err != nil {
return nil, fmt.Errorf("retry request failed: %w", err) return nil, fmt.Errorf("retry request failed: %w", err)
} }
@@ -480,7 +491,13 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *m
}, nil }, nil
} }
func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *model.Account, body []byte, token, tokenType string) (*http.Request, error) { // buildUpstreamRequestResult contains the request and optional custom client for proxy
type buildUpstreamRequestResult struct {
Request *http.Request
Client *http.Client // nil means use default s.httpClient
}
func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *model.Account, body []byte, token, tokenType string) (*buildUpstreamRequestResult, error) {
// 确定目标URL // 确定目标URL
targetURL := claudeAPIURL targetURL := claudeAPIURL
if account.Type == model.AccountTypeApiKey { if account.Type == model.AccountTypeApiKey {
@@ -549,7 +566,8 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
req.Header.Set("anthropic-beta", s.getBetaHeader(body, c.GetHeader("anthropic-beta"))) req.Header.Set("anthropic-beta", s.getBetaHeader(body, c.GetHeader("anthropic-beta")))
} }
// 配置代理 // 配置代理 - 创建独立的client避免并发修改共享httpClient
var customClient *http.Client
if account.ProxyID != nil && account.Proxy != nil { if account.ProxyID != nil && account.Proxy != nil {
proxyURL := account.Proxy.URL() proxyURL := account.Proxy.URL()
if proxyURL != "" { if proxyURL != "" {
@@ -566,12 +584,18 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
IdleConnTimeout: 90 * time.Second, IdleConnTimeout: 90 * time.Second,
ResponseHeaderTimeout: responseHeaderTimeout, ResponseHeaderTimeout: responseHeaderTimeout,
} }
s.httpClient.Transport = transport // 创建独立的client避免并发时修改共享的s.httpClient.Transport
customClient = &http.Client{
Transport: transport,
}
} }
} }
} }
return req, nil return &buildUpstreamRequestResult{
Request: req,
Client: customClient,
}, nil
} }
// getBetaHeader 处理anthropic-beta header // getBetaHeader 处理anthropic-beta header

View File

@@ -57,20 +57,22 @@ type RedeemService struct {
} }
// NewRedeemService 创建兑换码服务实例 // NewRedeemService 创建兑换码服务实例
func NewRedeemService(redeemRepo *repository.RedeemCodeRepository, userRepo *repository.UserRepository, subscriptionService *SubscriptionService, rdb *redis.Client) *RedeemService { func NewRedeemService(
redeemRepo *repository.RedeemCodeRepository,
userRepo *repository.UserRepository,
subscriptionService *SubscriptionService,
rdb *redis.Client,
billingCacheService *BillingCacheService,
) *RedeemService {
return &RedeemService{ return &RedeemService{
redeemRepo: redeemRepo, redeemRepo: redeemRepo,
userRepo: userRepo, userRepo: userRepo,
subscriptionService: subscriptionService, subscriptionService: subscriptionService,
rdb: rdb, rdb: rdb,
billingCacheService: billingCacheService,
} }
} }
// SetBillingCacheService 设置计费缓存服务(用于缓存失效)
func (s *RedeemService) SetBillingCacheService(billingCacheService *BillingCacheService) {
s.billingCacheService = billingCacheService
}
// GenerateRandomCode 生成随机兑换码 // GenerateRandomCode 生成随机兑换码
func (s *RedeemService) GenerateRandomCode() (string, error) { func (s *RedeemService) GenerateRandomCode() (string, error) {
// 生成16字节随机数据 // 生成16字节随机数据

View File

@@ -1,12 +1,5 @@
package service package service
import (
"sub2api/internal/config"
"sub2api/internal/repository"
"github.com/redis/go-redis/v9"
)
// Services 服务集合容器 // Services 服务集合容器
type Services struct { type Services struct {
Auth *AuthService Auth *AuthService
@@ -34,106 +27,3 @@ type Services struct {
Concurrency *ConcurrencyService Concurrency *ConcurrencyService
Identity *IdentityService Identity *IdentityService
} }
// NewServices 创建所有服务实例
func NewServices(repos *repository.Repositories, rdb *redis.Client, cfg *config.Config) *Services {
// 初始化价格服务
pricingService := NewPricingService(cfg)
if err := pricingService.Initialize(); err != nil {
// 价格服务初始化失败不应阻止启动,使用回退价格
println("[Service] Warning: Pricing service initialization failed:", err.Error())
}
// 初始化计费服务(依赖价格服务)
billingService := NewBillingService(cfg, pricingService)
// 初始化其他服务
authService := NewAuthService(repos.User, cfg)
userService := NewUserService(repos.User, cfg)
apiKeyService := NewApiKeyService(repos.ApiKey, repos.User, repos.Group, repos.UserSubscription, rdb, cfg)
groupService := NewGroupService(repos.Group)
accountService := NewAccountService(repos.Account, repos.Group)
proxyService := NewProxyService(repos.Proxy)
usageService := NewUsageService(repos.UsageLog, repos.User)
// 初始化订阅服务 (RedeemService 依赖)
subscriptionService := NewSubscriptionService(repos)
// 初始化兑换服务 (依赖订阅服务)
redeemService := NewRedeemService(repos.RedeemCode, repos.User, subscriptionService, rdb)
// 初始化Admin服务
adminService := NewAdminService(repos)
// 初始化OAuth服务GatewayService依赖
oauthService := NewOAuthService(repos.Proxy)
// 初始化限流服务
rateLimitService := NewRateLimitService(repos, cfg)
// 初始化计费缓存服务
billingCacheService := NewBillingCacheService(rdb, repos.User, repos.UserSubscription)
// 初始化账号使用量服务
accountUsageService := NewAccountUsageService(repos, oauthService)
// 初始化账号测试服务
accountTestService := NewAccountTestService(repos, oauthService)
// 初始化身份指纹服务
identityService := NewIdentityService(rdb)
// 初始化Gateway服务
gatewayService := NewGatewayService(repos, rdb, cfg, oauthService, billingService, rateLimitService, billingCacheService, identityService)
// 初始化设置服务
settingService := NewSettingService(repos.Setting, cfg)
emailService := NewEmailService(repos.Setting, rdb)
// 初始化邮件队列服务
emailQueueService := NewEmailQueueService(emailService, 3)
// 初始化Turnstile服务
turnstileService := NewTurnstileService(settingService)
// 设置Auth服务的依赖用于注册开关和邮件验证
authService.SetSettingService(settingService)
authService.SetEmailService(emailService)
authService.SetTurnstileService(turnstileService)
authService.SetEmailQueueService(emailQueueService)
// 初始化并发控制服务
concurrencyService := NewConcurrencyService(rdb)
// 注入计费缓存服务到需要失效缓存的服务
redeemService.SetBillingCacheService(billingCacheService)
subscriptionService.SetBillingCacheService(billingCacheService)
SetAdminServiceBillingCache(adminService, billingCacheService)
return &Services{
Auth: authService,
User: userService,
ApiKey: apiKeyService,
Group: groupService,
Account: accountService,
Proxy: proxyService,
Redeem: redeemService,
Usage: usageService,
Pricing: pricingService,
Billing: billingService,
BillingCache: billingCacheService,
Admin: adminService,
Gateway: gatewayService,
OAuth: oauthService,
RateLimit: rateLimitService,
AccountUsage: accountUsageService,
AccountTest: accountTestService,
Setting: settingService,
Email: emailService,
EmailQueue: emailQueueService,
Turnstile: turnstileService,
Subscription: subscriptionService,
Concurrency: concurrencyService,
Identity: identityService,
}
}

View File

@@ -28,13 +28,11 @@ type SubscriptionService struct {
} }
// NewSubscriptionService 创建订阅服务 // NewSubscriptionService 创建订阅服务
func NewSubscriptionService(repos *repository.Repositories) *SubscriptionService { func NewSubscriptionService(repos *repository.Repositories, billingCacheService *BillingCacheService) *SubscriptionService {
return &SubscriptionService{repos: repos} return &SubscriptionService{
} repos: repos,
billingCacheService: billingCacheService,
// SetBillingCacheService 设置计费缓存服务(用于缓存失效) }
func (s *SubscriptionService) SetBillingCacheService(billingCacheService *BillingCacheService) {
s.billingCacheService = billingCacheService
} }
// AssignSubscriptionInput 分配订阅输入 // AssignSubscriptionInput 分配订阅输入
@@ -88,6 +86,7 @@ func (s *SubscriptionService) AssignSubscription(ctx context.Context, input *Ass
// 如果用户已有同分组的订阅: // 如果用户已有同分组的订阅:
// - 未过期:从当前过期时间累加天数 // - 未过期:从当前过期时间累加天数
// - 已过期:从当前时间开始计算新的过期时间,并激活订阅 // - 已过期:从当前时间开始计算新的过期时间,并激活订阅
//
// 如果没有订阅:创建新订阅 // 如果没有订阅:创建新订阅
func (s *SubscriptionService) AssignOrExtendSubscription(ctx context.Context, input *AssignSubscriptionInput) (*model.UserSubscription, bool, error) { func (s *SubscriptionService) AssignOrExtendSubscription(ctx context.Context, input *AssignSubscriptionInput) (*model.UserSubscription, bool, error) {
// 检查分组是否存在且为订阅类型 // 检查分组是否存在且为订阅类型
@@ -191,15 +190,15 @@ func (s *SubscriptionService) createSubscription(ctx context.Context, input *Ass
now := time.Now() now := time.Now()
sub := &model.UserSubscription{ sub := &model.UserSubscription{
UserID: input.UserID, UserID: input.UserID,
GroupID: input.GroupID, GroupID: input.GroupID,
StartsAt: now, StartsAt: now,
ExpiresAt: now.AddDate(0, 0, validityDays), ExpiresAt: now.AddDate(0, 0, validityDays),
Status: model.SubscriptionStatusActive, Status: model.SubscriptionStatusActive,
AssignedAt: now, AssignedAt: now,
Notes: input.Notes, Notes: input.Notes,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
// 只有当 AssignedBy > 0 时才设置0 表示系统分配,如兑换码) // 只有当 AssignedBy > 0 时才设置0 表示系统分配,如兑换码)
if input.AssignedBy > 0 { if input.AssignedBy > 0 {
@@ -225,17 +224,17 @@ type BulkAssignSubscriptionInput struct {
// BulkAssignResult 批量分配结果 // BulkAssignResult 批量分配结果
type BulkAssignResult struct { type BulkAssignResult struct {
SuccessCount int SuccessCount int
FailedCount int FailedCount int
Subscriptions []model.UserSubscription Subscriptions []model.UserSubscription
Errors []string Errors []string
} }
// BulkAssignSubscription 批量分配订阅 // BulkAssignSubscription 批量分配订阅
func (s *SubscriptionService) BulkAssignSubscription(ctx context.Context, input *BulkAssignSubscriptionInput) (*BulkAssignResult, error) { func (s *SubscriptionService) BulkAssignSubscription(ctx context.Context, input *BulkAssignSubscriptionInput) (*BulkAssignResult, error) {
result := &BulkAssignResult{ result := &BulkAssignResult{
Subscriptions: make([]model.UserSubscription, 0), Subscriptions: make([]model.UserSubscription, 0),
Errors: make([]string, 0), Errors: make([]string, 0),
} }
for _, userID := range input.UserIDs { for _, userID := range input.UserIDs {
@@ -417,10 +416,10 @@ func (s *SubscriptionService) RecordUsage(ctx context.Context, subscriptionID in
// SubscriptionProgress 订阅进度 // SubscriptionProgress 订阅进度
type SubscriptionProgress struct { type SubscriptionProgress struct {
ID int64 `json:"id"` ID int64 `json:"id"`
GroupName string `json:"group_name"` GroupName string `json:"group_name"`
ExpiresAt time.Time `json:"expires_at"` ExpiresAt time.Time `json:"expires_at"`
ExpiresInDays int `json:"expires_in_days"` ExpiresInDays int `json:"expires_in_days"`
Daily *UsageWindowProgress `json:"daily,omitempty"` Daily *UsageWindowProgress `json:"daily,omitempty"`
Weekly *UsageWindowProgress `json:"weekly,omitempty"` Weekly *UsageWindowProgress `json:"weekly,omitempty"`
Monthly *UsageWindowProgress `json:"monthly,omitempty"` Monthly *UsageWindowProgress `json:"monthly,omitempty"`
@@ -428,13 +427,13 @@ type SubscriptionProgress struct {
// UsageWindowProgress 使用窗口进度 // UsageWindowProgress 使用窗口进度
type UsageWindowProgress struct { type UsageWindowProgress struct {
LimitUSD float64 `json:"limit_usd"` LimitUSD float64 `json:"limit_usd"`
UsedUSD float64 `json:"used_usd"` UsedUSD float64 `json:"used_usd"`
RemainingUSD float64 `json:"remaining_usd"` RemainingUSD float64 `json:"remaining_usd"`
Percentage float64 `json:"percentage"` Percentage float64 `json:"percentage"`
WindowStart time.Time `json:"window_start"` WindowStart time.Time `json:"window_start"`
ResetsAt time.Time `json:"resets_at"` ResetsAt time.Time `json:"resets_at"`
ResetsInSeconds int64 `json:"resets_in_seconds"` ResetsInSeconds int64 `json:"resets_in_seconds"`
} }
// GetSubscriptionProgress 获取订阅使用进度 // GetSubscriptionProgress 获取订阅使用进度
@@ -464,12 +463,12 @@ func (s *SubscriptionService) GetSubscriptionProgress(ctx context.Context, subsc
limit := *group.DailyLimitUSD limit := *group.DailyLimitUSD
resetsAt := sub.DailyWindowStart.Add(24 * time.Hour) resetsAt := sub.DailyWindowStart.Add(24 * time.Hour)
progress.Daily = &UsageWindowProgress{ progress.Daily = &UsageWindowProgress{
LimitUSD: limit, LimitUSD: limit,
UsedUSD: sub.DailyUsageUSD, UsedUSD: sub.DailyUsageUSD,
RemainingUSD: limit - sub.DailyUsageUSD, RemainingUSD: limit - sub.DailyUsageUSD,
Percentage: (sub.DailyUsageUSD / limit) * 100, Percentage: (sub.DailyUsageUSD / limit) * 100,
WindowStart: *sub.DailyWindowStart, WindowStart: *sub.DailyWindowStart,
ResetsAt: resetsAt, ResetsAt: resetsAt,
ResetsInSeconds: int64(time.Until(resetsAt).Seconds()), ResetsInSeconds: int64(time.Until(resetsAt).Seconds()),
} }
if progress.Daily.RemainingUSD < 0 { if progress.Daily.RemainingUSD < 0 {
@@ -488,12 +487,12 @@ func (s *SubscriptionService) GetSubscriptionProgress(ctx context.Context, subsc
limit := *group.WeeklyLimitUSD limit := *group.WeeklyLimitUSD
resetsAt := sub.WeeklyWindowStart.Add(7 * 24 * time.Hour) resetsAt := sub.WeeklyWindowStart.Add(7 * 24 * time.Hour)
progress.Weekly = &UsageWindowProgress{ progress.Weekly = &UsageWindowProgress{
LimitUSD: limit, LimitUSD: limit,
UsedUSD: sub.WeeklyUsageUSD, UsedUSD: sub.WeeklyUsageUSD,
RemainingUSD: limit - sub.WeeklyUsageUSD, RemainingUSD: limit - sub.WeeklyUsageUSD,
Percentage: (sub.WeeklyUsageUSD / limit) * 100, Percentage: (sub.WeeklyUsageUSD / limit) * 100,
WindowStart: *sub.WeeklyWindowStart, WindowStart: *sub.WeeklyWindowStart,
ResetsAt: resetsAt, ResetsAt: resetsAt,
ResetsInSeconds: int64(time.Until(resetsAt).Seconds()), ResetsInSeconds: int64(time.Until(resetsAt).Seconds()),
} }
if progress.Weekly.RemainingUSD < 0 { if progress.Weekly.RemainingUSD < 0 {
@@ -512,12 +511,12 @@ func (s *SubscriptionService) GetSubscriptionProgress(ctx context.Context, subsc
limit := *group.MonthlyLimitUSD limit := *group.MonthlyLimitUSD
resetsAt := sub.MonthlyWindowStart.Add(30 * 24 * time.Hour) resetsAt := sub.MonthlyWindowStart.Add(30 * 24 * time.Hour)
progress.Monthly = &UsageWindowProgress{ progress.Monthly = &UsageWindowProgress{
LimitUSD: limit, LimitUSD: limit,
UsedUSD: sub.MonthlyUsageUSD, UsedUSD: sub.MonthlyUsageUSD,
RemainingUSD: limit - sub.MonthlyUsageUSD, RemainingUSD: limit - sub.MonthlyUsageUSD,
Percentage: (sub.MonthlyUsageUSD / limit) * 100, Percentage: (sub.MonthlyUsageUSD / limit) * 100,
WindowStart: *sub.MonthlyWindowStart, WindowStart: *sub.MonthlyWindowStart,
ResetsAt: resetsAt, ResetsAt: resetsAt,
ResetsInSeconds: int64(time.Until(resetsAt).Seconds()), ResetsInSeconds: int64(time.Until(resetsAt).Seconds()),
} }
if progress.Monthly.RemainingUSD < 0 { if progress.Monthly.RemainingUSD < 0 {

View File

@@ -13,7 +13,6 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
@@ -126,6 +125,7 @@ func (s *UpdateService) CheckUpdate(ctx context.Context, force bool) (*UpdateInf
} }
// PerformUpdate downloads and applies the update // PerformUpdate downloads and applies the update
// Uses atomic file replacement pattern for safe in-place updates
func (s *UpdateService) PerformUpdate(ctx context.Context) error { func (s *UpdateService) PerformUpdate(ctx context.Context) error {
info, err := s.CheckUpdate(ctx, true) info, err := s.CheckUpdate(ctx, true)
if err != nil { if err != nil {
@@ -174,8 +174,11 @@ func (s *UpdateService) PerformUpdate(ctx context.Context) error {
return fmt.Errorf("failed to resolve symlinks: %w", err) return fmt.Errorf("failed to resolve symlinks: %w", err)
} }
// Create temp directory for extraction exeDir := filepath.Dir(exePath)
tempDir, err := os.MkdirTemp("", "sub2api-update-*")
// Create temp directory in the SAME directory as executable
// This ensures os.Rename is atomic (same filesystem)
tempDir, err := os.MkdirTemp(exeDir, ".sub2api-update-*")
if err != nil { if err != nil {
return fmt.Errorf("failed to create temp dir: %w", err) return fmt.Errorf("failed to create temp dir: %w", err)
} }
@@ -200,23 +203,36 @@ func (s *UpdateService) PerformUpdate(ctx context.Context) error {
return fmt.Errorf("extraction failed: %w", err) return fmt.Errorf("extraction failed: %w", err)
} }
// Backup current binary // Set executable permission before replacement
backupFile := exePath + ".backup" if err := os.Chmod(newBinaryPath, 0755); err != nil {
if err := os.Rename(exePath, backupFile); err != nil {
return fmt.Errorf("backup failed: %w", err)
}
// Replace with new binary
if err := copyFile(newBinaryPath, exePath); err != nil {
os.Rename(backupFile, exePath)
return fmt.Errorf("replace failed: %w", err)
}
// Make executable
if err := os.Chmod(exePath, 0755); err != nil {
return fmt.Errorf("chmod failed: %w", err) return fmt.Errorf("chmod failed: %w", err)
} }
// Atomic replacement using rename pattern:
// 1. Rename current -> backup (atomic on Unix)
// 2. Rename new -> current (atomic on Unix, same filesystem)
// If step 2 fails, restore backup
backupPath := exePath + ".backup"
// Remove old backup if exists
os.Remove(backupPath)
// Step 1: Move current binary to backup
if err := os.Rename(exePath, backupPath); err != nil {
return fmt.Errorf("backup failed: %w", err)
}
// Step 2: Move new binary to target location (atomic, same filesystem)
if err := os.Rename(newBinaryPath, exePath); err != nil {
// Restore backup on failure
if restoreErr := os.Rename(backupPath, exePath); restoreErr != nil {
return fmt.Errorf("replace failed and restore failed: %w (restore error: %v)", err, restoreErr)
}
return fmt.Errorf("replace failed (restored backup): %w", err)
}
// Success - backup file is kept for rollback capability
// It will be cleaned up on next successful update
return nil return nil
} }
@@ -244,23 +260,6 @@ func (s *UpdateService) Rollback() error {
return nil return nil
} }
// RestartService triggers a service restart via systemd
func (s *UpdateService) RestartService() error {
if runtime.GOOS != "linux" {
return fmt.Errorf("systemd restart only available on Linux")
}
// Try direct systemctl first (works if running as root or with proper permissions)
cmd := exec.Command("systemctl", "restart", "sub2api")
if err := cmd.Run(); err != nil {
// Try with sudo (requires NOPASSWD sudoers entry)
sudoCmd := exec.Command("sudo", "systemctl", "restart", "sub2api")
if sudoErr := sudoCmd.Run(); sudoErr != nil {
return fmt.Errorf("systemctl restart failed: %w (sudo also failed: %v)", err, sudoErr)
}
}
return nil
}
func (s *UpdateService) fetchLatestRelease(ctx context.Context) (*UpdateInfo, error) { func (s *UpdateService) fetchLatestRelease(ctx context.Context) (*UpdateInfo, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", githubRepo) url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", githubRepo)
@@ -533,23 +532,6 @@ func (s *UpdateService) extractBinary(archivePath, destPath string) error {
return err return err
} }
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
func (s *UpdateService) getFromCache(ctx context.Context) (*UpdateInfo, error) { func (s *UpdateService) getFromCache(ctx context.Context) (*UpdateInfo, error) {
data, err := s.rdb.Get(ctx, updateCacheKey).Result() data, err := s.rdb.Get(ctx, updateCacheKey).Result()
if err != nil { if err != nil {

View File

@@ -0,0 +1,54 @@
package service
import (
"sub2api/internal/config"
"github.com/google/wire"
)
// ProvidePricingService creates and initializes PricingService
func ProvidePricingService(cfg *config.Config) (*PricingService, error) {
svc := NewPricingService(cfg)
if err := svc.Initialize(); err != nil {
// 价格服务初始化失败不应阻止启动,使用回退价格
println("[Service] Warning: Pricing service initialization failed:", err.Error())
}
return svc, nil
}
// ProvideEmailQueueService creates EmailQueueService with default worker count
func ProvideEmailQueueService(emailService *EmailService) *EmailQueueService {
return NewEmailQueueService(emailService, 3)
}
// ProviderSet is the Wire provider set for all services
var ProviderSet = wire.NewSet(
// Core services
NewAuthService,
NewUserService,
NewApiKeyService,
NewGroupService,
NewAccountService,
NewProxyService,
NewRedeemService,
NewUsageService,
ProvidePricingService,
NewBillingService,
NewBillingCacheService,
NewAdminService,
NewGatewayService,
NewOAuthService,
NewRateLimitService,
NewAccountUsageService,
NewAccountTestService,
NewSettingService,
NewEmailService,
ProvideEmailQueueService,
NewTurnstileService,
NewSubscriptionService,
NewConcurrencyService,
NewIdentityService,
// Provide the Services container struct
wire.Struct(new(Services), "*"),
)

View File

@@ -7,8 +7,10 @@ import (
"regexp" "regexp"
"strings" "strings"
"sync" "sync"
"time"
"sub2api/internal/pkg/response" "sub2api/internal/pkg/response"
"sub2api/internal/pkg/sysutil"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -337,8 +339,17 @@ func install(c *gin.Context) {
return return
} }
// Schedule service restart in background after sending response
// This ensures the client receives the success response before the service restarts
go func() {
// Wait a moment to ensure the response is sent
time.Sleep(500 * time.Millisecond)
sysutil.RestartServiceAsync()
}()
response.Success(c, gin.H{ response.Success(c, gin.H{
"message": "Installation completed successfully", "message": "Installation completed successfully. Service will restart automatically.",
"restart": true, "restart": true,
}) })
} }

View File

@@ -10,7 +10,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
//go:embed dist/* //go:embed all:dist
var frontendFS embed.FS var frontendFS embed.FS
// ServeEmbeddedFrontend returns a Gin handler that serves embedded frontend assets // ServeEmbeddedFrontend returns a Gin handler that serves embedded frontend assets

8
backend/tools.go Normal file
View File

@@ -0,0 +1,8 @@
//go:build tools
// +build tools
package tools
import (
_ "github.com/google/wire/cmd/wire"
)

View File

@@ -73,9 +73,6 @@ declare -A MSG_ZH=(
["dirs_configured"]="目录配置完成" ["dirs_configured"]="目录配置完成"
["installing_service"]="正在安装 systemd 服务..." ["installing_service"]="正在安装 systemd 服务..."
["service_installed"]="systemd 服务已安装" ["service_installed"]="systemd 服务已安装"
["setting_up_sudoers"]="正在配置 sudoers..."
["sudoers_configured"]="sudoers 配置完成"
["sudoers_failed"]="sudoers 验证失败,已移除文件"
["ready_for_setup"]="准备就绪,可以启动设置向导" ["ready_for_setup"]="准备就绪,可以启动设置向导"
# Completion # Completion
@@ -131,6 +128,15 @@ declare -A MSG_ZH=(
["server_port_hint"]="建议使用 1024-65535 之间的端口" ["server_port_hint"]="建议使用 1024-65535 之间的端口"
["server_config_summary"]="服务器配置" ["server_config_summary"]="服务器配置"
["invalid_port"]="无效端口号,请输入 1-65535 之间的数字" ["invalid_port"]="无效端口号,请输入 1-65535 之间的数字"
# Service management
["starting_service"]="正在启动服务..."
["service_started"]="服务已启动"
["service_start_failed"]="服务启动失败,请检查日志"
["enabling_autostart"]="正在设置开机自启..."
["autostart_enabled"]="开机自启已启用"
["getting_public_ip"]="正在获取公网 IP..."
["public_ip_failed"]="无法获取公网 IP使用本地 IP"
) )
# English strings # English strings
@@ -173,9 +179,6 @@ declare -A MSG_EN=(
["dirs_configured"]="Directories configured" ["dirs_configured"]="Directories configured"
["installing_service"]="Installing systemd service..." ["installing_service"]="Installing systemd service..."
["service_installed"]="Systemd service installed" ["service_installed"]="Systemd service installed"
["setting_up_sudoers"]="Setting up sudoers..."
["sudoers_configured"]="Sudoers configured"
["sudoers_failed"]="Sudoers validation failed, removing file"
["ready_for_setup"]="Ready for Setup Wizard" ["ready_for_setup"]="Ready for Setup Wizard"
# Completion # Completion
@@ -231,6 +234,15 @@ declare -A MSG_EN=(
["server_port_hint"]="Recommended range: 1024-65535" ["server_port_hint"]="Recommended range: 1024-65535"
["server_config_summary"]="Server configuration" ["server_config_summary"]="Server configuration"
["invalid_port"]="Invalid port number, please enter a number between 1-65535" ["invalid_port"]="Invalid port number, please enter a number between 1-65535"
# Service management
["starting_service"]="Starting service..."
["service_started"]="Service started"
["service_start_failed"]="Service failed to start, please check logs"
["enabling_autostart"]="Enabling auto-start on boot..."
["autostart_enabled"]="Auto-start enabled"
["getting_public_ip"]="Getting public IP..."
["public_ip_failed"]="Failed to get public IP, using local IP"
) )
# Get message based on current language # Get message based on current language
@@ -260,9 +272,11 @@ print_error() {
echo -e "${RED}[$(msg 'error')]${NC} $1" echo -e "${RED}[$(msg 'error')]${NC} $1"
} }
# Check if running interactively (stdin is a terminal) # Check if running interactively (can access terminal)
# When piped (curl | bash), stdin is not a terminal, but /dev/tty may still be available
is_interactive() { is_interactive() {
[ -t 0 ] # Check if /dev/tty is available (works even when piped)
[ -e /dev/tty ] && [ -r /dev/tty ] && [ -w /dev/tty ]
} }
# Select language # Select language
@@ -282,7 +296,7 @@ select_language() {
echo " 2) $(msg 'lang_en')" echo " 2) $(msg 'lang_en')"
echo "" echo ""
read -p "$(msg 'enter_choice'): " lang_input read -p "$(msg 'enter_choice'): " lang_input < /dev/tty
case "$lang_input" in case "$lang_input" in
2|en|EN|english|English) 2|en|EN|english|English)
@@ -323,7 +337,7 @@ configure_server() {
# Server host # Server host
echo -e "${YELLOW}$(msg 'server_host_hint')${NC}" echo -e "${YELLOW}$(msg 'server_host_hint')${NC}"
read -p "$(msg 'server_host_prompt') [${SERVER_HOST}]: " input_host read -p "$(msg 'server_host_prompt') [${SERVER_HOST}]: " input_host < /dev/tty
if [ -n "$input_host" ]; then if [ -n "$input_host" ]; then
SERVER_HOST="$input_host" SERVER_HOST="$input_host"
fi fi
@@ -333,7 +347,7 @@ configure_server() {
# Server port # Server port
echo -e "${YELLOW}$(msg 'server_port_hint')${NC}" echo -e "${YELLOW}$(msg 'server_port_hint')${NC}"
while true; do while true; do
read -p "$(msg 'server_port_prompt') [${SERVER_PORT}]: " input_port read -p "$(msg 'server_port_prompt') [${SERVER_PORT}]: " input_port < /dev/tty
if [ -z "$input_port" ]; then if [ -z "$input_port" ]; then
# Use default # Use default
break break
@@ -483,9 +497,24 @@ download_and_extract() {
create_user() { create_user() {
if id "$SERVICE_USER" &>/dev/null; then if id "$SERVICE_USER" &>/dev/null; then
print_info "$(msg 'user_exists'): $SERVICE_USER" print_info "$(msg 'user_exists'): $SERVICE_USER"
# Fix: Ensure existing user has /bin/sh shell for sudo to work
# Previous versions used /bin/false which prevents sudo execution
local current_shell
current_shell=$(getent passwd "$SERVICE_USER" 2>/dev/null | cut -d: -f7)
if [ "$current_shell" = "/bin/false" ] || [ "$current_shell" = "/sbin/nologin" ]; then
print_info "Fixing user shell for sudo compatibility..."
if usermod -s /bin/sh "$SERVICE_USER" 2>/dev/null; then
print_success "User shell updated to /bin/sh"
else
print_warning "Failed to update user shell. Service restart may not work automatically."
print_warning "Manual fix: sudo usermod -s /bin/sh $SERVICE_USER"
fi
fi
else else
print_info "$(msg 'creating_user') $SERVICE_USER..." print_info "$(msg 'creating_user') $SERVICE_USER..."
useradd -r -s /bin/false -d "$INSTALL_DIR" "$SERVICE_USER" # Use /bin/sh instead of /bin/false to allow sudo execution
# The user still cannot login interactively (no password set)
useradd -r -s /bin/sh -d "$INSTALL_DIR" "$SERVICE_USER"
print_success "$(msg 'user_created')" print_success "$(msg 'user_created')"
fi fi
} }
@@ -506,35 +535,6 @@ setup_directories() {
print_success "$(msg 'dirs_configured')" print_success "$(msg 'dirs_configured')"
} }
# Setup sudoers for service restart
setup_sudoers() {
print_info "$(msg 'setting_up_sudoers')"
# Check if sudoers file exists in install dir
if [ -f "$INSTALL_DIR/sub2api-sudoers" ]; then
cp "$INSTALL_DIR/sub2api-sudoers" /etc/sudoers.d/sub2api
else
# Create sudoers file
cat > /etc/sudoers.d/sub2api << 'EOF'
# Sudoers configuration for Sub2API
sub2api ALL=(ALL) NOPASSWD: /bin/systemctl restart sub2api
sub2api ALL=(ALL) NOPASSWD: /bin/systemctl stop sub2api
sub2api ALL=(ALL) NOPASSWD: /bin/systemctl start sub2api
EOF
fi
# Set correct permissions (required for sudoers files)
chmod 440 /etc/sudoers.d/sub2api
# Validate sudoers file
if visudo -c -f /etc/sudoers.d/sub2api &>/dev/null; then
print_success "$(msg 'sudoers_configured')"
else
print_warning "$(msg 'sudoers_failed')"
rm -f /etc/sudoers.d/sub2api
fi
}
# Install systemd service # Install systemd service
install_service() { install_service() {
print_info "$(msg 'installing_service')" print_info "$(msg 'installing_service')"
@@ -586,13 +586,61 @@ prepare_for_setup() {
print_success "$(msg 'ready_for_setup')" print_success "$(msg 'ready_for_setup')"
} }
# Get public IP address
get_public_ip() {
print_info "$(msg 'getting_public_ip')"
# Try to get public IP from ipinfo.io
local response
response=$(curl -s --connect-timeout 5 --max-time 10 "https://ipinfo.io/json" 2>/dev/null)
if [ -n "$response" ]; then
# Extract IP from JSON response using grep and sed (no jq dependency)
PUBLIC_IP=$(echo "$response" | grep -o '"ip": *"[^"]*"' | sed 's/"ip": *"\([^"]*\)"/\1/')
if [ -n "$PUBLIC_IP" ]; then
print_success "Public IP: $PUBLIC_IP"
return 0
fi
fi
# Fallback to local IP
print_warning "$(msg 'public_ip_failed')"
PUBLIC_IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "YOUR_SERVER_IP")
return 1
}
# Start service
start_service() {
print_info "$(msg 'starting_service')"
if systemctl start sub2api; then
print_success "$(msg 'service_started')"
return 0
else
print_error "$(msg 'service_start_failed')"
print_info "sudo journalctl -u sub2api -n 50"
return 1
fi
}
# Enable service auto-start
enable_autostart() {
print_info "$(msg 'enabling_autostart')"
if systemctl enable sub2api 2>/dev/null; then
print_success "$(msg 'autostart_enabled')"
return 0
else
print_warning "Failed to enable auto-start"
return 1
fi
}
# Print completion message # Print completion message
print_completion() { print_completion() {
local ip_addr # Use PUBLIC_IP which was set by get_public_ip()
ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "YOUR_SERVER_IP")
# Determine display address # Determine display address
local display_host="$ip_addr" local display_host="${PUBLIC_IP:-YOUR_SERVER_IP}"
if [ "$SERVER_HOST" = "127.0.0.1" ]; then if [ "$SERVER_HOST" = "127.0.0.1" ]; then
display_host="127.0.0.1" display_host="127.0.0.1"
fi fi
@@ -606,21 +654,9 @@ print_completion() {
echo "$(msg 'server_config_summary'): ${SERVER_HOST}:${SERVER_PORT}" echo "$(msg 'server_config_summary'): ${SERVER_HOST}:${SERVER_PORT}"
echo "" echo ""
echo "==============================================" echo "=============================================="
echo " $(msg 'next_steps')" echo " $(msg 'step4_open_wizard')"
echo "==============================================" echo "=============================================="
echo "" echo ""
echo " 1. $(msg 'step1_check_services')"
echo " sudo systemctl status postgresql"
echo " sudo systemctl status redis"
echo ""
echo " 2. $(msg 'step2_start_service')"
echo " sudo systemctl start sub2api"
echo ""
echo " 3. $(msg 'step3_enable_autostart')"
echo " sudo systemctl enable sub2api"
echo ""
echo " 4. $(msg 'step4_open_wizard')"
echo ""
print_info " http://${display_host}:${SERVER_PORT}" print_info " http://${display_host}:${SERVER_PORT}"
echo "" echo ""
echo " $(msg 'wizard_guide')" echo " $(msg 'wizard_guide')"
@@ -683,11 +719,11 @@ uninstall() {
# If not interactive (piped), require -y flag or skip confirmation # If not interactive (piped), require -y flag or skip confirmation
if ! is_interactive; then if ! is_interactive; then
if [ "${FORCE_YES:-}" != "true" ]; then if [ "${FORCE_YES:-}" != "true" ]; then
print_error "Non-interactive mode detected. Use 'bash -s -- uninstall -y' to confirm." print_error "Non-interactive mode detected. Use 'curl ... | bash -s -- uninstall -y' to confirm."
exit 1 exit 1
fi fi
else else
read -p "$(msg 'are_you_sure') " -n 1 -r read -p "$(msg 'are_you_sure') " -n 1 -r < /dev/tty
echo echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then if [[ ! $REPLY =~ ^[Yy]$ ]]; then
print_info "$(msg 'uninstall_cancelled')" print_info "$(msg 'uninstall_cancelled')"
@@ -701,7 +737,6 @@ uninstall() {
print_info "$(msg 'removing_files')" print_info "$(msg 'removing_files')"
rm -f /etc/systemd/system/sub2api.service rm -f /etc/systemd/system/sub2api.service
rm -f /etc/sudoers.d/sub2api
systemctl daemon-reload systemctl daemon-reload
print_info "$(msg 'removing_install_dir')" print_info "$(msg 'removing_install_dir')"
@@ -772,8 +807,10 @@ main() {
create_user create_user
setup_directories setup_directories
install_service install_service
setup_sudoers
prepare_for_setup prepare_for_setup
get_public_ip
start_service
enable_autostart
print_completion print_completion
} }

View File

@@ -1,13 +0,0 @@
# Sudoers configuration for Sub2API
# This file allows the sub2api service user to restart the service without password
#
# Installation:
# sudo cp sub2api-sudoers /etc/sudoers.d/sub2api
# sudo chmod 440 /etc/sudoers.d/sub2api
#
# SECURITY NOTE: This grants limited sudo access only for service management
# Allow sub2api user to restart the service without password
sub2api ALL=(ALL) NOPASSWD: /bin/systemctl restart sub2api
sub2api ALL=(ALL) NOPASSWD: /bin/systemctl stop sub2api
sub2api ALL=(ALL) NOPASSWD: /bin/systemctl start sub2api

View File

@@ -40,9 +40,42 @@ export async function checkUpdates(force = false): Promise<VersionInfo> {
return data; return data;
} }
export interface UpdateResult {
message: string;
need_restart: boolean;
}
/**
* Perform system update
* Downloads and applies the latest version
*/
export async function performUpdate(): Promise<UpdateResult> {
const { data } = await apiClient.post<UpdateResult>('/admin/system/update');
return data;
}
/**
* Rollback to previous version
*/
export async function rollback(): Promise<UpdateResult> {
const { data } = await apiClient.post<UpdateResult>('/admin/system/rollback');
return data;
}
/**
* Restart the service
*/
export async function restartService(): Promise<{ message: string }> {
const { data } = await apiClient.post<{ message: string }>('/admin/system/restart');
return data;
}
export const systemAPI = { export const systemAPI = {
getVersion, getVersion,
checkUpdates, checkUpdates,
performUpdate,
rollback,
restartService,
}; };
export default systemAPI; export default systemAPI;

View File

@@ -12,7 +12,8 @@
]" ]"
:title="hasUpdate ? 'New version available' : 'Up to date'" :title="hasUpdate ? 'New version available' : 'Up to date'"
> >
<span class="font-medium">v{{ currentVersion }}</span> <span v-if="currentVersion" class="font-medium">v{{ currentVersion }}</span>
<span v-else class="font-medium w-12 h-3 bg-gray-200 dark:bg-dark-600 rounded animate-pulse"></span>
<!-- Update indicator --> <!-- Update indicator -->
<span v-if="hasUpdate" class="relative flex h-2 w-2"> <span v-if="hasUpdate" class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span> <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
@@ -56,7 +57,8 @@
<!-- Version display - centered and prominent --> <!-- Version display - centered and prominent -->
<div class="text-center mb-4"> <div class="text-center mb-4">
<div class="inline-flex items-center gap-2"> <div class="inline-flex items-center gap-2">
<span class="text-2xl font-bold text-gray-900 dark:text-white">v{{ currentVersion }}</span> <span v-if="currentVersion" class="text-2xl font-bold text-gray-900 dark:text-white">v{{ currentVersion }}</span>
<span v-else class="text-2xl font-bold text-gray-400 dark:text-dark-500">--</span>
<!-- Show check mark when up to date --> <!-- Show check mark when up to date -->
<span v-if="!hasUpdate" class="flex items-center justify-center w-5 h-5 rounded-full bg-green-100 dark:bg-green-900/30"> <span v-if="!hasUpdate" class="flex items-center justify-center w-5 h-5 rounded-full bg-green-100 dark:bg-green-900/30">
<svg class="w-3 h-3 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-3 h-3 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20">
@@ -69,8 +71,63 @@
</p> </p>
</div> </div>
<!-- Update available for source build - show git pull hint --> <!-- Priority 1: Update error (must check before hasUpdate) -->
<div v-if="hasUpdate && !isReleaseBuild" class="space-y-2"> <div v-if="updateError" class="space-y-2">
<div class="flex items-center gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/50">
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-red-100 dark:bg-red-900/50 flex items-center justify-center">
<svg class="w-4 h-4 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-red-700 dark:text-red-300">{{ t('version.updateFailed') }}</p>
<p class="text-xs text-red-600/70 dark:text-red-400/70 truncate">{{ updateError }}</p>
</div>
</div>
<!-- Retry button -->
<button
@click="handleUpdate"
:disabled="updating"
class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-500 hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ t('version.retry') }}
</button>
</div>
<!-- Priority 2: Update success - need restart -->
<div v-else-if="updateSuccess && needRestart" class="space-y-2">
<div class="flex items-center gap-3 p-3 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800/50">
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-green-100 dark:bg-green-900/50 flex items-center justify-center">
<svg class="w-4 h-4 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-green-700 dark:text-green-300">{{ t('version.updateComplete') }}</p>
<p class="text-xs text-green-600/70 dark:text-green-400/70">{{ t('version.restartRequired') }}</p>
</div>
</div>
<!-- Restart button -->
<button
@click="handleRestart"
:disabled="restarting"
class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-green-500 hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg v-if="restarting" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<svg v-else class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ restarting ? t('version.restarting') : t('version.restartNow') }}
</button>
</div>
<!-- Priority 3: Update available for source build - show git pull hint -->
<div v-else-if="hasUpdate && !isReleaseBuild" class="space-y-2">
<a <a
v-if="releaseInfo?.html_url && releaseInfo.html_url !== '#'" v-if="releaseInfo?.html_url && releaseInfo.html_url !== '#'"
:href="releaseInfo.html_url" :href="releaseInfo.html_url"
@@ -100,29 +157,53 @@
</div> </div>
</div> </div>
<!-- Update available for release build - show download link --> <!-- Priority 4: Update available for release build - show update button -->
<a <div v-else-if="hasUpdate && isReleaseBuild" class="space-y-2">
v-else-if="hasUpdate && isReleaseBuild && releaseInfo?.html_url && releaseInfo.html_url !== '#'" <!-- Update info card -->
:href="releaseInfo.html_url" <div class="flex items-center gap-3 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50">
target="_blank" <div class="flex-shrink-0 w-8 h-8 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center">
rel="noopener noreferrer" <svg class="w-4 h-4 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
class="flex items-center gap-3 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50 hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors group" <path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
> </svg>
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center"> </div>
<svg class="w-4 h-4 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <div class="flex-1 min-w-0">
<p class="text-sm font-medium text-amber-700 dark:text-amber-300">{{ t('version.updateAvailable') }}</p>
<p class="text-xs text-amber-600/70 dark:text-amber-400/70">v{{ latestVersion }}</p>
</div>
</div>
<!-- Update button -->
<button
@click="handleUpdate"
:disabled="updating"
class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-primary-500 hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg v-if="updating" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<svg v-else class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /> <path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg> </svg>
</div> {{ updating ? t('version.updating') : t('version.updateNow') }}
<div class="flex-1 min-w-0"> </button>
<p class="text-sm font-medium text-amber-700 dark:text-amber-300">{{ t('version.updateAvailable') }}</p>
<p class="text-xs text-amber-600/70 dark:text-amber-400/70">v{{ latestVersion }}</p>
</div>
<svg class="w-4 h-4 text-amber-500 dark:text-amber-400 group-hover:translate-x-0.5 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</a>
<!-- GitHub link when up to date --> <!-- View release link -->
<a
v-if="releaseInfo?.html_url && releaseInfo.html_url !== '#'"
:href="releaseInfo.html_url"
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-center gap-1 text-xs text-gray-500 dark:text-dark-400 hover:text-gray-700 dark:hover:text-dark-200 transition-colors"
>
{{ t('version.viewChangelog') }}
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
<!-- Priority 5: Up to date - show GitHub link -->
<a <a
v-else-if="releaseInfo?.html_url && releaseInfo.html_url !== '#'" v-else-if="releaseInfo?.html_url && releaseInfo.html_url !== '#'"
:href="releaseInfo.html_url" :href="releaseInfo.html_url"
@@ -154,8 +235,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/stores'; import { useAuthStore, useAppStore } from '@/stores';
import { checkUpdates, type VersionInfo, type ReleaseInfo } from '@/api/admin/system'; import { performUpdate, restartService } from '@/api/admin/system';
const { t } = useI18n(); const { t } = useI18n();
@@ -164,18 +245,27 @@ const props = defineProps<{
}>(); }>();
const authStore = useAuthStore(); const authStore = useAuthStore();
const appStore = useAppStore();
const isAdmin = computed(() => authStore.isAdmin); const isAdmin = computed(() => authStore.isAdmin);
const loading = ref(false);
const dropdownOpen = ref(false); const dropdownOpen = ref(false);
const dropdownRef = ref<HTMLElement | null>(null); const dropdownRef = ref<HTMLElement | null>(null);
const currentVersion = ref('0.1.0'); // Use store's cached version state
const latestVersion = ref('0.1.0'); const loading = computed(() => appStore.versionLoading);
const hasUpdate = ref(false); const currentVersion = computed(() => appStore.currentVersion || props.version || '');
const releaseInfo = ref<ReleaseInfo | null>(null); const latestVersion = computed(() => appStore.latestVersion);
const buildType = ref('source'); // "source" or "release" const hasUpdate = computed(() => appStore.hasUpdate);
const releaseInfo = computed(() => appStore.releaseInfo);
const buildType = computed(() => appStore.buildType);
// Update process states (local to this component)
const updating = ref(false);
const restarting = ref(false);
const needRestart = ref(false);
const updateError = ref('');
const updateSuccess = ref(false);
// Only show update check for release builds (binary/docker deployment) // Only show update check for release builds (binary/docker deployment)
const isReleaseBuild = computed(() => buildType.value === 'release'); const isReleaseBuild = computed(() => buildType.value === 'release');
@@ -191,22 +281,54 @@ function closeDropdown() {
async function refreshVersion(force = true) { async function refreshVersion(force = true) {
if (!isAdmin.value) return; if (!isAdmin.value) return;
loading.value = true; // Reset update states when refreshing
updateError.value = '';
updateSuccess.value = false;
needRestart.value = false;
await appStore.fetchVersion(force);
}
async function handleUpdate() {
if (updating.value) return;
updating.value = true;
updateError.value = '';
updateSuccess.value = false;
try { try {
const data: VersionInfo = await checkUpdates(force); const result = await performUpdate();
currentVersion.value = data.current_version; updateSuccess.value = true;
latestVersion.value = data.latest_version; needRestart.value = result.need_restart;
buildType.value = data.build_type || 'source'; // Clear version cache to reflect update completed
// Show update indicator for all build types appStore.clearVersionCache();
hasUpdate.value = data.has_update; } catch (error: unknown) {
releaseInfo.value = data.release_info || null; const err = error as { response?: { data?: { message?: string } }; message?: string };
} catch (error) { updateError.value = err.response?.data?.message || err.message || t('version.updateFailed');
console.error('Failed to check updates:', error);
} finally { } finally {
loading.value = false; updating.value = false;
} }
} }
async function handleRestart() {
if (restarting.value) return;
restarting.value = true;
try {
await restartService();
// Service will restart, page will reload automatically or show disconnected
} catch (error) {
// Expected - connection will be lost during restart
console.log('Service restarting...');
}
// Show restarting state for a while, then reload
setTimeout(() => {
window.location.reload();
}, 3000);
}
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
const target = event.target as Node; const target = event.target as Node;
const button = (event.target as Element).closest('button'); const button = (event.target as Element).closest('button');
@@ -217,9 +339,8 @@ function handleClickOutside(event: MouseEvent) {
onMounted(() => { onMounted(() => {
if (isAdmin.value) { if (isAdmin.value) {
refreshVersion(false); // Use cached version if available, otherwise fetch
} else if (props.version) { appStore.fetchVersion(false);
currentVersion.value = props.version;
} }
document.addEventListener('click', handleClickOutside); document.addEventListener('click', handleClickOutside);
}); });

View File

@@ -108,7 +108,7 @@
</router-link> </router-link>
<a <a
href="https://github.com/fangyuan99/sub2api" href="https://github.com/Wei-Shaw/sub2api"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@click="closeDropdown" @click="closeDropdown"

View File

@@ -543,6 +543,7 @@ export default {
title: 'Subscription Settings', title: 'Subscription Settings',
type: 'Billing Type', type: 'Billing Type',
typeHint: 'Standard billing deducts from user balance. Subscription mode uses quota limits instead.', typeHint: 'Standard billing deducts from user balance. Subscription mode uses quota limits instead.',
typeNotEditable: 'Billing type cannot be changed after group creation.',
standard: 'Standard (Balance)', standard: 'Standard (Balance)',
subscription: 'Subscription (Quota)', subscription: 'Subscription (Quota)',
dailyLimit: 'Daily Limit (USD)', dailyLimit: 'Daily Limit (USD)',
@@ -1023,9 +1024,18 @@ export default {
noReleaseNotes: 'No release notes', noReleaseNotes: 'No release notes',
viewUpdate: 'View Update', viewUpdate: 'View Update',
viewRelease: 'View Release', viewRelease: 'View Release',
viewChangelog: 'View Changelog',
refresh: 'Refresh', refresh: 'Refresh',
sourceMode: 'Source Build', sourceMode: 'Source Build',
sourceModeHint: 'Update detection is disabled for source builds. Use git pull to update.', sourceModeHint: 'Source build, use git pull to update',
updateNow: 'Update Now',
updating: 'Updating...',
updateComplete: 'Update Complete',
updateFailed: 'Update Failed',
restartRequired: 'Please restart the service to apply the update',
restartNow: 'Restart Now',
restarting: 'Restarting...',
retry: 'Retry',
}, },
// User Subscriptions Page // User Subscriptions Page

View File

@@ -598,6 +598,7 @@ export default {
title: '订阅设置', title: '订阅设置',
type: '计费类型', type: '计费类型',
typeHint: '标准计费从用户余额扣除。订阅模式使用配额限制。', typeHint: '标准计费从用户余额扣除。订阅模式使用配额限制。',
typeNotEditable: '分组创建后无法修改计费类型。',
standard: '标准(余额)', standard: '标准(余额)',
subscription: '订阅(配额)', subscription: '订阅(配额)',
dailyLimit: '每日限额USD', dailyLimit: '每日限额USD',
@@ -1202,9 +1203,18 @@ export default {
noReleaseNotes: '暂无更新日志', noReleaseNotes: '暂无更新日志',
viewUpdate: '查看更新', viewUpdate: '查看更新',
viewRelease: '查看发布', viewRelease: '查看发布',
viewChangelog: '查看更新日志',
refresh: '刷新', refresh: '刷新',
sourceMode: '源码构建', sourceMode: '源码构建',
sourceModeHint: '源码构建模式不支持更新检测,请使用 git pull 更新代码。', sourceModeHint: '源码构建请使用 git pull 更新',
updateNow: '立即更新',
updating: '正在更新...',
updateComplete: '更新完成',
updateFailed: '更新失败',
restartRequired: '请重启服务以应用更新',
restartNow: '立即重启',
restarting: '正在重启...',
retry: '重试',
}, },
// User Subscriptions Page // User Subscriptions Page

View File

@@ -9,4 +9,8 @@ const app = createApp(App)
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
app.use(i18n) app.use(i18n)
app.mount('#app')
// 等待路由器完成初始导航后再挂载,避免竞态条件导致的空白渲染
router.isReady().then(() => {
app.mount('#app')
})

View File

@@ -305,9 +305,10 @@ router.beforeEach((to, _from, next) => {
// If route doesn't require auth, allow access // If route doesn't require auth, allow access
if (!requiresAuth) { if (!requiresAuth) {
// If already authenticated and trying to access login/register, redirect to dashboard // If already authenticated and trying to access login/register, redirect to appropriate dashboard
if (authStore.isAuthenticated && (to.path === '/login' || to.path === '/register')) { if (authStore.isAuthenticated && (to.path === '/login' || to.path === '/register')) {
next('/dashboard'); // Admin users go to admin dashboard, regular users go to user dashboard
next(authStore.isAdmin ? '/admin/dashboard' : '/dashboard');
return; return;
} }
next(); next();

View File

@@ -6,14 +6,24 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import type { Toast, ToastType } from '@/types'; import type { Toast, ToastType } from '@/types';
import { checkUpdates as checkUpdatesAPI, type VersionInfo, type ReleaseInfo } from '@/api/admin/system';
export const useAppStore = defineStore('app', () => { export const useAppStore = defineStore('app', () => {
// ==================== State ==================== // ==================== State ====================
const sidebarCollapsed = ref<boolean>(false); const sidebarCollapsed = ref<boolean>(false);
const loading = ref<boolean>(false); const loading = ref<boolean>(false);
const toasts = ref<Toast[]>([]); const toasts = ref<Toast[]>([]);
// Version cache state
const versionLoaded = ref<boolean>(false);
const versionLoading = ref<boolean>(false);
const currentVersion = ref<string>('');
const latestVersion = ref<string>('');
const hasUpdate = ref<boolean>(false);
const buildType = ref<string>('source');
const releaseInfo = ref<ReleaseInfo | null>(null);
// Auto-incrementing ID for toasts // Auto-incrementing ID for toasts
let toastIdCounter = 0; let toastIdCounter = 0;
@@ -192,6 +202,56 @@ export const useAppStore = defineStore('app', () => {
toasts.value = []; toasts.value = [];
} }
// ==================== Version Management ====================
/**
* Fetch version info (uses cache unless force=true)
* @param force - Force refresh from API
*/
async function fetchVersion(force = false): Promise<VersionInfo | null> {
// Return cached data if available and not forcing refresh
if (versionLoaded.value && !force) {
return {
current_version: currentVersion.value,
latest_version: latestVersion.value,
has_update: hasUpdate.value,
build_type: buildType.value,
release_info: releaseInfo.value || undefined,
cached: true,
};
}
// Prevent duplicate requests
if (versionLoading.value) {
return null;
}
versionLoading.value = true;
try {
const data = await checkUpdatesAPI(force);
currentVersion.value = data.current_version;
latestVersion.value = data.latest_version;
hasUpdate.value = data.has_update;
buildType.value = data.build_type || 'source';
releaseInfo.value = data.release_info || null;
versionLoaded.value = true;
return data;
} catch (error) {
console.error('Failed to fetch version:', error);
return null;
} finally {
versionLoading.value = false;
}
}
/**
* Clear version cache (e.g., after update)
*/
function clearVersionCache(): void {
versionLoaded.value = false;
hasUpdate.value = false;
}
// ==================== Return Store API ==================== // ==================== Return Store API ====================
return { return {
@@ -199,10 +259,19 @@ export const useAppStore = defineStore('app', () => {
sidebarCollapsed, sidebarCollapsed,
loading, loading,
toasts, toasts,
// Version state
versionLoaded,
versionLoading,
currentVersion,
latestVersion,
hasUpdate,
buildType,
releaseInfo,
// Computed // Computed
hasActiveToasts, hasActiveToasts,
// Actions // Actions
toggleSidebar, toggleSidebar,
setSidebarCollapsed, setSidebarCollapsed,
@@ -217,5 +286,9 @@ export const useAppStore = defineStore('app', () => {
withLoading, withLoading,
withLoadingAndError, withLoadingAndError,
reset, reset,
// Version actions
fetchVersion,
clearVersionCache,
}; };
}); });

View File

@@ -282,7 +282,7 @@ const siteSubtitle = ref('AI API Gateway Platform');
const isDark = ref(document.documentElement.classList.contains('dark')); const isDark = ref(document.documentElement.classList.contains('dark'));
// GitHub URL // GitHub URL
const githubUrl = 'https://github.com/fangyuan99/sub2api'; const githubUrl = 'https://github.com/Wei-Shaw/sub2api';
// Auth state // Auth state
const isAuthenticated = computed(() => authStore.isAuthenticated); const isAuthenticated = computed(() => authStore.isAuthenticated);

View File

@@ -406,7 +406,7 @@ const lineOptions = computed(() => ({
// Model chart data // Model chart data
const modelChartData = computed(() => { const modelChartData = computed(() => {
if (!modelStats.value.length) return null if (!modelStats.value?.length) return null
const colors = [ const colors = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
@@ -425,7 +425,7 @@ const modelChartData = computed(() => {
// Trend chart data // Trend chart data
const trendChartData = computed(() => { const trendChartData = computed(() => {
if (!trendData.value.length) return null if (!trendData.value?.length) return null
return { return {
labels: trendData.value.map(d => d.date), labels: trendData.value.map(d => d.date),
@@ -460,7 +460,7 @@ const trendChartData = computed(() => {
// User trend chart data // User trend chart data
const userTrendChartData = computed(() => { const userTrendChartData = computed(() => {
if (!userTrend.value.length) return null if (!userTrend.value?.length) return null
// Group by user // Group by user
const userGroups = new Map<string, { name: string; data: Map<string, number> }>() const userGroups = new Map<string, { name: string; data: Map<string, number> }>()

View File

@@ -180,7 +180,7 @@
/> />
<p class="input-hint">{{ t('admin.groups.rateMultiplierHint') }}</p> <p class="input-hint">{{ t('admin.groups.rateMultiplierHint') }}</p>
</div> </div>
<div class="flex items-center gap-3"> <div v-if="createForm.subscription_type !== 'subscription'" class="flex items-center gap-3">
<button <button
type="button" type="button"
@click="createForm.is_exclusive = !createForm.is_exclusive" @click="createForm.is_exclusive = !createForm.is_exclusive"
@@ -323,7 +323,7 @@
class="input" class="input"
/> />
</div> </div>
<div class="flex items-center gap-3"> <div v-if="editForm.subscription_type !== 'subscription'" class="flex items-center gap-3">
<button <button
type="button" type="button"
@click="editForm.is_exclusive = !editForm.is_exclusive" @click="editForm.is_exclusive = !editForm.is_exclusive"
@@ -360,8 +360,9 @@
<Select <Select
v-model="editForm.subscription_type" v-model="editForm.subscription_type"
:options="subscriptionTypeOptions" :options="subscriptionTypeOptions"
:disabled="true"
/> />
<p class="input-hint">{{ t('admin.groups.subscription.typeHint') }}</p> <p class="input-hint">{{ t('admin.groups.subscription.typeNotEditable') }}</p>
</div> </div>
<!-- Subscription limits (only show when subscription type is selected) --> <!-- Subscription limits (only show when subscription type is selected) -->
@@ -676,16 +677,11 @@ const confirmDelete = async () => {
} }
} }
// 监听 subscription_type 变化,配额模式时重置 rate_multiplier 为 1 // 监听 subscription_type 变化,订阅模式时重置 rate_multiplier 为 1is_exclusive 为 true
watch(() => createForm.subscription_type, (newVal) => { watch(() => createForm.subscription_type, (newVal) => {
if (newVal === 'subscription') { if (newVal === 'subscription') {
createForm.rate_multiplier = 1.0 createForm.rate_multiplier = 1.0
} createForm.is_exclusive = true
})
watch(() => editForm.subscription_type, (newVal) => {
if (newVal === 'subscription') {
editForm.rate_multiplier = 1.0
} }
}) })

View File

@@ -214,12 +214,18 @@
<!-- Success Message --> <!-- Success Message -->
<div v-if="installSuccess" class="mt-6 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800/50 rounded-xl"> <div v-if="installSuccess" class="mt-6 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800/50 rounded-xl">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<svg class="w-5 h-5 text-green-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> <svg v-if="!serviceReady" class="animate-spin w-5 h-5 text-green-500 flex-shrink-0" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<svg v-else class="w-5 h-5 text-green-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
<div> <div>
<p class="text-sm font-medium text-green-700 dark:text-green-400">Installation completed!</p> <p class="text-sm font-medium text-green-700 dark:text-green-400">Installation completed!</p>
<p class="text-sm text-green-600 dark:text-green-500 mt-1">Please restart the service to apply changes.</p> <p class="text-sm text-green-600 dark:text-green-500 mt-1">
{{ serviceReady ? 'Redirecting to login page...' : 'Service is restarting, please wait...' }}
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -290,6 +296,7 @@ const dbConnected = ref(false);
const redisConnected = ref(false); const redisConnected = ref(false);
const installing = ref(false); const installing = ref(false);
const confirmPassword = ref(''); const confirmPassword = ref('');
const serviceReady = ref(false);
// Get current server port from browser location (set by install.sh) // Get current server port from browser location (set by install.sh)
const getCurrentPort = (): number => { const getCurrentPort = (): number => {
@@ -390,6 +397,8 @@ async function performInstall() {
try { try {
await install(formData); await install(formData);
installSuccess.value = true; installSuccess.value = true;
// Start polling for service restart
waitForServiceRestart();
} catch (error: unknown) { } catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } }; message?: string }; const err = error as { response?: { data?: { detail?: string } }; message?: string };
errorMessage.value = err.response?.data?.detail || err.message || 'Installation failed'; errorMessage.value = err.response?.data?.detail || err.message || 'Installation failed';
@@ -397,4 +406,52 @@ async function performInstall() {
installing.value = false; installing.value = false;
} }
} }
// Wait for service to restart and become available
async function waitForServiceRestart() {
const maxAttempts = 30; // 30 attempts, ~30 seconds max
const interval = 1000; // 1 second between attempts
// Wait a moment for the service to start restarting
await new Promise(resolve => setTimeout(resolve, 2000));
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
// Try to access the health endpoint
const response = await fetch('/health', {
method: 'GET',
cache: 'no-store'
});
if (response.ok) {
// Service is up, check if setup is no longer needed
const statusResponse = await fetch('/setup/status', {
method: 'GET',
cache: 'no-store'
});
if (statusResponse.ok) {
const data = await statusResponse.json();
// If needs_setup is false, service has restarted in normal mode
if (data.data && !data.data.needs_setup) {
serviceReady.value = true;
// Redirect to login page after a short delay
setTimeout(() => {
window.location.href = '/login';
}, 1500);
return;
}
}
}
} catch {
// Service not ready yet, continue polling
}
await new Promise(resolve => setTimeout(resolve, interval));
}
// If we reach here, service didn't restart in time
// Show a message to refresh manually
errorMessage.value = 'Service restart is taking longer than expected. Please refresh the page manually.';
}
</script> </script>

View File

@@ -531,7 +531,7 @@ const lineOptions = computed(() => ({
// Model chart data // Model chart data
const modelChartData = computed(() => { const modelChartData = computed(() => {
if (!modelStats.value.length) return null if (!modelStats.value?.length) return null
const colors = [ const colors = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
@@ -550,7 +550,7 @@ const modelChartData = computed(() => {
// Trend chart data // Trend chart data
const trendChartData = computed(() => { const trendChartData = computed(() => {
if (!trendData.value.length) return null if (!trendData.value?.length) return null
return { return {
labels: trendData.value.map(d => d.date), labels: trendData.value.map(d => d.date),
@@ -688,8 +688,9 @@ const loadChartData = async () => {
usageAPI.getDashboardModels({ start_date: startDate.value, end_date: endDate.value }), usageAPI.getDashboardModels({ start_date: startDate.value, end_date: endDate.value }),
]) ])
trendData.value = trendResponse.trend // Ensure we always have arrays, even if API returns null
modelStats.value = modelResponse.models trendData.value = trendResponse.trend || []
modelStats.value = modelResponse.models || []
} catch (error) { } catch (error) {
console.error('Error loading chart data:', error) console.error('Error loading chart data:', error)
} }