Compare commits

...

17 Commits

Author SHA1 Message Date
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
26 changed files with 814 additions and 284 deletions

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.

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 服务,平台负责鉴权、计费、负载均衡和请求转发。

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"
@@ -110,78 +113,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,7 +143,7 @@ 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)
} }
@@ -201,6 +151,11 @@ func runMainServer() {
} }
func initDB(cfg *config.Config) (*gorm.DB, error) { func initDB(cfg *config.Config) (*gorm.DB, error) {
// 初始化时区(在数据库连接之前,确保时区设置正确)
if err := timezone.Init(cfg.Timezone); err != nil {
return nil, err
}
gormConfig := &gorm.Config{} gormConfig := &gorm.Config{}
if cfg.Server.Mode == "debug" { if cfg.Server.Mode == "debug" {
gormConfig.Logger = logger.Default.LogMode(logger.Info) gormConfig.Logger = logger.Default.LogMode(logger.Info)
@@ -272,10 +227,10 @@ func registerRoutes(r *gin.Engine, h *handler.Handlers, s *service.Services, rep
authenticated.GET("/auth/me", h.Auth.GetCurrentUser) authenticated.GET("/auth/me", h.Auth.GetCurrentUser)
// 用户接口 // 用户接口
user := authenticated.Group("/user") user := authenticated.Group("/users/me")
{ {
user.GET("/profile", h.User.GetProfile) user.GET("", h.User.GetProfile)
user.PUT("/password", h.User.ChangePassword) user.POST("/password", h.User.ChangePassword)
} }
// API Key管理 // API Key管理
@@ -479,3 +434,34 @@ func registerRoutes(r *gin.Engine, h *handler.Handlers, s *service.Services, rep
gateway.GET("/usage", h.Gateway.Usage) gateway.GET("/usage", h.Gateway.Usage)
} }
} }
// 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
}
// createHTTPServer 创建HTTP服务器
func createHTTPServer(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因为大请求体可能需要较长时间读取
}
}

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/repository"
"sub2api/internal/service"
"net/http"
"github.com/gin-gonic/gin"
"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(
// Config provider
provideConfig,
// Database provider
provideDB,
// Redis provider
provideRedis,
// Repository provider
provideRepositories,
// Service provider
provideServices,
// Handler provider
provideHandlers,
// Router provider
provideRouter,
// HTTP Server provider
provideHTTPServer,
// Cleanup provider
provideCleanup,
// Application provider
wire.Struct(new(Application), "Server", "Cleanup"),
)
return nil, nil
}
func provideConfig() (*config.Config, error) {
return config.Load()
}
func provideDB(cfg *config.Config) (*gorm.DB, error) {
return initDB(cfg)
}
func provideRedis(cfg *config.Config) *redis.Client {
return initRedis(cfg)
}
func provideRepositories(db *gorm.DB) *repository.Repositories {
return repository.NewRepositories(db)
}
func provideServices(repos *repository.Repositories, rdb *redis.Client, cfg *config.Config) *service.Services {
return service.NewServices(repos, rdb, cfg)
}
func provideHandlers(services *service.Services, repos *repository.Repositories, rdb *redis.Client, buildInfo handler.BuildInfo) *handler.Handlers {
return handler.NewHandlers(services, repos, rdb, buildInfo)
}
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)
}
func provideHTTPServer(cfg *config.Config, router *gin.Engine) *http.Server {
return createHTTPServer(cfg, router)
}
func provideCleanup() func() {
return func() {
// @todo
}
}

View File

@@ -0,0 +1,99 @@
// 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 (
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"net/http"
"sub2api/internal/config"
"sub2api/internal/handler"
"sub2api/internal/repository"
"sub2api/internal/service"
)
import (
_ "embed"
)
// Injectors from wire.go:
func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
config, err := provideConfig()
if err != nil {
return nil, err
}
db, err := provideDB(config)
if err != nil {
return nil, err
}
repositories := provideRepositories(db)
client := provideRedis(config)
services := provideServices(repositories, client, config)
handlers := provideHandlers(services, repositories, client, buildInfo)
engine := provideRouter(config, handlers, services, repositories)
server := provideHTTPServer(config, engine)
v := provideCleanup()
application := &Application{
Server: server,
Cleanup: v,
}
return application, nil
}
// wire.go:
type Application struct {
Server *http.Server
Cleanup func()
}
func provideConfig() (*config.Config, error) {
return config.Load()
}
func provideDB(cfg *config.Config) (*gorm.DB, error) {
return initDB(cfg)
}
func provideRedis(cfg *config.Config) *redis.Client {
return initRedis(cfg)
}
func provideRepositories(db *gorm.DB) *repository.Repositories {
return repository.NewRepositories(db)
}
func provideServices(repos *repository.Repositories, rdb *redis.Client, cfg *config.Config) *service.Services {
return service.NewServices(repos, rdb, cfg)
}
func provideHandlers(services *service.Services, repos *repository.Repositories, rdb *redis.Client, buildInfo handler.BuildInfo) *handler.Handlers {
return handler.NewHandlers(services, repos, rdb, buildInfo)
}
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)
}
func provideHTTPServer(cfg *config.Config, router *gin.Engine) *http.Server {
return createHTTPServer(cfg, router)
}
func provideCleanup() func() {
return func() {
}
}

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

@@ -1,43 +1,39 @@
package sysutil package sysutil
import ( import (
"fmt"
"log" "log"
"os/exec" "os"
"runtime" "runtime"
"time"
) )
const serviceName = "sub2api" // RestartService triggers a service restart by gracefully exiting.
// RestartService triggers a service restart via systemd.
// //
// IMPORTANT: This function initiates the restart and returns immediately. // This relies on systemd's Restart=always configuration to automatically
// The actual restart happens asynchronously - the current process will be killed // restart the service after it exits. This is the industry-standard approach:
// by systemd and a new process will be started. // - Simple and reliable
// // - No sudo permissions needed
// We use Start() instead of Run() because: // - No complex process management
// - systemctl restart will kill the current process first // - Leverages systemd's native restart capability
// - Run() waits for completion, but the process dies before completion
// - Start() spawns the command independently, allowing systemd to handle the full cycle
// //
// Prerequisites: // Prerequisites:
// - Linux OS with systemd // - Linux OS with systemd
// - NOPASSWD sudo access configured (install.sh creates /etc/sudoers.d/sub2api) // - Service configured with Restart=always in systemd unit file
func RestartService() error { func RestartService() error {
if runtime.GOOS != "linux" { if runtime.GOOS != "linux" {
return fmt.Errorf("systemd restart only available on Linux") log.Println("Service restart via exit only works on Linux with systemd")
return nil
} }
log.Println("Initiating service restart...") log.Println("Initiating service restart by graceful exit...")
log.Println("systemd will automatically restart the service (Restart=always)")
// The sub2api user has NOPASSWD sudo access for systemctl commands // Give a moment for logs to flush and response to be sent
// (configured by install.sh in /etc/sudoers.d/sub2api). go func() {
cmd := exec.Command("sudo", "systemctl", "restart", serviceName) time.Sleep(100 * time.Millisecond)
if err := cmd.Start(); err != nil { os.Exit(0)
return fmt.Errorf("failed to initiate service restart: %w", err) }()
}
log.Println("Service restart initiated successfully")
return nil return nil
} }

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

@@ -125,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 {
@@ -173,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)
} }
@@ -199,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
} }
@@ -515,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 {

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')"
@@ -687,7 +723,7 @@ uninstall() {
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

@@ -1023,9 +1023,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

@@ -1202,9 +1202,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

@@ -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)
} }