mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-07 17:00:20 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d30ceae8d | ||
|
|
60f6ed6bf6 | ||
|
|
4a2f7d4a99 | ||
|
|
c19a393be9 | ||
|
|
938ffb002e | ||
|
|
372a01290b | ||
|
|
8b163ca49b | ||
|
|
d23810dc53 | ||
|
|
62ed5422dd | ||
|
|
2e76302af7 | ||
|
|
6553828008 | ||
|
|
adcb7bf00e | ||
|
|
876e85e7ad | ||
|
|
2e7818d688 | ||
|
|
836c4dda2b | ||
|
|
e65e9587b4 | ||
|
|
aaadd6ed04 |
94
.github/workflows/release.yml
vendored
94
.github/workflows/release.yml
vendored
@@ -85,6 +85,19 @@ jobs:
|
|||||||
go-version: '1.24'
|
go-version: '1.24'
|
||||||
cache-dependency-path: backend/go.sum
|
cache-dependency-path: backend/go.sum
|
||||||
|
|
||||||
|
# Docker setup for GoReleaser
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Fetch tags with annotations
|
- name: Fetch tags with annotations
|
||||||
run: |
|
run: |
|
||||||
# 确保获取完整的 annotated tag 信息
|
# 确保获取完整的 annotated tag 信息
|
||||||
@@ -117,87 +130,16 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAG_MESSAGE: ${{ steps.tag_message.outputs.message }}
|
TAG_MESSAGE: ${{ steps.tag_message.outputs.message }}
|
||||||
|
GITHUB_REPO_OWNER: ${{ github.repository_owner }}
|
||||||
|
GITHUB_REPO_NAME: ${{ github.event.repository.name }}
|
||||||
|
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
|
||||||
# ===========================================================================
|
# Update DockerHub description
|
||||||
# Docker Build and Push
|
|
||||||
# ===========================================================================
|
|
||||||
docker:
|
|
||||||
needs: [update-version, build-frontend]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Download VERSION artifact
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: version-file
|
|
||||||
path: backend/cmd/server/
|
|
||||||
|
|
||||||
- name: Download frontend artifact
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: frontend-dist
|
|
||||||
path: backend/internal/web/dist/
|
|
||||||
|
|
||||||
# Extract version from tag
|
|
||||||
- name: Extract version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
VERSION=${GITHUB_REF#refs/tags/v}
|
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
echo "Version: $VERSION"
|
|
||||||
|
|
||||||
# Set up Docker Buildx for multi-platform builds
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
# Login to DockerHub
|
|
||||||
- name: Login to DockerHub
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
# Extract metadata for Docker
|
|
||||||
- name: Extract Docker metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
weishaw/sub2api
|
|
||||||
tags: |
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
|
||||||
|
|
||||||
# Build and push Docker image
|
|
||||||
- name: Build and push Docker image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
build-args: |
|
|
||||||
VERSION=${{ steps.version.outputs.version }}
|
|
||||||
COMMIT=${{ github.sha }}
|
|
||||||
DATE=${{ github.event.head_commit.timestamp }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
# Update DockerHub description (optional)
|
|
||||||
- name: Update DockerHub description
|
- name: Update DockerHub description
|
||||||
uses: peter-evans/dockerhub-description@v4
|
uses: peter-evans/dockerhub-description@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
repository: weishaw/sub2api
|
repository: ${{ secrets.DOCKERHUB_USERNAME }}/sub2api
|
||||||
short-description: "Sub2API - AI API Gateway Platform"
|
short-description: "Sub2API - AI API Gateway Platform"
|
||||||
readme-filepath: ./deploy/DOCKER.md
|
readme-filepath: ./deploy/DOCKER.md
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -92,6 +92,13 @@ backend/internal/web/dist/*
|
|||||||
# 后端运行时缓存数据
|
# 后端运行时缓存数据
|
||||||
backend/data/
|
backend/data/
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# 本地配置文件(包含敏感信息)
|
||||||
|
# ===================
|
||||||
|
backend/config.yaml
|
||||||
|
deploy/config.yaml
|
||||||
|
backend/.installed
|
||||||
|
|
||||||
# ===================
|
# ===================
|
||||||
# 其他
|
# 其他
|
||||||
# ===================
|
# ===================
|
||||||
|
|||||||
@@ -52,10 +52,58 @@ changelog:
|
|||||||
# 禁用自动 changelog,完全使用 tag 消息
|
# 禁用自动 changelog,完全使用 tag 消息
|
||||||
disable: true
|
disable: true
|
||||||
|
|
||||||
|
# Docker images
|
||||||
|
dockers:
|
||||||
|
- id: amd64
|
||||||
|
goos: linux
|
||||||
|
goarch: amd64
|
||||||
|
image_templates:
|
||||||
|
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-amd64"
|
||||||
|
dockerfile: Dockerfile.goreleaser
|
||||||
|
use: buildx
|
||||||
|
build_flag_templates:
|
||||||
|
- "--platform=linux/amd64"
|
||||||
|
- "--label=org.opencontainers.image.version={{ .Version }}"
|
||||||
|
- "--label=org.opencontainers.image.revision={{ .Commit }}"
|
||||||
|
|
||||||
|
- id: arm64
|
||||||
|
goos: linux
|
||||||
|
goarch: arm64
|
||||||
|
image_templates:
|
||||||
|
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-arm64"
|
||||||
|
dockerfile: Dockerfile.goreleaser
|
||||||
|
use: buildx
|
||||||
|
build_flag_templates:
|
||||||
|
- "--platform=linux/arm64"
|
||||||
|
- "--label=org.opencontainers.image.version={{ .Version }}"
|
||||||
|
- "--label=org.opencontainers.image.revision={{ .Commit }}"
|
||||||
|
|
||||||
|
# Docker manifests for multi-arch support
|
||||||
|
docker_manifests:
|
||||||
|
- name_template: "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}"
|
||||||
|
image_templates:
|
||||||
|
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-amd64"
|
||||||
|
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-arm64"
|
||||||
|
|
||||||
|
- name_template: "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:latest"
|
||||||
|
image_templates:
|
||||||
|
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-amd64"
|
||||||
|
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-arm64"
|
||||||
|
|
||||||
|
- name_template: "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Major }}.{{ .Minor }}"
|
||||||
|
image_templates:
|
||||||
|
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-amd64"
|
||||||
|
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-arm64"
|
||||||
|
|
||||||
|
- name_template: "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Major }}"
|
||||||
|
image_templates:
|
||||||
|
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-amd64"
|
||||||
|
- "{{ .Env.DOCKERHUB_USERNAME }}/sub2api:{{ .Version }}-arm64"
|
||||||
|
|
||||||
release:
|
release:
|
||||||
github:
|
github:
|
||||||
owner: Wei-Shaw
|
owner: "{{ .Env.GITHUB_REPO_OWNER }}"
|
||||||
name: sub2api
|
name: "{{ .Env.GITHUB_REPO_NAME }}"
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: auto
|
prerelease: auto
|
||||||
name_template: "Sub2API {{.Version}}"
|
name_template: "Sub2API {{.Version}}"
|
||||||
@@ -73,7 +121,7 @@ release:
|
|||||||
|
|
||||||
**One-line install (Linux):**
|
**One-line install (Linux):**
|
||||||
```bash
|
```bash
|
||||||
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash
|
curl -sSL https://raw.githubusercontent.com/{{ .Env.GITHUB_REPO_OWNER }}/{{ .Env.GITHUB_REPO_NAME }}/main/deploy/install.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
**Manual download:**
|
**Manual download:**
|
||||||
@@ -81,5 +129,5 @@ release:
|
|||||||
|
|
||||||
## 📚 Documentation
|
## 📚 Documentation
|
||||||
|
|
||||||
- [GitHub Repository](https://github.com/Wei-Shaw/sub2api)
|
- [GitHub Repository](https://github.com/{{ .Env.GITHUB_REPO_OWNER }}/{{ .Env.GITHUB_REPO_NAME }})
|
||||||
- [Installation Guide](https://github.com/Wei-Shaw/sub2api/blob/main/deploy/README.md)
|
- [Installation Guide](https://github.com/{{ .Env.GITHUB_REPO_OWNER }}/{{ .Env.GITHUB_REPO_NAME }}/blob/main/deploy/README.md)
|
||||||
|
|||||||
40
Dockerfile.goreleaser
Normal file
40
Dockerfile.goreleaser
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Sub2API Dockerfile for GoReleaser
|
||||||
|
# =============================================================================
|
||||||
|
# This Dockerfile is used by GoReleaser to build Docker images.
|
||||||
|
# It only packages the pre-built binary, no compilation needed.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
FROM alpine:3.19
|
||||||
|
|
||||||
|
LABEL maintainer="Wei-Shaw <github.com/Wei-Shaw>"
|
||||||
|
LABEL description="Sub2API - AI API Gateway Platform"
|
||||||
|
LABEL org.opencontainers.image.source="https://github.com/Wei-Shaw/sub2api"
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
ca-certificates \
|
||||||
|
tzdata \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1000 sub2api && \
|
||||||
|
adduser -u 1000 -G sub2api -s /bin/sh -D sub2api
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy pre-built binary from GoReleaser
|
||||||
|
COPY sub2api /app/sub2api
|
||||||
|
|
||||||
|
# Create data directory
|
||||||
|
RUN mkdir -p /app/data && chown -R sub2api:sub2api /app
|
||||||
|
|
||||||
|
USER sub2api
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:${SERVER_PORT:-8080}/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/sub2api"]
|
||||||
@@ -15,11 +15,11 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"sub2api/internal/handler"
|
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||||
"sub2api/internal/middleware"
|
"github.com/Wei-Shaw/sub2api/internal/middleware"
|
||||||
"sub2api/internal/setup"
|
"github.com/Wei-Shaw/sub2api/internal/setup"
|
||||||
"sub2api/internal/web"
|
"github.com/Wei-Shaw/sub2api/internal/web"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"sub2api/internal/handler"
|
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||||
"sub2api/internal/infrastructure"
|
"github.com/Wei-Shaw/sub2api/internal/infrastructure"
|
||||||
"sub2api/internal/repository"
|
"github.com/Wei-Shaw/sub2api/internal/repository"
|
||||||
"sub2api/internal/server"
|
"github.com/Wei-Shaw/sub2api/internal/server"
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
|||||||
@@ -8,17 +8,17 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/infrastructure"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/repository"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/server"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"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"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,7 +86,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, httpUpstream)
|
accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, httpUpstream)
|
||||||
concurrencyCache := repository.NewConcurrencyCache(client)
|
concurrencyCache := repository.NewConcurrencyCache(client)
|
||||||
concurrencyService := service.NewConcurrencyService(concurrencyCache)
|
concurrencyService := service.NewConcurrencyService(concurrencyCache)
|
||||||
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService)
|
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService)
|
||||||
|
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService)
|
||||||
oAuthHandler := admin.NewOAuthHandler(oAuthService)
|
oAuthHandler := admin.NewOAuthHandler(oAuthService)
|
||||||
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)
|
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)
|
||||||
proxyHandler := admin.NewProxyHandler(adminService)
|
proxyHandler := admin.NewProxyHandler(adminService)
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
server:
|
|
||||||
host: "0.0.0.0"
|
|
||||||
port: 8080
|
|
||||||
mode: "debug" # debug/release
|
|
||||||
|
|
||||||
database:
|
|
||||||
host: "127.0.0.1"
|
|
||||||
port: 5432
|
|
||||||
user: "postgres"
|
|
||||||
password: "XZeRr7nkjHWhm8fw"
|
|
||||||
dbname: "sub2api"
|
|
||||||
sslmode: "disable"
|
|
||||||
|
|
||||||
redis:
|
|
||||||
host: "127.0.0.1"
|
|
||||||
port: 6379
|
|
||||||
password: ""
|
|
||||||
db: 0
|
|
||||||
|
|
||||||
jwt:
|
|
||||||
secret: "your-secret-key-change-in-production"
|
|
||||||
expire_hour: 24
|
|
||||||
|
|
||||||
default:
|
|
||||||
admin_email: "admin@sub2api.com"
|
|
||||||
admin_password: "admin123"
|
|
||||||
user_concurrency: 5
|
|
||||||
user_balance: 0
|
|
||||||
api_key_prefix: "sk-"
|
|
||||||
rate_multiplier: 1.0
|
|
||||||
|
|
||||||
# Timezone configuration (similar to PHP's date_default_timezone_set)
|
|
||||||
# This affects ALL time operations:
|
|
||||||
# - Database timestamps
|
|
||||||
# - Usage statistics "today" boundary
|
|
||||||
# - Subscription expiry times
|
|
||||||
# Common values: Asia/Shanghai, America/New_York, Europe/London, UTC
|
|
||||||
timezone: "Asia/Shanghai"
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
module sub2api
|
module github.com/Wei-Shaw/sub2api
|
||||||
|
|
||||||
go 1.24.0
|
go 1.24.0
|
||||||
|
|
||||||
@@ -8,10 +8,13 @@ require (
|
|||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/google/wire v0.7.0
|
||||||
github.com/imroc/req/v3 v3.56.0
|
github.com/imroc/req/v3 v3.56.0
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
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
|
||||||
|
github.com/tidwall/gjson v1.18.0
|
||||||
|
github.com/tidwall/sjson v1.2.5
|
||||||
golang.org/x/crypto v0.44.0
|
golang.org/x/crypto v0.44.0
|
||||||
golang.org/x/net v0.47.0
|
golang.org/x/net v0.47.0
|
||||||
golang.org/x/term v0.37.0
|
golang.org/x/term v0.37.0
|
||||||
@@ -35,7 +38,6 @@ require (
|
|||||||
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/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
|
||||||
@@ -64,6 +66,8 @@ require (
|
|||||||
github.com/spf13/cast v1.6.0 // indirect
|
github.com/spf13/cast v1.6.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
|
github.com/tidwall/pretty v1.2.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
go.uber.org/atomic v1.9.0 // indirect
|
go.uber.org/atomic v1.9.0 // indirect
|
||||||
|
|||||||
@@ -139,6 +139,15 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
|
|||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||||
|
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
|
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||||
|
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ package admin
|
|||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/claude"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||||
"sub2api/internal/pkg/openai"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||||
"sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
"sub2api/internal/pkg/timezone"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -34,10 +34,20 @@ type AccountHandler struct {
|
|||||||
accountUsageService *service.AccountUsageService
|
accountUsageService *service.AccountUsageService
|
||||||
accountTestService *service.AccountTestService
|
accountTestService *service.AccountTestService
|
||||||
concurrencyService *service.ConcurrencyService
|
concurrencyService *service.ConcurrencyService
|
||||||
|
crsSyncService *service.CRSSyncService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAccountHandler creates a new admin account handler
|
// NewAccountHandler creates a new admin account handler
|
||||||
func NewAccountHandler(adminService service.AdminService, oauthService *service.OAuthService, openaiOAuthService *service.OpenAIOAuthService, rateLimitService *service.RateLimitService, accountUsageService *service.AccountUsageService, accountTestService *service.AccountTestService, concurrencyService *service.ConcurrencyService) *AccountHandler {
|
func NewAccountHandler(
|
||||||
|
adminService service.AdminService,
|
||||||
|
oauthService *service.OAuthService,
|
||||||
|
openaiOAuthService *service.OpenAIOAuthService,
|
||||||
|
rateLimitService *service.RateLimitService,
|
||||||
|
accountUsageService *service.AccountUsageService,
|
||||||
|
accountTestService *service.AccountTestService,
|
||||||
|
concurrencyService *service.ConcurrencyService,
|
||||||
|
crsSyncService *service.CRSSyncService,
|
||||||
|
) *AccountHandler {
|
||||||
return &AccountHandler{
|
return &AccountHandler{
|
||||||
adminService: adminService,
|
adminService: adminService,
|
||||||
oauthService: oauthService,
|
oauthService: oauthService,
|
||||||
@@ -46,6 +56,7 @@ func NewAccountHandler(adminService service.AdminService, oauthService *service.
|
|||||||
accountUsageService: accountUsageService,
|
accountUsageService: accountUsageService,
|
||||||
accountTestService: accountTestService,
|
accountTestService: accountTestService,
|
||||||
concurrencyService: concurrencyService,
|
concurrencyService: concurrencyService,
|
||||||
|
crsSyncService: crsSyncService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +87,19 @@ type UpdateAccountRequest struct {
|
|||||||
GroupIDs *[]int64 `json:"group_ids"`
|
GroupIDs *[]int64 `json:"group_ids"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BulkUpdateAccountsRequest represents the payload for bulk editing accounts
|
||||||
|
type BulkUpdateAccountsRequest struct {
|
||||||
|
AccountIDs []int64 `json:"account_ids" binding:"required,min=1"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ProxyID *int64 `json:"proxy_id"`
|
||||||
|
Concurrency *int `json:"concurrency"`
|
||||||
|
Priority *int `json:"priority"`
|
||||||
|
Status string `json:"status" binding:"omitempty,oneof=active inactive error"`
|
||||||
|
GroupIDs *[]int64 `json:"group_ids"`
|
||||||
|
Credentials map[string]any `json:"credentials"`
|
||||||
|
Extra map[string]any `json:"extra"`
|
||||||
|
}
|
||||||
|
|
||||||
// AccountWithConcurrency extends Account with real-time concurrency info
|
// AccountWithConcurrency extends Account with real-time concurrency info
|
||||||
type AccountWithConcurrency struct {
|
type AccountWithConcurrency struct {
|
||||||
*model.Account
|
*model.Account
|
||||||
@@ -224,6 +248,13 @@ type TestAccountRequest struct {
|
|||||||
ModelID string `json:"model_id"`
|
ModelID string `json:"model_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SyncFromCRSRequest struct {
|
||||||
|
BaseURL string `json:"base_url" binding:"required"`
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
SyncProxies *bool `json:"sync_proxies"`
|
||||||
|
}
|
||||||
|
|
||||||
// Test handles testing account connectivity with SSE streaming
|
// Test handles testing account connectivity with SSE streaming
|
||||||
// POST /api/v1/admin/accounts/:id/test
|
// POST /api/v1/admin/accounts/:id/test
|
||||||
func (h *AccountHandler) Test(c *gin.Context) {
|
func (h *AccountHandler) Test(c *gin.Context) {
|
||||||
@@ -244,6 +275,35 @@ func (h *AccountHandler) Test(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SyncFromCRS handles syncing accounts from claude-relay-service (CRS)
|
||||||
|
// POST /api/v1/admin/accounts/sync/crs
|
||||||
|
func (h *AccountHandler) SyncFromCRS(c *gin.Context) {
|
||||||
|
var req SyncFromCRSRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to syncing proxies (can be disabled by explicitly setting false)
|
||||||
|
syncProxies := true
|
||||||
|
if req.SyncProxies != nil {
|
||||||
|
syncProxies = *req.SyncProxies
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.crsSyncService.SyncFromCRS(c.Request.Context(), service.SyncFromCRSInput{
|
||||||
|
BaseURL: req.BaseURL,
|
||||||
|
Username: req.Username,
|
||||||
|
Password: req.Password,
|
||||||
|
SyncProxies: syncProxies,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Sync failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
// Refresh handles refreshing account credentials
|
// Refresh handles refreshing account credentials
|
||||||
// POST /api/v1/admin/accounts/:id/refresh
|
// POST /api/v1/admin/accounts/:id/refresh
|
||||||
func (h *AccountHandler) Refresh(c *gin.Context) {
|
func (h *AccountHandler) Refresh(c *gin.Context) {
|
||||||
@@ -387,6 +447,136 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BatchUpdateCredentialsRequest represents batch credentials update request
|
||||||
|
type BatchUpdateCredentialsRequest struct {
|
||||||
|
AccountIDs []int64 `json:"account_ids" binding:"required,min=1"`
|
||||||
|
Field string `json:"field" binding:"required,oneof=account_uuid org_uuid intercept_warmup_requests"`
|
||||||
|
Value any `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchUpdateCredentials handles batch updating credentials fields
|
||||||
|
// POST /api/v1/admin/accounts/batch-update-credentials
|
||||||
|
func (h *AccountHandler) BatchUpdateCredentials(c *gin.Context) {
|
||||||
|
var req BatchUpdateCredentialsRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate value type based on field
|
||||||
|
if req.Field == "intercept_warmup_requests" {
|
||||||
|
// Must be boolean
|
||||||
|
if _, ok := req.Value.(bool); !ok {
|
||||||
|
response.BadRequest(c, "intercept_warmup_requests must be boolean")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// account_uuid and org_uuid can be string or null
|
||||||
|
if req.Value != nil {
|
||||||
|
if _, ok := req.Value.(string); !ok {
|
||||||
|
response.BadRequest(c, req.Field+" must be string or null")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
success := 0
|
||||||
|
failed := 0
|
||||||
|
results := []gin.H{}
|
||||||
|
|
||||||
|
for _, accountID := range req.AccountIDs {
|
||||||
|
// Get account
|
||||||
|
account, err := h.adminService.GetAccount(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
failed++
|
||||||
|
results = append(results, gin.H{
|
||||||
|
"account_id": accountID,
|
||||||
|
"success": false,
|
||||||
|
"error": "Account not found",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update credentials field
|
||||||
|
if account.Credentials == nil {
|
||||||
|
account.Credentials = make(map[string]any)
|
||||||
|
}
|
||||||
|
|
||||||
|
account.Credentials[req.Field] = req.Value
|
||||||
|
|
||||||
|
// Update account
|
||||||
|
updateInput := &service.UpdateAccountInput{
|
||||||
|
Credentials: account.Credentials,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.adminService.UpdateAccount(ctx, accountID, updateInput)
|
||||||
|
if err != nil {
|
||||||
|
failed++
|
||||||
|
results = append(results, gin.H{
|
||||||
|
"account_id": accountID,
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
success++
|
||||||
|
results = append(results, gin.H{
|
||||||
|
"account_id": accountID,
|
||||||
|
"success": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, gin.H{
|
||||||
|
"success": success,
|
||||||
|
"failed": failed,
|
||||||
|
"results": results,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkUpdate handles bulk updating accounts with selected fields/credentials.
|
||||||
|
// POST /api/v1/admin/accounts/bulk-update
|
||||||
|
func (h *AccountHandler) BulkUpdate(c *gin.Context) {
|
||||||
|
var req BulkUpdateAccountsRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hasUpdates := req.Name != "" ||
|
||||||
|
req.ProxyID != nil ||
|
||||||
|
req.Concurrency != nil ||
|
||||||
|
req.Priority != nil ||
|
||||||
|
req.Status != "" ||
|
||||||
|
req.GroupIDs != nil ||
|
||||||
|
len(req.Credentials) > 0 ||
|
||||||
|
len(req.Extra) > 0
|
||||||
|
|
||||||
|
if !hasUpdates {
|
||||||
|
response.BadRequest(c, "No updates provided")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.adminService.BulkUpdateAccounts(c.Request.Context(), &service.BulkUpdateAccountsInput{
|
||||||
|
AccountIDs: req.AccountIDs,
|
||||||
|
Name: req.Name,
|
||||||
|
ProxyID: req.ProxyID,
|
||||||
|
Concurrency: req.Concurrency,
|
||||||
|
Priority: req.Priority,
|
||||||
|
Status: req.Status,
|
||||||
|
GroupIDs: req.GroupIDs,
|
||||||
|
Credentials: req.Credentials,
|
||||||
|
Extra: req.Extra,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.InternalError(c, "Failed to bulk update accounts: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
// ========== OAuth Handlers ==========
|
// ========== OAuth Handlers ==========
|
||||||
|
|
||||||
// GenerateAuthURLRequest represents the request for generating auth URL
|
// GenerateAuthURLRequest represents the request for generating auth URL
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sub2api/internal/pkg/response"
|
|
||||||
"sub2api/internal/pkg/timezone"
|
|
||||||
"sub2api/internal/service"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -107,6 +107,10 @@ func (h *DashboardHandler) GetStats(c *gin.Context) {
|
|||||||
// 系统运行统计
|
// 系统运行统计
|
||||||
"average_duration_ms": stats.AverageDurationMs,
|
"average_duration_ms": stats.AverageDurationMs,
|
||||||
"uptime": uptime,
|
"uptime": uptime,
|
||||||
|
|
||||||
|
// 性能指标
|
||||||
|
"rpm": stats.Rpm,
|
||||||
|
"tpm": stats.Tpm,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package admin
|
|||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package admin
|
|||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -60,6 +60,7 @@ type UpdateSettingsRequest struct {
|
|||||||
SiteSubtitle string `json:"site_subtitle"`
|
SiteSubtitle string `json:"site_subtitle"`
|
||||||
ApiBaseUrl string `json:"api_base_url"`
|
ApiBaseUrl string `json:"api_base_url"`
|
||||||
ContactInfo string `json:"contact_info"`
|
ContactInfo string `json:"contact_info"`
|
||||||
|
DocUrl string `json:"doc_url"`
|
||||||
|
|
||||||
// 默认配置
|
// 默认配置
|
||||||
DefaultConcurrency int `json:"default_concurrency"`
|
DefaultConcurrency int `json:"default_concurrency"`
|
||||||
@@ -104,6 +105,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
SiteSubtitle: req.SiteSubtitle,
|
SiteSubtitle: req.SiteSubtitle,
|
||||||
ApiBaseUrl: req.ApiBaseUrl,
|
ApiBaseUrl: req.ApiBaseUrl,
|
||||||
ContactInfo: req.ContactInfo,
|
ContactInfo: req.ContactInfo,
|
||||||
|
DocUrl: req.DocUrl,
|
||||||
DefaultConcurrency: req.DefaultConcurrency,
|
DefaultConcurrency: req.DefaultConcurrency,
|
||||||
DefaultBalance: req.DefaultBalance,
|
DefaultBalance: req.DefaultBalance,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ package admin
|
|||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
"sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
"sub2api/internal/pkg/sysutil"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/sysutil"
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
"sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
"sub2api/internal/pkg/timezone"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||||
"sub2api/internal/pkg/usagestats"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package admin
|
|||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
"sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/middleware"
|
"github.com/Wei-Shaw/sub2api/internal/middleware"
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/claude"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||||
"sub2api/internal/pkg/openai"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sub2api/internal/handler/admin"
|
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdminHandlers contains all admin-related HTTP handlers
|
// AdminHandlers contains all admin-related HTTP handlers
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/middleware"
|
"github.com/Wei-Shaw/sub2api/internal/middleware"
|
||||||
"sub2api/internal/pkg/openai"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
"sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
"sub2api/internal/pkg/timezone"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sub2api/internal/handler/admin"
|
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/google/wire"
|
"github.com/google/wire"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package infrastructure
|
package infrastructure
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/timezone"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||||
|
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package infrastructure
|
package infrastructure
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package infrastructure
|
package infrastructure
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
|
|
||||||
"github.com/google/wire"
|
"github.com/google/wire"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
"strings"
|
"strings"
|
||||||
"sub2api/internal/model"
|
|
||||||
"sub2api/internal/service"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
"sub2api/internal/model"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
"strings"
|
"strings"
|
||||||
"sub2api/internal/model"
|
|
||||||
"sub2api/internal/service"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const (
|
|||||||
SettingKeySiteSubtitle = "site_subtitle" // 网站副标题
|
SettingKeySiteSubtitle = "site_subtitle" // 网站副标题
|
||||||
SettingKeyApiBaseUrl = "api_base_url" // API端点地址(用于客户端配置和导入)
|
SettingKeyApiBaseUrl = "api_base_url" // API端点地址(用于客户端配置和导入)
|
||||||
SettingKeyContactInfo = "contact_info" // 客服联系方式
|
SettingKeyContactInfo = "contact_info" // 客服联系方式
|
||||||
|
SettingKeyDocUrl = "doc_url" // 文档链接
|
||||||
|
|
||||||
// 默认配置
|
// 默认配置
|
||||||
SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量
|
SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量
|
||||||
@@ -80,6 +81,7 @@ type SystemSettings struct {
|
|||||||
SiteSubtitle string `json:"site_subtitle"`
|
SiteSubtitle string `json:"site_subtitle"`
|
||||||
ApiBaseUrl string `json:"api_base_url"`
|
ApiBaseUrl string `json:"api_base_url"`
|
||||||
ContactInfo string `json:"contact_info"`
|
ContactInfo string `json:"contact_info"`
|
||||||
|
DocUrl string `json:"doc_url"`
|
||||||
|
|
||||||
// 默认配置
|
// 默认配置
|
||||||
DefaultConcurrency int `json:"default_concurrency"`
|
DefaultConcurrency int `json:"default_concurrency"`
|
||||||
@@ -97,5 +99,6 @@ type PublicSettings struct {
|
|||||||
SiteSubtitle string `json:"site_subtitle"`
|
SiteSubtitle string `json:"site_subtitle"`
|
||||||
ApiBaseUrl string `json:"api_base_url"`
|
ApiBaseUrl string `json:"api_base_url"`
|
||||||
ContactInfo string `json:"contact_info"`
|
ContactInfo string `json:"contact_info"`
|
||||||
|
DocUrl string `json:"doc_url"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ type DashboardStats struct {
|
|||||||
AverageDurationMs float64 `json:"average_duration_ms"` // 平均响应时间
|
AverageDurationMs float64 `json:"average_duration_ms"` // 平均响应时间
|
||||||
|
|
||||||
// 性能指标
|
// 性能指标
|
||||||
Rpm int64 `json:"rpm"` // 最近1分钟的请求数
|
Rpm int64 `json:"rpm"` // 近5分钟平均每分钟请求数
|
||||||
Tpm int64 `json:"tpm"` // 最近1分钟的Token数
|
Tpm int64 `json:"tpm"` // 近5分钟平均每分钟Token数
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrendDataPoint represents a single point in trend data
|
// TrendDataPoint represents a single point in trend data
|
||||||
@@ -121,8 +121,8 @@ type UserDashboardStats struct {
|
|||||||
AverageDurationMs float64 `json:"average_duration_ms"`
|
AverageDurationMs float64 `json:"average_duration_ms"`
|
||||||
|
|
||||||
// 性能指标
|
// 性能指标
|
||||||
Rpm int64 `json:"rpm"` // 最近1分钟的请求数
|
Rpm int64 `json:"rpm"` // 近5分钟平均每分钟请求数
|
||||||
Tpm int64 `json:"tpm"` // 最近1分钟的Token数
|
Tpm int64 `json:"tpm"` // 近5分钟平均每分钟Token数
|
||||||
}
|
}
|
||||||
|
|
||||||
// UsageLogFilters represents filters for usage log queries
|
// UsageLogFilters represents filters for usage log queries
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sub2api/internal/model"
|
"errors"
|
||||||
"sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AccountRepository struct {
|
type AccountRepository struct {
|
||||||
@@ -39,6 +42,22 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int64) (*model.Accou
|
|||||||
return &account, nil
|
return &account, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *AccountRepository) GetByCRSAccountID(ctx context.Context, crsAccountID string) (*model.Account, error) {
|
||||||
|
if crsAccountID == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var account model.Account
|
||||||
|
err := r.db.WithContext(ctx).Where("extra->>'crs_account_id' = ?", crsAccountID).First(&account).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &account, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *AccountRepository) Update(ctx context.Context, account *model.Account) error {
|
func (r *AccountRepository) Update(ctx context.Context, account *model.Account) error {
|
||||||
return r.db.WithContext(ctx).Save(account).Error
|
return r.db.WithContext(ctx).Save(account).Error
|
||||||
}
|
}
|
||||||
@@ -335,3 +354,47 @@ func (r *AccountRepository) UpdateExtra(ctx context.Context, id int64, updates m
|
|||||||
return r.db.WithContext(ctx).Model(&model.Account{}).Where("id = ?", id).
|
return r.db.WithContext(ctx).Model(&model.Account{}).Where("id = ?", id).
|
||||||
Update("extra", account.Extra).Error
|
Update("extra", account.Extra).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BulkUpdate updates multiple accounts with the provided fields.
|
||||||
|
// It merges credentials/extra JSONB fields instead of overwriting them.
|
||||||
|
func (r *AccountRepository) BulkUpdate(ctx context.Context, ids []int64, updates ports.AccountBulkUpdate) (int64, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMap := map[string]any{}
|
||||||
|
|
||||||
|
if updates.Name != nil {
|
||||||
|
updateMap["name"] = *updates.Name
|
||||||
|
}
|
||||||
|
if updates.ProxyID != nil {
|
||||||
|
updateMap["proxy_id"] = updates.ProxyID
|
||||||
|
}
|
||||||
|
if updates.Concurrency != nil {
|
||||||
|
updateMap["concurrency"] = *updates.Concurrency
|
||||||
|
}
|
||||||
|
if updates.Priority != nil {
|
||||||
|
updateMap["priority"] = *updates.Priority
|
||||||
|
}
|
||||||
|
if updates.Status != nil {
|
||||||
|
updateMap["status"] = *updates.Status
|
||||||
|
}
|
||||||
|
if len(updates.Credentials) > 0 {
|
||||||
|
updateMap["credentials"] = gorm.Expr("COALESCE(credentials,'{}') || ?", updates.Credentials)
|
||||||
|
}
|
||||||
|
if len(updates.Extra) > 0 {
|
||||||
|
updateMap["extra"] = gorm.Expr("COALESCE(extra,'{}') || ?", updates.Extra)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(updateMap) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := r.db.WithContext(ctx).
|
||||||
|
Model(&model.Account{}).
|
||||||
|
Where("id IN ?", ids).
|
||||||
|
Clauses(clause.Returning{}).
|
||||||
|
Updates(updateMap)
|
||||||
|
|
||||||
|
return result.RowsAffected, result.Error
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/service/ports"
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/service/ports"
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/pkg/oauth"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"github.com/imroc/req/v3"
|
"github.com/imroc/req/v3"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
type claudeUsageService struct{}
|
type claudeUsageService struct{}
|
||||||
|
|||||||
@@ -5,60 +5,95 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/service/ports"
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
accountConcurrencyKeyPrefix = "concurrency:account:"
|
// Key prefixes for independent slot keys
|
||||||
userConcurrencyKeyPrefix = "concurrency:user:"
|
// Format: concurrency:account:{accountID}:{requestID}
|
||||||
waitQueueKeyPrefix = "concurrency:wait:"
|
accountSlotKeyPrefix = "concurrency:account:"
|
||||||
concurrencyTTL = 5 * time.Minute
|
// Format: concurrency:user:{userID}:{requestID}
|
||||||
|
userSlotKeyPrefix = "concurrency:user:"
|
||||||
|
// Wait queue keeps counter format: concurrency:wait:{userID}
|
||||||
|
waitQueueKeyPrefix = "concurrency:wait:"
|
||||||
|
|
||||||
|
// Slot TTL - each slot expires independently
|
||||||
|
slotTTL = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
// acquireScript uses SCAN to count existing slots and creates new slot if under limit
|
||||||
|
// KEYS[1] = pattern for SCAN (e.g., "concurrency:account:2:*")
|
||||||
|
// KEYS[2] = full slot key (e.g., "concurrency:account:2:req_xxx")
|
||||||
|
// ARGV[1] = maxConcurrency
|
||||||
|
// ARGV[2] = TTL in seconds
|
||||||
acquireScript = redis.NewScript(`
|
acquireScript = redis.NewScript(`
|
||||||
local current = redis.call('GET', KEYS[1])
|
local pattern = KEYS[1]
|
||||||
if current == false then
|
local slotKey = KEYS[2]
|
||||||
current = 0
|
local maxConcurrency = tonumber(ARGV[1])
|
||||||
else
|
local ttl = tonumber(ARGV[2])
|
||||||
current = tonumber(current)
|
|
||||||
end
|
-- Count existing slots using SCAN
|
||||||
if current < tonumber(ARGV[1]) then
|
local cursor = "0"
|
||||||
redis.call('INCR', KEYS[1])
|
local count = 0
|
||||||
redis.call('EXPIRE', KEYS[1], ARGV[2])
|
repeat
|
||||||
|
local result = redis.call('SCAN', cursor, 'MATCH', pattern, 'COUNT', 100)
|
||||||
|
cursor = result[1]
|
||||||
|
count = count + #result[2]
|
||||||
|
until cursor == "0"
|
||||||
|
|
||||||
|
-- Check if we can acquire a slot
|
||||||
|
if count < maxConcurrency then
|
||||||
|
redis.call('SET', slotKey, '1', 'EX', ttl)
|
||||||
return 1
|
return 1
|
||||||
end
|
end
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
`)
|
`)
|
||||||
|
|
||||||
releaseScript = redis.NewScript(`
|
// getCountScript counts slots using SCAN
|
||||||
local current = redis.call('GET', KEYS[1])
|
// KEYS[1] = pattern for SCAN
|
||||||
if current ~= false and tonumber(current) > 0 then
|
getCountScript = redis.NewScript(`
|
||||||
redis.call('DECR', KEYS[1])
|
local pattern = KEYS[1]
|
||||||
end
|
local cursor = "0"
|
||||||
return 1
|
local count = 0
|
||||||
|
repeat
|
||||||
|
local result = redis.call('SCAN', cursor, 'MATCH', pattern, 'COUNT', 100)
|
||||||
|
cursor = result[1]
|
||||||
|
count = count + #result[2]
|
||||||
|
until cursor == "0"
|
||||||
|
return count
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
// incrementWaitScript - only sets TTL on first creation to avoid refreshing
|
||||||
|
// KEYS[1] = wait queue key
|
||||||
|
// ARGV[1] = maxWait
|
||||||
|
// ARGV[2] = TTL in seconds
|
||||||
incrementWaitScript = redis.NewScript(`
|
incrementWaitScript = redis.NewScript(`
|
||||||
local waitKey = KEYS[1]
|
local current = redis.call('GET', KEYS[1])
|
||||||
local maxWait = tonumber(ARGV[1])
|
|
||||||
local ttl = tonumber(ARGV[2])
|
|
||||||
local current = redis.call('GET', waitKey)
|
|
||||||
if current == false then
|
if current == false then
|
||||||
current = 0
|
current = 0
|
||||||
else
|
else
|
||||||
current = tonumber(current)
|
current = tonumber(current)
|
||||||
end
|
end
|
||||||
if current >= maxWait then
|
|
||||||
|
if current >= tonumber(ARGV[1]) then
|
||||||
return 0
|
return 0
|
||||||
end
|
end
|
||||||
redis.call('INCR', waitKey)
|
|
||||||
redis.call('EXPIRE', waitKey, ttl)
|
local newVal = redis.call('INCR', KEYS[1])
|
||||||
|
|
||||||
|
-- Only set TTL on first creation to avoid refreshing zombie data
|
||||||
|
if newVal == 1 then
|
||||||
|
redis.call('EXPIRE', KEYS[1], ARGV[2])
|
||||||
|
end
|
||||||
|
|
||||||
return 1
|
return 1
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
// decrementWaitScript - same as before
|
||||||
decrementWaitScript = redis.NewScript(`
|
decrementWaitScript = redis.NewScript(`
|
||||||
local current = redis.call('GET', KEYS[1])
|
local current = redis.call('GET', KEYS[1])
|
||||||
if current ~= false and tonumber(current) > 0 then
|
if current ~= false and tonumber(current) > 0 then
|
||||||
@@ -76,49 +111,86 @@ func NewConcurrencyCache(rdb *redis.Client) ports.ConcurrencyCache {
|
|||||||
return &concurrencyCache{rdb: rdb}
|
return &concurrencyCache{rdb: rdb}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *concurrencyCache) AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int) (bool, error) {
|
// Helper functions for key generation
|
||||||
key := fmt.Sprintf("%s%d", accountConcurrencyKeyPrefix, accountID)
|
func accountSlotKey(accountID int64, requestID string) string {
|
||||||
result, err := acquireScript.Run(ctx, c.rdb, []string{key}, maxConcurrency, int(concurrencyTTL.Seconds())).Int()
|
return fmt.Sprintf("%s%d:%s", accountSlotKeyPrefix, accountID, requestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func accountSlotPattern(accountID int64) string {
|
||||||
|
return fmt.Sprintf("%s%d:*", accountSlotKeyPrefix, accountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func userSlotKey(userID int64, requestID string) string {
|
||||||
|
return fmt.Sprintf("%s%d:%s", userSlotKeyPrefix, userID, requestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func userSlotPattern(userID int64) string {
|
||||||
|
return fmt.Sprintf("%s%d:*", userSlotKeyPrefix, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitQueueKey(userID int64) string {
|
||||||
|
return fmt.Sprintf("%s%d", waitQueueKeyPrefix, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account slot operations
|
||||||
|
|
||||||
|
func (c *concurrencyCache) AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) {
|
||||||
|
pattern := accountSlotPattern(accountID)
|
||||||
|
slotKey := accountSlotKey(accountID, requestID)
|
||||||
|
|
||||||
|
result, err := acquireScript.Run(ctx, c.rdb, []string{pattern, slotKey}, maxConcurrency, int(slotTTL.Seconds())).Int()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
return result == 1, nil
|
return result == 1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *concurrencyCache) ReleaseAccountSlot(ctx context.Context, accountID int64) error {
|
func (c *concurrencyCache) ReleaseAccountSlot(ctx context.Context, accountID int64, requestID string) error {
|
||||||
key := fmt.Sprintf("%s%d", accountConcurrencyKeyPrefix, accountID)
|
slotKey := accountSlotKey(accountID, requestID)
|
||||||
_, err := releaseScript.Run(ctx, c.rdb, []string{key}).Result()
|
return c.rdb.Del(ctx, slotKey).Err()
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *concurrencyCache) GetAccountConcurrency(ctx context.Context, accountID int64) (int, error) {
|
func (c *concurrencyCache) GetAccountConcurrency(ctx context.Context, accountID int64) (int, error) {
|
||||||
key := fmt.Sprintf("%s%d", accountConcurrencyKeyPrefix, accountID)
|
pattern := accountSlotPattern(accountID)
|
||||||
return c.rdb.Get(ctx, key).Int()
|
result, err := getCountScript.Run(ctx, c.rdb, []string{pattern}).Int()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *concurrencyCache) AcquireUserSlot(ctx context.Context, userID int64, maxConcurrency int) (bool, error) {
|
// User slot operations
|
||||||
key := fmt.Sprintf("%s%d", userConcurrencyKeyPrefix, userID)
|
|
||||||
result, err := acquireScript.Run(ctx, c.rdb, []string{key}, maxConcurrency, int(concurrencyTTL.Seconds())).Int()
|
func (c *concurrencyCache) AcquireUserSlot(ctx context.Context, userID int64, maxConcurrency int, requestID string) (bool, error) {
|
||||||
|
pattern := userSlotPattern(userID)
|
||||||
|
slotKey := userSlotKey(userID, requestID)
|
||||||
|
|
||||||
|
result, err := acquireScript.Run(ctx, c.rdb, []string{pattern, slotKey}, maxConcurrency, int(slotTTL.Seconds())).Int()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
return result == 1, nil
|
return result == 1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *concurrencyCache) ReleaseUserSlot(ctx context.Context, userID int64) error {
|
func (c *concurrencyCache) ReleaseUserSlot(ctx context.Context, userID int64, requestID string) error {
|
||||||
key := fmt.Sprintf("%s%d", userConcurrencyKeyPrefix, userID)
|
slotKey := userSlotKey(userID, requestID)
|
||||||
_, err := releaseScript.Run(ctx, c.rdb, []string{key}).Result()
|
return c.rdb.Del(ctx, slotKey).Err()
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *concurrencyCache) GetUserConcurrency(ctx context.Context, userID int64) (int, error) {
|
func (c *concurrencyCache) GetUserConcurrency(ctx context.Context, userID int64) (int, error) {
|
||||||
key := fmt.Sprintf("%s%d", userConcurrencyKeyPrefix, userID)
|
pattern := userSlotPattern(userID)
|
||||||
return c.rdb.Get(ctx, key).Int()
|
result, err := getCountScript.Run(ctx, c.rdb, []string{pattern}).Int()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait queue operations
|
||||||
|
|
||||||
func (c *concurrencyCache) IncrementWaitCount(ctx context.Context, userID int64, maxWait int) (bool, error) {
|
func (c *concurrencyCache) IncrementWaitCount(ctx context.Context, userID int64, maxWait int) (bool, error) {
|
||||||
key := fmt.Sprintf("%s%d", waitQueueKeyPrefix, userID)
|
key := waitQueueKey(userID)
|
||||||
result, err := incrementWaitScript.Run(ctx, c.rdb, []string{key}, maxWait, int(concurrencyTTL.Seconds())).Int()
|
result, err := incrementWaitScript.Run(ctx, c.rdb, []string{key}, maxWait, int(slotTTL.Seconds())).Int()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -126,7 +198,7 @@ func (c *concurrencyCache) IncrementWaitCount(ctx context.Context, userID int64,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *concurrencyCache) DecrementWaitCount(ctx context.Context, userID int64) error {
|
func (c *concurrencyCache) DecrementWaitCount(ctx context.Context, userID int64) error {
|
||||||
key := fmt.Sprintf("%s%d", waitQueueKeyPrefix, userID)
|
key := waitQueueKey(userID)
|
||||||
_, err := decrementWaitScript.Run(ctx, c.rdb, []string{key}).Result()
|
_, err := decrementWaitScript.Run(ctx, c.rdb, []string{key}).Result()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/service/ports"
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/service/ports"
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
type githubReleaseClient struct {
|
type githubReleaseClient struct {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"sub2api/internal/service/ports"
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
)
|
)
|
||||||
|
|
||||||
// httpUpstreamService is a generic HTTP upstream service that can be used for
|
// httpUpstreamService is a generic HTTP upstream service that can be used for
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/service/ports"
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/pkg/openai"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||||
"sub2api/internal/service/ports"
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
|
|
||||||
"github.com/imroc/req/v3"
|
"github.com/imroc/req/v3"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
type pricingRemoteClient struct {
|
type pricingRemoteClient struct {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
"golang.org/x/net/proxy"
|
"golang.org/x/net/proxy"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/service/ports"
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
const turnstileVerifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
const turnstileVerifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/service/ports"
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
"sub2api/internal/pkg/timezone"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||||
"sub2api/internal/pkg/usagestats"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -19,9 +19,9 @@ func NewUsageLogRepository(db *gorm.DB) *UsageLogRepository {
|
|||||||
return &UsageLogRepository{db: db}
|
return &UsageLogRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getPerformanceStats 获取 RPM 和 TPM(可选按用户过滤)
|
// getPerformanceStats 获取 RPM 和 TPM(近5分钟平均值,可选按用户过滤)
|
||||||
func (r *UsageLogRepository) getPerformanceStats(ctx context.Context, userID int64) (rpm, tpm int64) {
|
func (r *UsageLogRepository) getPerformanceStats(ctx context.Context, userID int64) (rpm, tpm int64) {
|
||||||
oneMinuteAgo := time.Now().Add(-1 * time.Minute)
|
fiveMinutesAgo := time.Now().Add(-5 * time.Minute)
|
||||||
var perfStats struct {
|
var perfStats struct {
|
||||||
RequestCount int64 `gorm:"column:request_count"`
|
RequestCount int64 `gorm:"column:request_count"`
|
||||||
TokenCount int64 `gorm:"column:token_count"`
|
TokenCount int64 `gorm:"column:token_count"`
|
||||||
@@ -32,14 +32,15 @@ func (r *UsageLogRepository) getPerformanceStats(ctx context.Context, userID int
|
|||||||
COUNT(*) as request_count,
|
COUNT(*) as request_count,
|
||||||
COALESCE(SUM(input_tokens + output_tokens), 0) as token_count
|
COALESCE(SUM(input_tokens + output_tokens), 0) as token_count
|
||||||
`).
|
`).
|
||||||
Where("created_at >= ?", oneMinuteAgo)
|
Where("created_at >= ?", fiveMinutesAgo)
|
||||||
|
|
||||||
if userID > 0 {
|
if userID > 0 {
|
||||||
db = db.Where("user_id = ?", userID)
|
db = db.Where("user_id = ?", userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
db.Scan(&perfStats)
|
db.Scan(&perfStats)
|
||||||
return perfStats.RequestCount, perfStats.TokenCount
|
// 返回5分钟平均值
|
||||||
|
return perfStats.RequestCount / 5, perfStats.TokenCount / 5
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *UsageLogRepository) Create(ctx context.Context, log *model.UsageLog) error {
|
func (r *UsageLogRepository) Create(ctx context.Context, log *model.UsageLog) error {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sub2api/internal/service/ports"
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
|
|
||||||
"github.com/google/wire"
|
"github.com/google/wire"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/repository"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sub2api/internal/config"
|
|
||||||
"sub2api/internal/handler"
|
|
||||||
"sub2api/internal/repository"
|
|
||||||
"sub2api/internal/service"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/middleware"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/repository"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/web"
|
||||||
"net/http"
|
"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"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -180,6 +180,7 @@ func registerRoutes(r *gin.Engine, h *handler.Handlers, s *service.Services, rep
|
|||||||
accounts.GET("", h.Admin.Account.List)
|
accounts.GET("", h.Admin.Account.List)
|
||||||
accounts.GET("/:id", h.Admin.Account.GetByID)
|
accounts.GET("/:id", h.Admin.Account.GetByID)
|
||||||
accounts.POST("", h.Admin.Account.Create)
|
accounts.POST("", h.Admin.Account.Create)
|
||||||
|
accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS)
|
||||||
accounts.PUT("/:id", h.Admin.Account.Update)
|
accounts.PUT("/:id", h.Admin.Account.Update)
|
||||||
accounts.DELETE("/:id", h.Admin.Account.Delete)
|
accounts.DELETE("/:id", h.Admin.Account.Delete)
|
||||||
accounts.POST("/:id/test", h.Admin.Account.Test)
|
accounts.POST("/:id/test", h.Admin.Account.Test)
|
||||||
@@ -192,6 +193,8 @@ func registerRoutes(r *gin.Engine, h *handler.Handlers, s *service.Services, rep
|
|||||||
accounts.POST("/:id/schedulable", h.Admin.Account.SetSchedulable)
|
accounts.POST("/:id/schedulable", h.Admin.Account.SetSchedulable)
|
||||||
accounts.GET("/:id/models", h.Admin.Account.GetAvailableModels)
|
accounts.GET("/:id/models", h.Admin.Account.GetAvailableModels)
|
||||||
accounts.POST("/batch", h.Admin.Account.BatchCreate)
|
accounts.POST("/batch", h.Admin.Account.BatchCreate)
|
||||||
|
accounts.POST("/batch-update-credentials", h.Admin.Account.BatchUpdateCredentials)
|
||||||
|
accounts.POST("/bulk-update", h.Admin.Account.BulkUpdate)
|
||||||
|
|
||||||
// Claude OAuth routes
|
// Claude OAuth routes
|
||||||
accounts.POST("/generate-auth-url", h.Admin.OAuth.GenerateAuthURL)
|
accounts.POST("/generate-auth-url", h.Admin.OAuth.GenerateAuthURL)
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
"sub2api/internal/service/ports"
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/claude"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||||
"sub2api/internal/pkg/openai"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||||
"sub2api/internal/service/ports"
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -388,12 +388,14 @@ func createOpenAITestPayload(modelID string, isOAuth bool) map[string]any {
|
|||||||
"stream": true,
|
"stream": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth accounts using ChatGPT internal API require store: false and instructions
|
// OAuth accounts using ChatGPT internal API require store: false
|
||||||
if isOAuth {
|
if isOAuth {
|
||||||
payload["store"] = false
|
payload["store"] = false
|
||||||
payload["instructions"] = openai.DefaultInstructions
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// All accounts require instructions for Responses API
|
||||||
|
payload["instructions"] = openai.DefaultInstructions
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/usagestats"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
||||||
"sub2api/internal/service/ports"
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
)
|
)
|
||||||
|
|
||||||
// usageCache 用于缓存usage数据
|
// usageCache 用于缓存usage数据
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
"sub2api/internal/service/ports"
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -45,6 +45,7 @@ type AdminService interface {
|
|||||||
RefreshAccountCredentials(ctx context.Context, id int64) (*model.Account, error)
|
RefreshAccountCredentials(ctx context.Context, id int64) (*model.Account, error)
|
||||||
ClearAccountError(ctx context.Context, id int64) (*model.Account, error)
|
ClearAccountError(ctx context.Context, id int64) (*model.Account, error)
|
||||||
SetAccountSchedulable(ctx context.Context, id int64, schedulable bool) (*model.Account, error)
|
SetAccountSchedulable(ctx context.Context, id int64, schedulable bool) (*model.Account, error)
|
||||||
|
BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error)
|
||||||
|
|
||||||
// Proxy management
|
// Proxy management
|
||||||
ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string) ([]model.Proxy, int64, error)
|
ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string) ([]model.Proxy, int64, error)
|
||||||
@@ -140,6 +141,33 @@ type UpdateAccountInput struct {
|
|||||||
GroupIDs *[]int64
|
GroupIDs *[]int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BulkUpdateAccountsInput describes the payload for bulk updating accounts.
|
||||||
|
type BulkUpdateAccountsInput struct {
|
||||||
|
AccountIDs []int64
|
||||||
|
Name string
|
||||||
|
ProxyID *int64
|
||||||
|
Concurrency *int
|
||||||
|
Priority *int
|
||||||
|
Status string
|
||||||
|
GroupIDs *[]int64
|
||||||
|
Credentials map[string]any
|
||||||
|
Extra map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkUpdateAccountResult captures the result for a single account update.
|
||||||
|
type BulkUpdateAccountResult struct {
|
||||||
|
AccountID int64 `json:"account_id"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkUpdateAccountsResult is the aggregated response for bulk updates.
|
||||||
|
type BulkUpdateAccountsResult struct {
|
||||||
|
Success int `json:"success"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Results []BulkUpdateAccountResult `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
type CreateProxyInput struct {
|
type CreateProxyInput struct {
|
||||||
Name string
|
Name string
|
||||||
Protocol string
|
Protocol string
|
||||||
@@ -694,6 +722,65 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
|||||||
return account, nil
|
return account, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BulkUpdateAccounts updates multiple accounts in one request.
|
||||||
|
// It merges credentials/extra keys instead of overwriting the whole object.
|
||||||
|
func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error) {
|
||||||
|
result := &BulkUpdateAccountsResult{
|
||||||
|
Results: make([]BulkUpdateAccountResult, 0, len(input.AccountIDs)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(input.AccountIDs) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare bulk updates for columns and JSONB fields.
|
||||||
|
repoUpdates := ports.AccountBulkUpdate{
|
||||||
|
Credentials: input.Credentials,
|
||||||
|
Extra: input.Extra,
|
||||||
|
}
|
||||||
|
if input.Name != "" {
|
||||||
|
repoUpdates.Name = &input.Name
|
||||||
|
}
|
||||||
|
if input.ProxyID != nil {
|
||||||
|
repoUpdates.ProxyID = input.ProxyID
|
||||||
|
}
|
||||||
|
if input.Concurrency != nil {
|
||||||
|
repoUpdates.Concurrency = input.Concurrency
|
||||||
|
}
|
||||||
|
if input.Priority != nil {
|
||||||
|
repoUpdates.Priority = input.Priority
|
||||||
|
}
|
||||||
|
if input.Status != "" {
|
||||||
|
repoUpdates.Status = &input.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run bulk update for column/jsonb fields first.
|
||||||
|
if _, err := s.accountRepo.BulkUpdate(ctx, input.AccountIDs, repoUpdates); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle group bindings per account (requires individual operations).
|
||||||
|
for _, accountID := range input.AccountIDs {
|
||||||
|
entry := BulkUpdateAccountResult{AccountID: accountID}
|
||||||
|
|
||||||
|
if input.GroupIDs != nil {
|
||||||
|
if err := s.accountRepo.BindGroups(ctx, accountID, *input.GroupIDs); err != nil {
|
||||||
|
entry.Success = false
|
||||||
|
entry.Error = err.Error()
|
||||||
|
result.Failed++
|
||||||
|
result.Results = append(result.Results, entry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.Success = true
|
||||||
|
result.Success++
|
||||||
|
result.Results = append(result.Results, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *adminServiceImpl) DeleteAccount(ctx context.Context, id int64) error {
|
func (s *adminServiceImpl) DeleteAccount(ctx context.Context, id int64) error {
|
||||||
return s.accountRepo.Delete(ctx, id)
|
return s.accountRepo.Delete(ctx, id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
"sub2api/internal/pkg/timezone"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||||
"sub2api/internal/service/ports"
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
"log"
|
"log"
|
||||||
"sub2api/internal/config"
|
|
||||||
"sub2api/internal/model"
|
|
||||||
"sub2api/internal/service/ports"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/service/ports"
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 错误定义
|
// 错误定义
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
"sub2api/internal/config"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ModelPricing 模型价格配置(per-token价格,与LiteLLM格式一致)
|
// ModelPricing 模型价格配置(per-token价格,与LiteLLM格式一致)
|
||||||
|
|||||||
@@ -2,12 +2,26 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/service/ports"
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// generateRequestID generates a unique request ID for concurrency slot tracking
|
||||||
|
// Uses 8 random bytes (16 hex chars) for uniqueness
|
||||||
|
func generateRequestID() string {
|
||||||
|
b := make([]byte, 8)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
// Fallback to nanosecond timestamp (extremely rare case)
|
||||||
|
return fmt.Sprintf("%x", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Default extra wait slots beyond concurrency limit
|
// Default extra wait slots beyond concurrency limit
|
||||||
defaultExtraWaitSlots = 20
|
defaultExtraWaitSlots = 20
|
||||||
@@ -41,7 +55,10 @@ func (s *ConcurrencyService) AcquireAccountSlot(ctx context.Context, accountID i
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
acquired, err := s.cache.AcquireAccountSlot(ctx, accountID, maxConcurrency)
|
// Generate unique request ID for this slot
|
||||||
|
requestID := generateRequestID()
|
||||||
|
|
||||||
|
acquired, err := s.cache.AcquireAccountSlot(ctx, accountID, maxConcurrency, requestID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -52,8 +69,8 @@ func (s *ConcurrencyService) AcquireAccountSlot(ctx context.Context, accountID i
|
|||||||
ReleaseFunc: func() {
|
ReleaseFunc: func() {
|
||||||
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := s.cache.ReleaseAccountSlot(bgCtx, accountID); err != nil {
|
if err := s.cache.ReleaseAccountSlot(bgCtx, accountID, requestID); err != nil {
|
||||||
log.Printf("Warning: failed to release account slot for %d: %v", accountID, err)
|
log.Printf("Warning: failed to release account slot for %d (req=%s): %v", accountID, requestID, err)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
@@ -77,7 +94,10 @@ func (s *ConcurrencyService) AcquireUserSlot(ctx context.Context, userID int64,
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
acquired, err := s.cache.AcquireUserSlot(ctx, userID, maxConcurrency)
|
// Generate unique request ID for this slot
|
||||||
|
requestID := generateRequestID()
|
||||||
|
|
||||||
|
acquired, err := s.cache.AcquireUserSlot(ctx, userID, maxConcurrency, requestID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -88,8 +108,8 @@ func (s *ConcurrencyService) AcquireUserSlot(ctx context.Context, userID int64,
|
|||||||
ReleaseFunc: func() {
|
ReleaseFunc: func() {
|
||||||
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := s.cache.ReleaseUserSlot(bgCtx, userID); err != nil {
|
if err := s.cache.ReleaseUserSlot(bgCtx, userID, requestID); err != nil {
|
||||||
log.Printf("Warning: failed to release user slot for %d: %v", userID, err)
|
log.Printf("Warning: failed to release user slot for %d (req=%s): %v", userID, requestID, err)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
961
backend/internal/service/crs_sync_service.go
Normal file
961
backend/internal/service/crs_sync_service.go
Normal file
@@ -0,0 +1,961 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CRSSyncService struct {
|
||||||
|
accountRepo ports.AccountRepository
|
||||||
|
proxyRepo ports.ProxyRepository
|
||||||
|
oauthService *OAuthService
|
||||||
|
openaiOAuthService *OpenAIOAuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCRSSyncService(
|
||||||
|
accountRepo ports.AccountRepository,
|
||||||
|
proxyRepo ports.ProxyRepository,
|
||||||
|
oauthService *OAuthService,
|
||||||
|
openaiOAuthService *OpenAIOAuthService,
|
||||||
|
) *CRSSyncService {
|
||||||
|
return &CRSSyncService{
|
||||||
|
accountRepo: accountRepo,
|
||||||
|
proxyRepo: proxyRepo,
|
||||||
|
oauthService: oauthService,
|
||||||
|
openaiOAuthService: openaiOAuthService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyncFromCRSInput struct {
|
||||||
|
BaseURL string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
SyncProxies bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyncFromCRSItemResult struct {
|
||||||
|
CRSAccountID string `json:"crs_account_id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Action string `json:"action"` // created/updated/failed/skipped
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyncFromCRSResult struct {
|
||||||
|
Created int `json:"created"`
|
||||||
|
Updated int `json:"updated"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Items []SyncFromCRSItemResult `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type crsLoginResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type crsExportResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data struct {
|
||||||
|
ExportedAt string `json:"exportedAt"`
|
||||||
|
ClaudeAccounts []crsClaudeAccount `json:"claudeAccounts"`
|
||||||
|
ClaudeConsoleAccounts []crsConsoleAccount `json:"claudeConsoleAccounts"`
|
||||||
|
OpenAIOAuthAccounts []crsOpenAIOAuthAccount `json:"openaiOAuthAccounts"`
|
||||||
|
OpenAIResponsesAccounts []crsOpenAIResponsesAccount `json:"openaiResponsesAccounts"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type crsProxy struct {
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type crsClaudeAccount struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
AuthType string `json:"authType"` // oauth/setup-token
|
||||||
|
IsActive bool `json:"isActive"`
|
||||||
|
Schedulable bool `json:"schedulable"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Proxy *crsProxy `json:"proxy"`
|
||||||
|
Credentials map[string]any `json:"credentials"`
|
||||||
|
Extra map[string]any `json:"extra"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type crsConsoleAccount struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
IsActive bool `json:"isActive"`
|
||||||
|
Schedulable bool `json:"schedulable"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
MaxConcurrentTasks int `json:"maxConcurrentTasks"`
|
||||||
|
Proxy *crsProxy `json:"proxy"`
|
||||||
|
Credentials map[string]any `json:"credentials"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type crsOpenAIResponsesAccount struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
IsActive bool `json:"isActive"`
|
||||||
|
Schedulable bool `json:"schedulable"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Proxy *crsProxy `json:"proxy"`
|
||||||
|
Credentials map[string]any `json:"credentials"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type crsOpenAIOAuthAccount struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
AuthType string `json:"authType"` // oauth
|
||||||
|
IsActive bool `json:"isActive"`
|
||||||
|
Schedulable bool `json:"schedulable"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Proxy *crsProxy `json:"proxy"`
|
||||||
|
Credentials map[string]any `json:"credentials"`
|
||||||
|
Extra map[string]any `json:"extra"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput) (*SyncFromCRSResult, error) {
|
||||||
|
baseURL, err := normalizeBaseURL(input.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(input.Username) == "" || strings.TrimSpace(input.Password) == "" {
|
||||||
|
return nil, errors.New("username and password are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 20 * time.Second}
|
||||||
|
|
||||||
|
adminToken, err := crsLogin(ctx, client, baseURL, input.Username, input.Password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
exported, err := crsExportAccounts(ctx, client, baseURL, adminToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
|
result := &SyncFromCRSResult{
|
||||||
|
Items: make(
|
||||||
|
[]SyncFromCRSItemResult,
|
||||||
|
0,
|
||||||
|
len(exported.Data.ClaudeAccounts)+len(exported.Data.ClaudeConsoleAccounts)+len(exported.Data.OpenAIOAuthAccounts)+len(exported.Data.OpenAIResponsesAccounts),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
var proxies []model.Proxy
|
||||||
|
if input.SyncProxies {
|
||||||
|
proxies, _ = s.proxyRepo.ListActive(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude OAuth / Setup Token -> sub2api anthropic oauth/setup-token
|
||||||
|
for _, src := range exported.Data.ClaudeAccounts {
|
||||||
|
item := SyncFromCRSItemResult{
|
||||||
|
CRSAccountID: src.ID,
|
||||||
|
Kind: src.Kind,
|
||||||
|
Name: src.Name,
|
||||||
|
}
|
||||||
|
|
||||||
|
targetType := strings.TrimSpace(src.AuthType)
|
||||||
|
if targetType == "" {
|
||||||
|
targetType = "oauth"
|
||||||
|
}
|
||||||
|
if targetType != model.AccountTypeOAuth && targetType != model.AccountTypeSetupToken {
|
||||||
|
item.Action = "skipped"
|
||||||
|
item.Error = "unsupported authType: " + targetType
|
||||||
|
result.Skipped++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, _ := src.Credentials["access_token"].(string)
|
||||||
|
if strings.TrimSpace(accessToken) == "" {
|
||||||
|
item.Action = "failed"
|
||||||
|
item.Error = "missing access_token"
|
||||||
|
result.Failed++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyID, err := s.mapOrCreateProxy(ctx, input.SyncProxies, &proxies, src.Proxy, fmt.Sprintf("crs-%s", src.Name))
|
||||||
|
if err != nil {
|
||||||
|
item.Action = "failed"
|
||||||
|
item.Error = "proxy sync failed: " + err.Error()
|
||||||
|
result.Failed++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
credentials := sanitizeCredentialsMap(src.Credentials)
|
||||||
|
// 🔧 Remove /v1 suffix from base_url for Claude accounts
|
||||||
|
cleanBaseURL(credentials, "/v1")
|
||||||
|
// 🔧 Convert expires_at from ISO string to Unix timestamp
|
||||||
|
if expiresAtStr, ok := credentials["expires_at"].(string); ok && expiresAtStr != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, expiresAtStr); err == nil {
|
||||||
|
credentials["expires_at"] = t.Unix()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 🔧 Add intercept_warmup_requests if not present (defaults to false)
|
||||||
|
if _, exists := credentials["intercept_warmup_requests"]; !exists {
|
||||||
|
credentials["intercept_warmup_requests"] = false
|
||||||
|
}
|
||||||
|
priority := clampPriority(src.Priority)
|
||||||
|
concurrency := 3
|
||||||
|
status := mapCRSStatus(src.IsActive, src.Status)
|
||||||
|
|
||||||
|
// 🔧 Preserve all CRS extra fields and add sync metadata
|
||||||
|
extra := make(map[string]any)
|
||||||
|
if src.Extra != nil {
|
||||||
|
for k, v := range src.Extra {
|
||||||
|
extra[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extra["crs_account_id"] = src.ID
|
||||||
|
extra["crs_kind"] = src.Kind
|
||||||
|
extra["crs_synced_at"] = now
|
||||||
|
// Extract org_uuid and account_uuid from CRS credentials to extra
|
||||||
|
if orgUUID, ok := src.Credentials["org_uuid"]; ok {
|
||||||
|
extra["org_uuid"] = orgUUID
|
||||||
|
}
|
||||||
|
if accountUUID, ok := src.Credentials["account_uuid"]; ok {
|
||||||
|
extra["account_uuid"] = accountUUID
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID)
|
||||||
|
if err != nil {
|
||||||
|
item.Action = "failed"
|
||||||
|
item.Error = "db lookup failed: " + err.Error()
|
||||||
|
result.Failed++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing == nil {
|
||||||
|
account := &model.Account{
|
||||||
|
Name: defaultName(src.Name, src.ID),
|
||||||
|
Platform: model.PlatformAnthropic,
|
||||||
|
Type: targetType,
|
||||||
|
Credentials: model.JSONB(credentials),
|
||||||
|
Extra: model.JSONB(extra),
|
||||||
|
ProxyID: proxyID,
|
||||||
|
Concurrency: concurrency,
|
||||||
|
Priority: priority,
|
||||||
|
Status: status,
|
||||||
|
Schedulable: src.Schedulable,
|
||||||
|
}
|
||||||
|
if err := s.accountRepo.Create(ctx, account); err != nil {
|
||||||
|
item.Action = "failed"
|
||||||
|
item.Error = "create failed: " + err.Error()
|
||||||
|
result.Failed++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 🔄 Refresh OAuth token after creation
|
||||||
|
if targetType == model.AccountTypeOAuth {
|
||||||
|
if refreshedCreds := s.refreshOAuthToken(ctx, account); refreshedCreds != nil {
|
||||||
|
account.Credentials = refreshedCreds
|
||||||
|
_ = s.accountRepo.Update(ctx, account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item.Action = "created"
|
||||||
|
result.Created++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing
|
||||||
|
existing.Extra = mergeJSONB(existing.Extra, extra)
|
||||||
|
existing.Name = defaultName(src.Name, src.ID)
|
||||||
|
existing.Platform = model.PlatformAnthropic
|
||||||
|
existing.Type = targetType
|
||||||
|
existing.Credentials = mergeJSONB(existing.Credentials, credentials)
|
||||||
|
if proxyID != nil {
|
||||||
|
existing.ProxyID = proxyID
|
||||||
|
}
|
||||||
|
existing.Concurrency = concurrency
|
||||||
|
existing.Priority = priority
|
||||||
|
existing.Status = status
|
||||||
|
existing.Schedulable = src.Schedulable
|
||||||
|
|
||||||
|
if err := s.accountRepo.Update(ctx, existing); err != nil {
|
||||||
|
item.Action = "failed"
|
||||||
|
item.Error = "update failed: " + err.Error()
|
||||||
|
result.Failed++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔄 Refresh OAuth token after update
|
||||||
|
if targetType == model.AccountTypeOAuth {
|
||||||
|
if refreshedCreds := s.refreshOAuthToken(ctx, existing); refreshedCreds != nil {
|
||||||
|
existing.Credentials = refreshedCreds
|
||||||
|
_ = s.accountRepo.Update(ctx, existing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item.Action = "updated"
|
||||||
|
result.Updated++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude Console API Key -> sub2api anthropic apikey
|
||||||
|
for _, src := range exported.Data.ClaudeConsoleAccounts {
|
||||||
|
item := SyncFromCRSItemResult{
|
||||||
|
CRSAccountID: src.ID,
|
||||||
|
Kind: src.Kind,
|
||||||
|
Name: src.Name,
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey, _ := src.Credentials["api_key"].(string)
|
||||||
|
if strings.TrimSpace(apiKey) == "" {
|
||||||
|
item.Action = "failed"
|
||||||
|
item.Error = "missing api_key"
|
||||||
|
result.Failed++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyID, err := s.mapOrCreateProxy(ctx, input.SyncProxies, &proxies, src.Proxy, fmt.Sprintf("crs-%s", src.Name))
|
||||||
|
if err != nil {
|
||||||
|
item.Action = "failed"
|
||||||
|
item.Error = "proxy sync failed: " + err.Error()
|
||||||
|
result.Failed++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
credentials := sanitizeCredentialsMap(src.Credentials)
|
||||||
|
priority := clampPriority(src.Priority)
|
||||||
|
concurrency := 3
|
||||||
|
if src.MaxConcurrentTasks > 0 {
|
||||||
|
concurrency = src.MaxConcurrentTasks
|
||||||
|
}
|
||||||
|
status := mapCRSStatus(src.IsActive, src.Status)
|
||||||
|
|
||||||
|
extra := map[string]any{
|
||||||
|
"crs_account_id": src.ID,
|
||||||
|
"crs_kind": src.Kind,
|
||||||
|
"crs_synced_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID)
|
||||||
|
if err != nil {
|
||||||
|
item.Action = "failed"
|
||||||
|
item.Error = "db lookup failed: " + err.Error()
|
||||||
|
result.Failed++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing == nil {
|
||||||
|
account := &model.Account{
|
||||||
|
Name: defaultName(src.Name, src.ID),
|
||||||
|
Platform: model.PlatformAnthropic,
|
||||||
|
Type: model.AccountTypeApiKey,
|
||||||
|
Credentials: model.JSONB(credentials),
|
||||||
|
Extra: model.JSONB(extra),
|
||||||
|
ProxyID: proxyID,
|
||||||
|
Concurrency: concurrency,
|
||||||
|
Priority: priority,
|
||||||
|
Status: status,
|
||||||
|
Schedulable: src.Schedulable,
|
||||||
|
}
|
||||||
|
if err := s.accountRepo.Create(ctx, account); err != nil {
|
||||||
|
item.Action = "failed"
|
||||||
|
item.Error = "create failed: " + err.Error()
|
||||||
|
result.Failed++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item.Action = "created"
|
||||||
|
result.Created++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.Extra = mergeJSONB(existing.Extra, extra)
|
||||||
|
existing.Name = defaultName(src.Name, src.ID)
|
||||||
|
existing.Platform = model.PlatformAnthropic
|
||||||
|
existing.Type = model.AccountTypeApiKey
|
||||||
|
existing.Credentials = mergeJSONB(existing.Credentials, credentials)
|
||||||
|
if proxyID != nil {
|
||||||
|
existing.ProxyID = proxyID
|
||||||
|
}
|
||||||
|
existing.Concurrency = concurrency
|
||||||
|
existing.Priority = priority
|
||||||
|
existing.Status = status
|
||||||
|
existing.Schedulable = src.Schedulable
|
||||||
|
|
||||||
|
if err := s.accountRepo.Update(ctx, existing); err != nil {
|
||||||
|
item.Action = "failed"
|
||||||
|
item.Error = "update failed: " + err.Error()
|
||||||
|
result.Failed++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
item.Action = "updated"
|
||||||
|
result.Updated++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI OAuth -> sub2api openai oauth
|
||||||
|
for _, src := range exported.Data.OpenAIOAuthAccounts {
|
||||||
|
item := SyncFromCRSItemResult{
|
||||||
|
CRSAccountID: src.ID,
|
||||||
|
Kind: src.Kind,
|
||||||
|
Name: src.Name,
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, _ := src.Credentials["access_token"].(string)
|
||||||
|
if strings.TrimSpace(accessToken) == "" {
|
||||||
|
item.Action = "failed"
|
||||||
|
item.Error = "missing access_token"
|
||||||
|
result.Failed++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyID, err := s.mapOrCreateProxy(
|
||||||
|
ctx,
|
||||||
|
input.SyncProxies,
|
||||||
|
&proxies,
|
||||||
|
src.Proxy,
|
||||||
|
fmt.Sprintf("crs-%s", src.Name),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
item.Action = "failed"
|
||||||
|
item.Error = "proxy sync failed: " + err.Error()
|
||||||
|
result.Failed++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
credentials := sanitizeCredentialsMap(src.Credentials)
|
||||||
|
// Normalize token_type
|
||||||
|
if v, ok := credentials["token_type"].(string); !ok || strings.TrimSpace(v) == "" {
|
||||||
|
credentials["token_type"] = "Bearer"
|
||||||
|
}
|
||||||
|
// 🔧 Convert expires_at from ISO string to Unix timestamp
|
||||||
|
if expiresAtStr, ok := credentials["expires_at"].(string); ok && expiresAtStr != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, expiresAtStr); err == nil {
|
||||||
|
credentials["expires_at"] = t.Unix()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
priority := clampPriority(src.Priority)
|
||||||
|
concurrency := 3
|
||||||
|
status := mapCRSStatus(src.IsActive, src.Status)
|
||||||
|
|
||||||
|
// 🔧 Preserve all CRS extra fields and add sync metadata
|
||||||
|
extra := make(map[string]any)
|
||||||
|
if src.Extra != nil {
|
||||||
|
for k, v := range src.Extra {
|
||||||
|
extra[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extra["crs_account_id"] = src.ID
|
||||||
|
extra["crs_kind"] = src.Kind
|
||||||
|
extra["crs_synced_at"] = now
|
||||||
|
// Extract email from CRS extra (crs_email -> email)
|
||||||
|
if crsEmail, ok := src.Extra["crs_email"]; ok {
|
||||||
|
extra["email"] = crsEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID)
|
||||||
|
if err != nil {
|
||||||
|
item.Action = "failed"
|
||||||
|
item.Error = "db lookup failed: " + err.Error()
|
||||||
|
result.Failed++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing == nil {
|
||||||
|
account := &model.Account{
|
||||||
|
Name: defaultName(src.Name, src.ID),
|
||||||
|
Platform: model.PlatformOpenAI,
|
||||||
|
Type: model.AccountTypeOAuth,
|
||||||
|
Credentials: model.JSONB(credentials),
|
||||||
|
Extra: model.JSONB(extra),
|
||||||
|
ProxyID: proxyID,
|
||||||
|
Concurrency: concurrency,
|
||||||
|
Priority: priority,
|
||||||
|
Status: status,
|
||||||
|
Schedulable: src.Schedulable,
|
||||||
|
}
|
||||||
|
if err := s.accountRepo.Create(ctx, account); err != nil {
|
||||||
|
item.Action = "failed"
|
||||||
|
item.Error = "create failed: " + err.Error()
|
||||||
|
result.Failed++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 🔄 Refresh OAuth token after creation
|
||||||
|
if refreshedCreds := s.refreshOAuthToken(ctx, account); refreshedCreds != nil {
|
||||||
|
account.Credentials = refreshedCreds
|
||||||
|
_ = s.accountRepo.Update(ctx, account)
|
||||||
|
}
|
||||||
|
item.Action = "created"
|
||||||
|
result.Created++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.Extra = mergeJSONB(existing.Extra, extra)
|
||||||
|
existing.Name = defaultName(src.Name, src.ID)
|
||||||
|
existing.Platform = model.PlatformOpenAI
|
||||||
|
existing.Type = model.AccountTypeOAuth
|
||||||
|
existing.Credentials = mergeJSONB(existing.Credentials, credentials)
|
||||||
|
if proxyID != nil {
|
||||||
|
existing.ProxyID = proxyID
|
||||||
|
}
|
||||||
|
existing.Concurrency = concurrency
|
||||||
|
existing.Priority = priority
|
||||||
|
existing.Status = status
|
||||||
|
existing.Schedulable = src.Schedulable
|
||||||
|
|
||||||
|
if err := s.accountRepo.Update(ctx, existing); err != nil {
|
||||||
|
item.Action = "failed"
|
||||||
|
item.Error = "update failed: " + err.Error()
|
||||||
|
result.Failed++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔄 Refresh OAuth token after update
|
||||||
|
if refreshedCreds := s.refreshOAuthToken(ctx, existing); refreshedCreds != nil {
|
||||||
|
existing.Credentials = refreshedCreds
|
||||||
|
_ = s.accountRepo.Update(ctx, existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
item.Action = "updated"
|
||||||
|
result.Updated++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI Responses API Key -> sub2api openai apikey
|
||||||
|
for _, src := range exported.Data.OpenAIResponsesAccounts {
|
||||||
|
item := SyncFromCRSItemResult{
|
||||||
|
CRSAccountID: src.ID,
|
||||||
|
Kind: src.Kind,
|
||||||
|
Name: src.Name,
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey, _ := src.Credentials["api_key"].(string)
|
||||||
|
if strings.TrimSpace(apiKey) == "" {
|
||||||
|
item.Action = "failed"
|
||||||
|
item.Error = "missing api_key"
|
||||||
|
result.Failed++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if baseURL, ok := src.Credentials["base_url"].(string); !ok || strings.TrimSpace(baseURL) == "" {
|
||||||
|
src.Credentials["base_url"] = "https://api.openai.com"
|
||||||
|
}
|
||||||
|
// 🔧 Remove /v1 suffix from base_url for OpenAI accounts
|
||||||
|
cleanBaseURL(src.Credentials, "/v1")
|
||||||
|
|
||||||
|
proxyID, err := s.mapOrCreateProxy(
|
||||||
|
ctx,
|
||||||
|
input.SyncProxies,
|
||||||
|
&proxies,
|
||||||
|
src.Proxy,
|
||||||
|
fmt.Sprintf("crs-%s", src.Name),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
item.Action = "failed"
|
||||||
|
item.Error = "proxy sync failed: " + err.Error()
|
||||||
|
result.Failed++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
credentials := sanitizeCredentialsMap(src.Credentials)
|
||||||
|
priority := clampPriority(src.Priority)
|
||||||
|
concurrency := 3
|
||||||
|
status := mapCRSStatus(src.IsActive, src.Status)
|
||||||
|
|
||||||
|
extra := map[string]any{
|
||||||
|
"crs_account_id": src.ID,
|
||||||
|
"crs_kind": src.Kind,
|
||||||
|
"crs_synced_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID)
|
||||||
|
if err != nil {
|
||||||
|
item.Action = "failed"
|
||||||
|
item.Error = "db lookup failed: " + err.Error()
|
||||||
|
result.Failed++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing == nil {
|
||||||
|
account := &model.Account{
|
||||||
|
Name: defaultName(src.Name, src.ID),
|
||||||
|
Platform: model.PlatformOpenAI,
|
||||||
|
Type: model.AccountTypeApiKey,
|
||||||
|
Credentials: model.JSONB(credentials),
|
||||||
|
Extra: model.JSONB(extra),
|
||||||
|
ProxyID: proxyID,
|
||||||
|
Concurrency: concurrency,
|
||||||
|
Priority: priority,
|
||||||
|
Status: status,
|
||||||
|
Schedulable: src.Schedulable,
|
||||||
|
}
|
||||||
|
if err := s.accountRepo.Create(ctx, account); err != nil {
|
||||||
|
item.Action = "failed"
|
||||||
|
item.Error = "create failed: " + err.Error()
|
||||||
|
result.Failed++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item.Action = "created"
|
||||||
|
result.Created++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.Extra = mergeJSONB(existing.Extra, extra)
|
||||||
|
existing.Name = defaultName(src.Name, src.ID)
|
||||||
|
existing.Platform = model.PlatformOpenAI
|
||||||
|
existing.Type = model.AccountTypeApiKey
|
||||||
|
existing.Credentials = mergeJSONB(existing.Credentials, credentials)
|
||||||
|
if proxyID != nil {
|
||||||
|
existing.ProxyID = proxyID
|
||||||
|
}
|
||||||
|
existing.Concurrency = concurrency
|
||||||
|
existing.Priority = priority
|
||||||
|
existing.Status = status
|
||||||
|
existing.Schedulable = src.Schedulable
|
||||||
|
|
||||||
|
if err := s.accountRepo.Update(ctx, existing); err != nil {
|
||||||
|
item.Action = "failed"
|
||||||
|
item.Error = "update failed: " + err.Error()
|
||||||
|
result.Failed++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
item.Action = "updated"
|
||||||
|
result.Updated++
|
||||||
|
result.Items = append(result.Items, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeJSONB merges two JSONB maps without removing keys that are absent in updates.
|
||||||
|
func mergeJSONB(existing model.JSONB, updates map[string]any) model.JSONB {
|
||||||
|
out := make(model.JSONB)
|
||||||
|
for k, v := range existing {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range updates {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CRSSyncService) mapOrCreateProxy(ctx context.Context, enabled bool, cached *[]model.Proxy, src *crsProxy, defaultName string) (*int64, error) {
|
||||||
|
if !enabled || src == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
protocol := strings.ToLower(strings.TrimSpace(src.Protocol))
|
||||||
|
switch protocol {
|
||||||
|
case "socks":
|
||||||
|
protocol = "socks5"
|
||||||
|
case "socks5h":
|
||||||
|
protocol = "socks5"
|
||||||
|
}
|
||||||
|
host := strings.TrimSpace(src.Host)
|
||||||
|
port := src.Port
|
||||||
|
username := strings.TrimSpace(src.Username)
|
||||||
|
password := strings.TrimSpace(src.Password)
|
||||||
|
|
||||||
|
if protocol == "" || host == "" || port <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if protocol != "http" && protocol != "https" && protocol != "socks5" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find existing proxy (active only).
|
||||||
|
for _, p := range *cached {
|
||||||
|
if strings.EqualFold(p.Protocol, protocol) &&
|
||||||
|
p.Host == host &&
|
||||||
|
p.Port == port &&
|
||||||
|
p.Username == username &&
|
||||||
|
p.Password == password {
|
||||||
|
id := p.ID
|
||||||
|
return &id, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new proxy
|
||||||
|
proxy := &model.Proxy{
|
||||||
|
Name: defaultProxyName(defaultName, protocol, host, port),
|
||||||
|
Protocol: protocol,
|
||||||
|
Host: host,
|
||||||
|
Port: port,
|
||||||
|
Username: username,
|
||||||
|
Password: password,
|
||||||
|
Status: model.StatusActive,
|
||||||
|
}
|
||||||
|
if err := s.proxyRepo.Create(ctx, proxy); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
*cached = append(*cached, *proxy)
|
||||||
|
id := proxy.ID
|
||||||
|
return &id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultProxyName(base, protocol, host string, port int) string {
|
||||||
|
base = strings.TrimSpace(base)
|
||||||
|
if base == "" {
|
||||||
|
base = "crs"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s (%s://%s:%d)", base, protocol, host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultName(name, id string) string {
|
||||||
|
if strings.TrimSpace(name) != "" {
|
||||||
|
return strings.TrimSpace(name)
|
||||||
|
}
|
||||||
|
return "CRS " + id
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampPriority(priority int) int {
|
||||||
|
if priority < 1 || priority > 100 {
|
||||||
|
return 50
|
||||||
|
}
|
||||||
|
return priority
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeCredentialsMap(input map[string]any) map[string]any {
|
||||||
|
if input == nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
out := make(map[string]any, len(input))
|
||||||
|
for k, v := range input {
|
||||||
|
// Avoid nil values to keep JSONB cleaner
|
||||||
|
if v != nil {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapCRSStatus(isActive bool, status string) string {
|
||||||
|
if !isActive {
|
||||||
|
return "inactive"
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(status), "error") {
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
return "active"
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBaseURL(raw string) (string, error) {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", errors.New("base_url is required")
|
||||||
|
}
|
||||||
|
u, err := url.Parse(trimmed)
|
||||||
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
return "", fmt.Errorf("invalid base_url: %s", trimmed)
|
||||||
|
}
|
||||||
|
u.Path = strings.TrimRight(u.Path, "/")
|
||||||
|
return strings.TrimRight(u.String(), "/"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanBaseURL removes trailing suffix from base_url in credentials
|
||||||
|
// Used for both Claude and OpenAI accounts to remove /v1
|
||||||
|
func cleanBaseURL(credentials map[string]any, suffixToRemove string) {
|
||||||
|
if baseURL, ok := credentials["base_url"].(string); ok && baseURL != "" {
|
||||||
|
trimmed := strings.TrimSpace(baseURL)
|
||||||
|
if strings.HasSuffix(trimmed, suffixToRemove) {
|
||||||
|
credentials["base_url"] = strings.TrimSuffix(trimmed, suffixToRemove)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func crsLogin(ctx context.Context, client *http.Client, baseURL, username, password string) (string, error) {
|
||||||
|
payload := map[string]any{
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/web/auth/login", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return "", fmt.Errorf("crs login failed: status=%d body=%s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed crsLoginResponse
|
||||||
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||||
|
return "", fmt.Errorf("crs login parse failed: %w", err)
|
||||||
|
}
|
||||||
|
if !parsed.Success || strings.TrimSpace(parsed.Token) == "" {
|
||||||
|
msg := parsed.Message
|
||||||
|
if msg == "" {
|
||||||
|
msg = parsed.Error
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
msg = "unknown error"
|
||||||
|
}
|
||||||
|
return "", errors.New("crs login failed: " + msg)
|
||||||
|
}
|
||||||
|
return parsed.Token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func crsExportAccounts(ctx context.Context, client *http.Client, baseURL, adminToken string) (*crsExportResponse, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/admin/sync/export-accounts?include_secrets=true", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 5<<20))
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("crs export failed: status=%d body=%s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed crsExportResponse
|
||||||
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||||
|
return nil, fmt.Errorf("crs export parse failed: %w", err)
|
||||||
|
}
|
||||||
|
if !parsed.Success {
|
||||||
|
msg := parsed.Message
|
||||||
|
if msg == "" {
|
||||||
|
msg = parsed.Error
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
msg = "unknown error"
|
||||||
|
}
|
||||||
|
return nil, errors.New("crs export failed: " + msg)
|
||||||
|
}
|
||||||
|
return &parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshOAuthToken attempts to refresh OAuth token for a synced account
|
||||||
|
// Returns updated credentials or nil if refresh failed/not applicable
|
||||||
|
func (s *CRSSyncService) refreshOAuthToken(ctx context.Context, account *model.Account) model.JSONB {
|
||||||
|
if account.Type != model.AccountTypeOAuth {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var newCredentials map[string]any
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch account.Platform {
|
||||||
|
case model.PlatformAnthropic:
|
||||||
|
if s.oauthService == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tokenInfo, refreshErr := s.oauthService.RefreshAccountToken(ctx, account)
|
||||||
|
if refreshErr != nil {
|
||||||
|
err = refreshErr
|
||||||
|
} else {
|
||||||
|
// Preserve existing credentials
|
||||||
|
newCredentials = make(map[string]any)
|
||||||
|
for k, v := range account.Credentials {
|
||||||
|
newCredentials[k] = v
|
||||||
|
}
|
||||||
|
// Update token fields
|
||||||
|
newCredentials["access_token"] = tokenInfo.AccessToken
|
||||||
|
newCredentials["token_type"] = tokenInfo.TokenType
|
||||||
|
newCredentials["expires_in"] = tokenInfo.ExpiresIn
|
||||||
|
newCredentials["expires_at"] = tokenInfo.ExpiresAt
|
||||||
|
if tokenInfo.RefreshToken != "" {
|
||||||
|
newCredentials["refresh_token"] = tokenInfo.RefreshToken
|
||||||
|
}
|
||||||
|
if tokenInfo.Scope != "" {
|
||||||
|
newCredentials["scope"] = tokenInfo.Scope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case model.PlatformOpenAI:
|
||||||
|
if s.openaiOAuthService == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tokenInfo, refreshErr := s.openaiOAuthService.RefreshAccountToken(ctx, account)
|
||||||
|
if refreshErr != nil {
|
||||||
|
err = refreshErr
|
||||||
|
} else {
|
||||||
|
newCredentials = s.openaiOAuthService.BuildAccountCredentials(tokenInfo)
|
||||||
|
// Preserve non-token settings from existing credentials
|
||||||
|
for k, v := range account.Credentials {
|
||||||
|
if _, exists := newCredentials[k]; !exists {
|
||||||
|
newCredentials[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// Log but don't fail the sync - token might still be valid or refreshable later
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.JSONB(newCredentials)
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/pkg/usagestats"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
||||||
"sub2api/internal/service/ports"
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DashboardService provides aggregated statistics for admin dashboard.
|
// DashboardService provides aggregated statistics for admin dashboard.
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import (
|
|||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sub2api/internal/model"
|
|
||||||
"sub2api/internal/service/ports"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/claude"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||||
"sub2api/internal/service/ports"
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
"github.com/tidwall/sjson"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -390,6 +392,18 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *m
|
|||||||
return nil, fmt.Errorf("parse request: %w", err)
|
return nil, fmt.Errorf("parse request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !gjson.GetBytes(body, "system").Exists() {
|
||||||
|
body, _ = sjson.SetBytes(body, "system", []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "text",
|
||||||
|
"text": "You are Claude Code, Anthropic's official CLI for Claude.",
|
||||||
|
"cache_control": map[string]string{
|
||||||
|
"type": "ephemeral",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 应用模型映射(仅对apikey类型账号)
|
// 应用模型映射(仅对apikey类型账号)
|
||||||
originalModel := req.Model
|
originalModel := req.Model
|
||||||
if account.Type == model.AccountTypeApiKey {
|
if account.Type == model.AccountTypeApiKey {
|
||||||
@@ -622,6 +636,9 @@ func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Res
|
|||||||
var statusCode int
|
var statusCode int
|
||||||
|
|
||||||
switch resp.StatusCode {
|
switch resp.StatusCode {
|
||||||
|
case 400:
|
||||||
|
c.Data(http.StatusBadRequest, "application/json", body)
|
||||||
|
return nil, fmt.Errorf("upstream error: %d", resp.StatusCode)
|
||||||
case 401:
|
case 401:
|
||||||
statusCode = http.StatusBadGateway
|
statusCode = http.StatusBadGateway
|
||||||
errType = "upstream_error"
|
errType = "upstream_error"
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
"sub2api/internal/service/ports"
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sub2api/internal/service/ports"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/oauth"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
|
||||||
"sub2api/internal/service/ports"
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ClaudeOAuthClient handles HTTP requests for Claude OAuth flows
|
// ClaudeOAuthClient handles HTTP requests for Claude OAuth flows
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/service/ports"
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/openai"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||||
"sub2api/internal/service/ports"
|
"github.com/Wei-Shaw/sub2api/internal/service/ports"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OpenAIOAuthService handles OpenAI OAuth authentication flows
|
// OpenAIOAuthService handles OpenAI OAuth authentication flows
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AccountRepository interface {
|
type AccountRepository interface {
|
||||||
Create(ctx context.Context, account *model.Account) error
|
Create(ctx context.Context, account *model.Account) error
|
||||||
GetByID(ctx context.Context, id int64) (*model.Account, error)
|
GetByID(ctx context.Context, id int64) (*model.Account, error)
|
||||||
|
// GetByCRSAccountID finds an account previously synced from CRS.
|
||||||
|
// Returns (nil, nil) if not found.
|
||||||
|
GetByCRSAccountID(ctx context.Context, crsAccountID string) (*model.Account, error)
|
||||||
Update(ctx context.Context, account *model.Account) error
|
Update(ctx context.Context, account *model.Account) error
|
||||||
Delete(ctx context.Context, id int64) error
|
Delete(ctx context.Context, id int64) error
|
||||||
|
|
||||||
@@ -35,4 +38,17 @@ type AccountRepository interface {
|
|||||||
ClearRateLimit(ctx context.Context, id int64) error
|
ClearRateLimit(ctx context.Context, id int64) error
|
||||||
UpdateSessionWindow(ctx context.Context, id int64, start, end *time.Time, status string) error
|
UpdateSessionWindow(ctx context.Context, id int64, start, end *time.Time, status string) error
|
||||||
UpdateExtra(ctx context.Context, id int64, updates map[string]any) error
|
UpdateExtra(ctx context.Context, id int64, updates map[string]any) error
|
||||||
|
BulkUpdate(ctx context.Context, ids []int64, updates AccountBulkUpdate) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountBulkUpdate describes the fields that can be updated in a bulk operation.
|
||||||
|
// Nil pointers mean "do not change".
|
||||||
|
type AccountBulkUpdate struct {
|
||||||
|
Name *string
|
||||||
|
ProxyID *int64
|
||||||
|
Concurrency *int
|
||||||
|
Priority *int
|
||||||
|
Status *string
|
||||||
|
Credentials map[string]any
|
||||||
|
Extra map[string]any
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package ports
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ApiKeyRepository interface {
|
type ApiKeyRepository interface {
|
||||||
|
|||||||
@@ -3,17 +3,21 @@ package ports
|
|||||||
import "context"
|
import "context"
|
||||||
|
|
||||||
// ConcurrencyCache defines cache operations for concurrency service
|
// ConcurrencyCache defines cache operations for concurrency service
|
||||||
|
// Uses independent keys per request slot with native Redis TTL for automatic cleanup
|
||||||
type ConcurrencyCache interface {
|
type ConcurrencyCache interface {
|
||||||
// Slot management
|
// Account slot management - each slot is a separate key with independent TTL
|
||||||
AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int) (bool, error)
|
// Key format: concurrency:account:{accountID}:{requestID}
|
||||||
ReleaseAccountSlot(ctx context.Context, accountID int64) error
|
AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error)
|
||||||
|
ReleaseAccountSlot(ctx context.Context, accountID int64, requestID string) error
|
||||||
GetAccountConcurrency(ctx context.Context, accountID int64) (int, error)
|
GetAccountConcurrency(ctx context.Context, accountID int64) (int, error)
|
||||||
|
|
||||||
AcquireUserSlot(ctx context.Context, userID int64, maxConcurrency int) (bool, error)
|
// User slot management - each slot is a separate key with independent TTL
|
||||||
ReleaseUserSlot(ctx context.Context, userID int64) error
|
// Key format: concurrency:user:{userID}:{requestID}
|
||||||
|
AcquireUserSlot(ctx context.Context, userID int64, maxConcurrency int, requestID string) (bool, error)
|
||||||
|
ReleaseUserSlot(ctx context.Context, userID int64, requestID string) error
|
||||||
GetUserConcurrency(ctx context.Context, userID int64) (int, error)
|
GetUserConcurrency(ctx context.Context, userID int64) (int, error)
|
||||||
|
|
||||||
// Wait queue
|
// Wait queue - uses counter with TTL set only on creation
|
||||||
IncrementWaitCount(ctx context.Context, userID int64, maxWait int) (bool, error)
|
IncrementWaitCount(ctx context.Context, userID int64, maxWait int) (bool, error)
|
||||||
DecrementWaitCount(ctx context.Context, userID int64) error
|
DecrementWaitCount(ctx context.Context, userID int64) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package ports
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package ports
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"sub2api/internal/pkg/openai"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OpenAIOAuthClient interface for OpenAI OAuth operations
|
// OpenAIOAuthClient interface for OpenAI OAuth operations
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package ports
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProxyRepository interface {
|
type ProxyRepository interface {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package ports
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RedeemCodeRepository interface {
|
type RedeemCodeRepository interface {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package ports
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SettingRepository interface {
|
type SettingRepository interface {
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
"sub2api/internal/pkg/usagestats"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UsageLogRepository interface {
|
type UsageLogRepository interface {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package ports
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserRepository interface {
|
type UserRepository interface {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/model"
|
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||||
"sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserSubscriptionRepository interface {
|
type UserSubscriptionRepository interface {
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"sub2api/internal/pkg/openai"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LiteLLMModelPricing LiteLLM价格数据结构
|
// LiteLLMModelPricing LiteLLM价格数据结构
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user