diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2c03eb2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,47 @@ +# Git +.git +.gitignore + +# IDE +.vscode +.idea +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ +data/logs/ +data/server.log + +# Database +data/data.db +data/stats.json + +# Build artifacts +random-api-go.exe +random-api-go +random-api-test +*.exe + +# Go +vendor/ + +# Docker +docker-compose*.yml +test-build.sh + +# Documentation +*.md +DOCKER_DEPLOYMENT.md + +# Misc +.env +.env.local +.env.example +README.md \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..59fa9e4 Binary files /dev/null and b/.env.example differ diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d1c6e63..392461f 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -22,19 +22,18 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: web/package-lock.json + - name: Set up Go uses: actions/setup-go@v4 with: go-version: '1.23' - - name: Build for amd64 - run: | - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -o bin/amd64/random-api . - - - name: Build for arm64 - run: | - CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -a -installsuffix cgo -o bin/arm64/random-api . - - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -51,23 +50,12 @@ jobs: uses: docker/build-push-action@v5 with: context: . - file: Dockerfile.run + file: Dockerfile push: true tags: woodchen/${{ env.IMAGE_NAME }}:latest platforms: linux/amd64,linux/arm64 - - - name: Create artifact - run: | - zip -r public.zip public - - - name: Deploy public directory to server - uses: appleboy/scp-action@master - with: - host: ${{ secrets.SERVER_HOST }} - username: root - key: ${{ secrets.SERVER_SSH_KEY }} - source: 'public.zip' - target: '/tmp' + cache-from: type=gha + cache-to: type=gha,mode=max - name: Execute deployment commands uses: appleboy/ssh-action@master @@ -76,28 +64,15 @@ jobs: username: root key: ${{ secrets.SERVER_SSH_KEY }} script: | - # 解压文件 - unzip -o /tmp/public.zip -d /tmp/public_temp - - # 删除目标目录中的现有文件 - rm -rf /opt/1panel/docker/compose/random-api-go/data/public/* - - # 移动新文件到目标目录 - mv -f /tmp/public_temp/public/* /opt/1panel/docker/compose/random-api-go/data/public/ - - # 设置目录及其子文件的所有权和权限 - chmod -R 0755 /opt/1panel/docker/compose/random-api-go/data/public - - # 清理临时文件 - rm /tmp/public.zip - rm -rf /tmp/public_temp - - # 拉取镜像 + # 拉取最新镜像 docker pull woodchen/random-api-go:latest - # 停止并删除容器 + # 停止并删除旧容器 docker stop random-api-go || true docker rm random-api-go || true - # 启动容器 + # 启动新容器 docker compose -f /opt/1panel/docker/compose/random-api-go/docker-compose.yml up -d + + # 清理未使用的镜像 + docker image prune -f diff --git a/.github/workflows/generate-csv.yml b/.github/workflows/generate-csv.yml deleted file mode 100644 index 659adb2..0000000 --- a/.github/workflows/generate-csv.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Generate CSV Files - -on: - push: - paths: - - lankong_tools/album_mapping.json - schedule: - - cron: '0 */4 * * *' - workflow_dispatch: - inputs: - message: - description: 'Trigger message' - required: false - default: 'Manual trigger to generate CSV files' - - -jobs: - generate: - runs-on: ubuntu-latest - steps: - - name: Checkout source repo - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '1.23' - - - name: Generate CSV files - run: | - go run lankong_tools/generate_csv.go - env: - API_TOKEN: ${{ secrets.API_TOKEN }} - - - name: Checkout target repo - uses: actions/checkout@v4 - with: - repository: woodchen-ink/github-file - token: ${{ secrets.TARGET_REPO_TOKEN }} - path: target-repo - - - name: Copy and commit files - run: | - # 删除不需要的文件和目录 - rm -f public/index.html public/index.md - rm -rf public/css - - # 创建目标目录 - mkdir -p target-repo/random-api.czl.net/url/pic - - # 复制所有CSV文件到pic目录 - find public -name "*.csv" -exec cp -v {} target-repo/random-api.czl.net/url/pic/ \; - - cd target-repo - git config --local user.email "github-actions[bot]@users.noreply.github.com" - git config --local user.name "github-actions[bot]" - - git remote set-url origin https://${{ secrets.TARGET_REPO_TOKEN }}@github.com/woodchen-ink/github-file.git - - git add . - git commit -m "Auto update CSV files by GitHub Actions" || echo "No changes to commit" - git push origin main - \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f80f3fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.cursorignore +.env +.cursor/rules/myrule.mdc +data/data.db +data/server.log +data/stats.json diff --git a/DOCKER_DEPLOYMENT.md b/DOCKER_DEPLOYMENT.md new file mode 100644 index 0000000..5a98e3a --- /dev/null +++ b/DOCKER_DEPLOYMENT.md @@ -0,0 +1,141 @@ +# Docker 部署说明 + +## 概述 + +本项目现在使用单一Docker镜像部署,包含前端(Next.js)和后端(Go)。前端被构建为静态文件并由后端服务器提供服务。 + +## 架构变更 + +### 之前的架构 +- 前端:独立的Next.js开发服务器 +- 后端:Go API服务器 +- 部署:需要分别处理前后端 + +### 现在的架构 +- 前端:构建为静态文件(Next.js export) +- 后端:Go服务器同时提供API和静态文件服务 +- 部署:单一Docker镜像包含完整应用 + +## 构建流程 + +### 多阶段Docker构建 + +1. **前端构建阶段** + ```dockerfile + FROM node:20-alpine AS frontend-builder + # 安装依赖并构建前端静态文件 + RUN npm run build + ``` + +2. **后端构建阶段** + ```dockerfile + FROM golang:1.23-alpine AS backend-builder + # 构建Go二进制文件 + RUN go build -o random-api . + ``` + +3. **运行阶段** + ```dockerfile + FROM alpine:latest + # 复制后端二进制文件和前端静态文件 + COPY --from=backend-builder /app/random-api . + COPY --from=frontend-builder /app/web/out ./web/out + ``` + +## 路由处理 + +### 静态文件优先级 +后端路由器现在按以下优先级处理请求: + +1. **API路径** (`/api/*`) → 后端API处理器 +2. **静态文件** (包含文件扩展名) → 静态文件服务 +3. **前端路由** (`/`, `/admin/*`) → 返回index.html +4. **动态API端点** (其他路径) → 后端API处理器 + +### 路由判断逻辑 +```go +func (r *Router) shouldServeStatic(path string) bool { + // API路径不由静态文件处理 + if strings.HasPrefix(path, "/api/") { + return false + } + + // 根路径和前端路由 + if path == "/" || strings.HasPrefix(path, "/admin") { + return true + } + + // 静态资源文件 + if r.hasFileExtension(path) { + return true + } + + return false +} +``` + +## 部署配置 + +### GitHub Actions +- 自动构建多架构镜像 (amd64/arm64) +- 推送到Docker Hub +- 自动部署到服务器 + +### Docker Compose +```yaml +services: + random-api-go: + container_name: random-api-go + image: woodchen/random-api-go:latest + ports: + - "5003:5003" + volumes: + - ./data:/root/data + environment: + - TZ=Asia/Shanghai + - BASE_URL=https://random-api.czl.net + restart: unless-stopped +``` + +## 访问地址 + +部署完成后,可以通过以下地址访问: + +- **前端首页**: `http://localhost:5003/` +- **管理后台**: `http://localhost:5003/admin` +- **API统计**: `http://localhost:5003/api/stats` +- **动态API端点**: `http://localhost:5003/{endpoint-name}` + +## 开发环境 + +### 本地开发 +在开发环境中,前端仍然可以使用开发服务器: + +```bash +# 启动后端 +go run main.go + +# 启动前端(另一个终端) +cd web +npm run dev +``` + +前端的`next.config.ts`会在开发环境中自动代理API请求到后端。 + +### 生产构建测试 +```bash +# 构建前端 +cd web +npm run build + +# 启动后端(会自动服务静态文件) +cd .. +go run main.go +``` + +## 注意事项 + +1. **前端路由**: 所有前端路由都会返回`index.html`,由前端路由器处理 +2. **API端点冲突**: 确保动态API端点名称不与静态文件路径冲突 +3. **缓存**: 静态文件会被适当缓存,API响应不会被缓存 +4. **错误处理**: 404错误会根据路径类型返回相应的错误页面 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 821a779..4014359 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,43 +1,62 @@ -# 构建阶段 -FROM golang:1.23 AS builder +# 前端构建阶段 +FROM node:20-alpine AS frontend-builder + +WORKDIR /app/web + +# 复制前端依赖文件 +COPY web/package*.json ./ + +# 安装前端依赖 +RUN npm ci --only=production + +# 复制前端源代码 +COPY web/ ./ + +# 构建前端静态文件 +RUN npm run build + +# 后端构建阶段 +FROM golang:1.23-alpine AS backend-builder WORKDIR /app -# 复制 go.mod 和 go.sum 文件(如果存在) -COPY go.mod go.sum* ./ +# 安装必要的工具 +RUN apk add --no-cache git + +# 复制 go.mod 和 go.sum 文件 +COPY go.mod go.sum ./ # 下载依赖 RUN go mod download -# 复制源代码 +# 复制后端源代码 COPY . . -# 构建应用 +# 构建后端应用 RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o random-api . # 运行阶段 FROM alpine:latest -RUN apk --no-cache add ca-certificates tzdata +# 安装必要的工具 +RUN apk --no-cache add ca-certificates tzdata tini WORKDIR /root/ -COPY --from=builder /app/random-api . -COPY --from=builder /app/public ./public -# 复制 public 目录到一个临时位置 -COPY --from=builder /app/public /tmp/public +# 从后端构建阶段复制二进制文件 +COPY --from=backend-builder /app/random-api . + +# 从前端构建阶段复制静态文件 +COPY --from=frontend-builder /app/web/out ./web/out # 创建必要的目录 -RUN mkdir -p /root/data/logs /root/data/public +RUN mkdir -p /root/data/logs +# 暴露端口 EXPOSE 5003 # 使用 tini 作为初始化系统 -RUN apk add --no-cache tini ENTRYPOINT ["/sbin/tini", "--"] -# 创建一个启动脚本 -COPY start.sh /start.sh -RUN chmod +x /start.sh - -CMD ["/start.sh"] +# 启动应用 +CMD ["./random-api"] diff --git a/Dockerfile.run b/Dockerfile.run deleted file mode 100644 index 134eefb..0000000 --- a/Dockerfile.run +++ /dev/null @@ -1,23 +0,0 @@ -FROM --platform=$TARGETPLATFORM alpine:latest - -WORKDIR /root/ - -# 安装必要的包 -RUN apk --no-cache add ca-certificates tzdata tini - -# 创建日志目录并设置权限 -RUN mkdir -p /var/log/random-api && chmod 755 /var/log/random-api - -# 根据目标平台复制对应的二进制文件 -ARG TARGETARCH -COPY bin/${TARGETARCH}/random-api . -COPY public ./public -COPY public /tmp/public -COPY start.sh /start.sh -RUN chmod +x /start.sh - -EXPOSE 5003 - -# 使用 tini 作为初始化系统 -ENTRYPOINT ["/sbin/tini", "--"] -CMD ["/start.sh"] diff --git a/config.json b/config.json deleted file mode 100644 index a4862a1..0000000 --- a/config.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "server": { - "port": ":5003", - "read_timeout": "30s", - "write_timeout": "30s", - "max_header_bytes": 1048576 - }, - "storage": { - "data_dir": "/root/data", - "stats_file": "/root/data/stats.json", - "log_file": "/root/data/logs/server.log" - }, - "api": { - "base_url": "", - "request_timeout": "10s" - } -} \ No newline at end of file diff --git a/config/config.go b/config/config.go index 0d2d16f..3bf1a13 100644 --- a/config/config.go +++ b/config/config.go @@ -1,45 +1,34 @@ package config import ( - "encoding/json" - "fmt" + "bufio" "math/rand" "os" - "path/filepath" + "strconv" + "strings" "time" ) -const ( - EnvBaseURL = "BASE_URL" - DefaultPort = ":5003" - RequestTimeout = 10 * time.Second -) - type Config struct { Server struct { - Port string `json:"port"` - ReadTimeout time.Duration `json:"read_timeout"` - WriteTimeout time.Duration `json:"write_timeout"` - MaxHeaderBytes int `json:"max_header_bytes"` - } `json:"server"` + Port string + ReadTimeout time.Duration + WriteTimeout time.Duration + MaxHeaderBytes int + } Storage struct { - DataDir string `json:"data_dir"` - StatsFile string `json:"stats_file"` - LogFile string `json:"log_file"` - } `json:"storage"` + DataDir string + } - API struct { - BaseURL string `json:"base_url"` - RequestTimeout time.Duration `json:"request_timeout"` - } `json:"api"` + OAuth struct { + ClientID string + ClientSecret string + } - Performance struct { - MaxConcurrentRequests int `json:"max_concurrent_requests"` - RequestTimeout time.Duration `json:"request_timeout"` - CacheTTL time.Duration `json:"cache_ttl"` - EnableCompression bool `json:"enable_compression"` - } `json:"performance"` + App struct { + BaseURL string + } } var ( @@ -47,87 +36,67 @@ var ( RNG *rand.Rand ) -func Load(configFile string) error { - // 尝试创建配置目录 - configDir := filepath.Dir(configFile) - if err := os.MkdirAll(configDir, 0755); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) - } - - // 检查配置文件是否存在 - if _, err := os.Stat(configFile); os.IsNotExist(err) { - // 创建默认配置 - defaultConfig := Config{ - Server: struct { - Port string `json:"port"` - ReadTimeout time.Duration `json:"read_timeout"` - WriteTimeout time.Duration `json:"write_timeout"` - MaxHeaderBytes int `json:"max_header_bytes"` - }{ - Port: ":5003", - ReadTimeout: 30 * time.Second, - WriteTimeout: 30 * time.Second, - MaxHeaderBytes: 1 << 20, - }, - Storage: struct { - DataDir string `json:"data_dir"` - StatsFile string `json:"stats_file"` - LogFile string `json:"log_file"` - }{ - DataDir: "/root/data", - StatsFile: "/root/data/stats.json", - LogFile: "/root/data/logs/server.log", - }, - API: struct { - BaseURL string `json:"base_url"` - RequestTimeout time.Duration `json:"request_timeout"` - }{ - BaseURL: "", - RequestTimeout: 10 * time.Second, - }, - Performance: struct { - MaxConcurrentRequests int `json:"max_concurrent_requests"` - RequestTimeout time.Duration `json:"request_timeout"` - CacheTTL time.Duration `json:"cache_ttl"` - EnableCompression bool `json:"enable_compression"` - }{ - MaxConcurrentRequests: 100, - RequestTimeout: 10 * time.Second, - CacheTTL: 1 * time.Hour, - EnableCompression: true, - }, - } - - // 将默认配置写入文件 - data, err := json.MarshalIndent(defaultConfig, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal default config: %w", err) - } - - if err := os.WriteFile(configFile, data, 0644); err != nil { - return fmt.Errorf("failed to write default config: %w", err) - } - - cfg = defaultConfig - return nil - } - - // 读取现有配置文件 - file, err := os.Open(configFile) +// loadEnvFile 加载.env文件 +func loadEnvFile() error { + file, err := os.Open(".env") if err != nil { - return err + return err // .env文件不存在,这是正常的 } defer file.Close() - decoder := json.NewDecoder(file) - if err := decoder.Decode(&cfg); err != nil { - return err + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // 跳过空行和注释 + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // 解析键值对 + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + // 移除引号 + if (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) || + (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) { + value = value[1 : len(value)-1] + } + + // 只有当环境变量不存在时才设置 + if os.Getenv(key) == "" { + os.Setenv(key, value) + } } - // 如果环境变量设置了 BASE_URL,则覆盖配置文件中的设置 - if envBaseURL := os.Getenv(EnvBaseURL); envBaseURL != "" { - cfg.API.BaseURL = envBaseURL - } + return scanner.Err() +} + +// Load 从环境变量加载配置 +func Load() error { + // 首先尝试加载.env文件 + loadEnvFile() // 忽略错误,因为.env文件是可选的 + + // 服务器配置 + cfg.Server.Port = getEnv("PORT", ":5003") + cfg.Server.ReadTimeout = getDurationEnv("READ_TIMEOUT", 30*time.Second) + cfg.Server.WriteTimeout = getDurationEnv("WRITE_TIMEOUT", 30*time.Second) + cfg.Server.MaxHeaderBytes = getIntEnv("MAX_HEADER_BYTES", 1<<20) + + // 存储配置 + cfg.Storage.DataDir = getEnv("DATA_DIR", "./data") + + // OAuth配置 + cfg.OAuth.ClientID = getEnv("OAUTH_CLIENT_ID", "") + cfg.OAuth.ClientSecret = getEnv("OAUTH_CLIENT_SECRET", "") + + // 应用配置 + cfg.App.BaseURL = getEnv("BASE_URL", "http://localhost:5003") return nil } @@ -139,3 +108,31 @@ func Get() *Config { func InitRNG(r *rand.Rand) { RNG = r } + +// getEnv 获取环境变量,如果不存在则返回默认值 +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// getIntEnv 获取整数类型的环境变量 +func getIntEnv(key string, defaultValue int) int { + if value := os.Getenv(key); value != "" { + if intValue, err := strconv.Atoi(value); err == nil { + return intValue + } + } + return defaultValue +} + +// getDurationEnv 获取时间间隔类型的环境变量 +func getDurationEnv(key string, defaultValue time.Duration) time.Duration { + if value := os.Getenv(key); value != "" { + if duration, err := time.ParseDuration(value); err == nil { + return duration + } + } + return defaultValue +} diff --git a/data/config.json b/data/config.json deleted file mode 100644 index 4991003..0000000 --- a/data/config.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "server": { - "port": ":5003", - "read_timeout": "30s", - "write_timeout": "30s", - "max_header_bytes": 1048576 - }, - "storage": { - "data_dir": "/root/data", - "stats_file": "/root/data/stats.json", - "log_file": "/var/log/random-api/server.log" - }, - "api": { - "base_url": "", - "request_timeout": "10s" - } -} \ No newline at end of file diff --git a/database/database.go b/database/database.go new file mode 100644 index 0000000..d99c5e3 --- /dev/null +++ b/database/database.go @@ -0,0 +1,133 @@ +package database + +import ( + "fmt" + "log" + "os" + "path/filepath" + "time" + + "random-api-go/models" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var DB *gorm.DB + +// Initialize 初始化数据库 +func Initialize(dataDir string) error { + // 确保数据目录存在 + if err := os.MkdirAll(dataDir, 0755); err != nil { + return fmt.Errorf("failed to create data directory: %w", err) + } + + dbPath := filepath.Join(dataDir, "data.db") + + // 配置GORM + config := &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), + } + + var err error + DB, err = gorm.Open(sqlite.Open(dbPath), config) + if err != nil { + return fmt.Errorf("failed to open database: %w", err) + } + + // 获取底层的sql.DB来设置连接池参数 + sqlDB, err := DB.DB() + if err != nil { + return fmt.Errorf("failed to get underlying sql.DB: %w", err) + } + + // 设置连接池参数 + sqlDB.SetMaxOpenConns(1) // SQLite建议单连接 + sqlDB.SetMaxIdleConns(1) + sqlDB.SetConnMaxLifetime(time.Hour) + + // 自动迁移数据库结构 + if err := autoMigrate(); err != nil { + return fmt.Errorf("failed to migrate database: %w", err) + } + + log.Printf("Database initialized successfully at %s", dbPath) + return nil +} + +// autoMigrate 自动迁移数据库结构 +func autoMigrate() error { + return DB.AutoMigrate( + &models.APIEndpoint{}, + &models.DataSource{}, + &models.URLReplaceRule{}, + &models.CachedURL{}, + &models.Config{}, + ) +} + +// Close 关闭数据库连接 +func Close() error { + if DB != nil { + sqlDB, err := DB.DB() + if err != nil { + return err + } + return sqlDB.Close() + } + return nil +} + +// CleanExpiredCache 清理过期缓存 +func CleanExpiredCache() error { + return DB.Where("expires_at < ?", time.Now()).Delete(&models.CachedURL{}).Error +} + +// GetConfig 获取配置值 +func GetConfig(key string, defaultValue string) string { + var config models.Config + if err := DB.Where("key = ?", key).First(&config).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return defaultValue + } + log.Printf("Failed to get config %s: %v", key, err) + return defaultValue + } + return config.Value +} + +// SetConfig 设置配置值 +func SetConfig(key, value, configType string) error { + var config models.Config + err := DB.Where("key = ?", key).First(&config).Error + + if err == gorm.ErrRecordNotFound { + // 创建新配置 + config = models.Config{ + Key: key, + Value: value, + Type: configType, + } + return DB.Create(&config).Error + } else if err != nil { + return err + } + + // 更新现有配置 + config.Value = value + config.Type = configType + return DB.Save(&config).Error +} + +// ListConfigs 列出所有配置 +func ListConfigs() ([]models.Config, error) { + var configs []models.Config + err := DB.Order("key").Find(&configs).Error + return configs, err +} + +// DeleteConfig 删除配置 +func DeleteConfig(key string) error { + return DB.Where("key = ?", key).Delete(&models.Config{}).Error +} diff --git a/docker-compose.yml b/docker-compose.yml index e885b9d..8fafcd7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,5 +8,7 @@ services: - ./data:/root/data environment: - TZ=Asia/Shanghai - - BASE_URL=https://example.net/random-api + - BASE_URL=https://random-api.czl.net + - OAUTH_CLIENT_ID=1234567890 + - OAUTH_CLIENT_SECRET=1234567890 restart: unless-stopped diff --git a/go.mod b/go.mod index cb4556e..6d4ba0f 100644 --- a/go.mod +++ b/go.mod @@ -4,4 +4,24 @@ go 1.23.0 toolchain go1.23.1 -require golang.org/x/time v0.11.0 +require ( + github.com/glebarez/sqlite v1.11.0 + golang.org/x/time v0.11.0 + gorm.io/gorm v1.30.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/google/uuid v1.5.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.26.0 // indirect + modernc.org/libc v1.37.6 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.7.2 // indirect + modernc.org/sqlite v1.28.0 // indirect +) diff --git a/go.sum b/go.sum index 6980d24..9805dbe 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,35 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw= +modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ= +modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0= diff --git a/handlers/admin_handler.go b/handlers/admin_handler.go new file mode 100644 index 0000000..f22a0e5 --- /dev/null +++ b/handlers/admin_handler.go @@ -0,0 +1,1127 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "random-api-go/config" + "random-api-go/database" + "random-api-go/models" + "random-api-go/services" + "strconv" + "strings" + + "gorm.io/gorm" +) + +// AdminHandler 管理后台处理器 +type AdminHandler struct { + endpointService *services.EndpointService +} + +// NewAdminHandler 创建管理后台处理器 +func NewAdminHandler() *AdminHandler { + return &AdminHandler{ + endpointService: services.GetEndpointService(), + } +} + +// ListEndpoints 列出所有端点 +func (h *AdminHandler) ListEndpoints(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + endpoints, err := h.endpointService.ListEndpoints() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to list endpoints: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": endpoints, + }) +} + +// CreateEndpoint 创建端点 +func (h *AdminHandler) CreateEndpoint(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var endpoint models.APIEndpoint + if err := json.NewDecoder(r.Body).Decode(&endpoint); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + // 验证必填字段 + if endpoint.Name == "" || endpoint.URL == "" { + http.Error(w, "Name and URL are required", http.StatusBadRequest) + return + } + + if err := h.endpointService.CreateEndpoint(&endpoint); err != nil { + http.Error(w, fmt.Sprintf("Failed to create endpoint: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": endpoint, + }) +} + +// GetEndpoint 获取端点详情 +func (h *AdminHandler) GetEndpoint(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + idStr := strings.TrimPrefix(r.URL.Path, "/api/admin/endpoints/") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + http.Error(w, "Invalid endpoint ID", http.StatusBadRequest) + return + } + + endpoint, err := h.endpointService.GetEndpoint(uint(id)) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to get endpoint: %v", err), http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": endpoint, + }) +} + +// UpdateEndpoint 更新端点 +func (h *AdminHandler) UpdateEndpoint(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + idStr := strings.TrimPrefix(r.URL.Path, "/api/admin/endpoints/") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + http.Error(w, "Invalid endpoint ID", http.StatusBadRequest) + return + } + + var endpoint models.APIEndpoint + if err := json.NewDecoder(r.Body).Decode(&endpoint); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + endpoint.ID = uint(id) + if err := h.endpointService.UpdateEndpoint(&endpoint); err != nil { + http.Error(w, fmt.Sprintf("Failed to update endpoint: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": endpoint, + }) +} + +// DeleteEndpoint 删除端点 +func (h *AdminHandler) DeleteEndpoint(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + idStr := strings.TrimPrefix(r.URL.Path, "/api/admin/endpoints/") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + http.Error(w, "Invalid endpoint ID", http.StatusBadRequest) + return + } + + if err := h.endpointService.DeleteEndpoint(uint(id)); err != nil { + http.Error(w, fmt.Sprintf("Failed to delete endpoint: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Endpoint deleted successfully", + }) +} + +// CreateDataSource 创建数据源 +func (h *AdminHandler) CreateDataSource(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var dataSource models.DataSource + if err := json.NewDecoder(r.Body).Decode(&dataSource); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + // 验证必填字段 + if dataSource.Name == "" || dataSource.Type == "" || dataSource.Config == "" { + http.Error(w, "Name, Type and Config are required", http.StatusBadRequest) + return + } + + // 使用服务创建数据源(会自动预加载) + if err := h.endpointService.CreateDataSource(&dataSource); err != nil { + http.Error(w, fmt.Sprintf("Failed to create data source: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": dataSource, + }) +} + +// HandleEndpointDataSources 处理端点数据源相关请求 +func (h *AdminHandler) HandleEndpointDataSources(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + h.ListEndpointDataSources(w, r) + case http.MethodPost: + h.CreateEndpointDataSource(w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +// ListEndpointDataSources 列出指定端点的数据源 +func (h *AdminHandler) ListEndpointDataSources(w http.ResponseWriter, r *http.Request) { + // 从URL路径中提取端点ID + path := r.URL.Path + // 路径格式: /api/admin/endpoints/{id}/data-sources + parts := strings.Split(path, "/") + if len(parts) < 5 { + http.Error(w, "Invalid endpoint ID", http.StatusBadRequest) + return + } + + endpointIDStr := parts[4] + endpointID, err := strconv.Atoi(endpointIDStr) + if err != nil { + http.Error(w, "Invalid endpoint ID", http.StatusBadRequest) + return + } + + var dataSources []models.DataSource + if err := database.DB.Where("endpoint_id = ?", endpointID).Order("created_at DESC").Find(&dataSources).Error; err != nil { + http.Error(w, fmt.Sprintf("Failed to query data sources: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": dataSources, + }) +} + +// CreateEndpointDataSource 为指定端点创建数据源 +func (h *AdminHandler) CreateEndpointDataSource(w http.ResponseWriter, r *http.Request) { + // 从URL路径中提取端点ID + path := r.URL.Path + // 路径格式: /api/admin/endpoints/{id}/data-sources + parts := strings.Split(path, "/") + if len(parts) < 5 { + http.Error(w, "Invalid endpoint ID", http.StatusBadRequest) + return + } + + endpointIDStr := parts[4] + endpointID, err := strconv.Atoi(endpointIDStr) + if err != nil { + http.Error(w, "Invalid endpoint ID", http.StatusBadRequest) + return + } + + var dataSource models.DataSource + if err := json.NewDecoder(r.Body).Decode(&dataSource); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + // 设置端点ID + dataSource.EndpointID = uint(endpointID) + + // 验证必填字段 + if dataSource.Name == "" || dataSource.Type == "" || dataSource.Config == "" { + http.Error(w, "Name, Type and Config are required", http.StatusBadRequest) + return + } + + // 验证端点是否存在 + var endpoint models.APIEndpoint + if err := database.DB.First(&endpoint, endpointID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + http.Error(w, "Endpoint not found", http.StatusNotFound) + return + } + http.Error(w, fmt.Sprintf("Failed to verify endpoint: %v", err), http.StatusInternalServerError) + return + } + + // 使用服务创建数据源(会自动预加载) + if err := h.endpointService.CreateDataSource(&dataSource); err != nil { + http.Error(w, fmt.Sprintf("Failed to create data source: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": dataSource, + }) +} + +// HandleDataSourceByID 处理特定数据源的请求 +func (h *AdminHandler) HandleDataSourceByID(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + h.GetDataSource(w, r) + case http.MethodPut: + h.UpdateDataSource(w, r) + case http.MethodDelete: + h.DeleteDataSource(w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +// GetDataSource 获取数据源详情 +func (h *AdminHandler) GetDataSource(w http.ResponseWriter, r *http.Request) { + // 从URL路径中提取数据源ID + path := r.URL.Path + // 路径格式: /api/admin/data-sources/{id} + parts := strings.Split(path, "/") + if len(parts) < 4 { + http.Error(w, "Invalid data source ID", http.StatusBadRequest) + return + } + + dataSourceIDStr := parts[4] + dataSourceID, err := strconv.Atoi(dataSourceIDStr) + if err != nil { + http.Error(w, "Invalid data source ID", http.StatusBadRequest) + return + } + + var dataSource models.DataSource + if err := database.DB.First(&dataSource, dataSourceID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + http.Error(w, "Data source not found", http.StatusNotFound) + return + } + http.Error(w, fmt.Sprintf("Failed to get data source: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": dataSource, + }) +} + +// UpdateDataSource 更新数据源 +func (h *AdminHandler) UpdateDataSource(w http.ResponseWriter, r *http.Request) { + // 从URL路径中提取数据源ID + path := r.URL.Path + // 路径格式: /api/admin/data-sources/{id} + parts := strings.Split(path, "/") + if len(parts) < 4 { + http.Error(w, "Invalid data source ID", http.StatusBadRequest) + return + } + + dataSourceIDStr := parts[4] + dataSourceID, err := strconv.Atoi(dataSourceIDStr) + if err != nil { + http.Error(w, "Invalid data source ID", http.StatusBadRequest) + return + } + + var dataSource models.DataSource + if err := database.DB.First(&dataSource, dataSourceID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + http.Error(w, "Data source not found", http.StatusNotFound) + return + } + http.Error(w, fmt.Sprintf("Failed to get data source: %v", err), http.StatusInternalServerError) + return + } + + var updateData models.DataSource + if err := json.NewDecoder(r.Body).Decode(&updateData); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + // 更新字段 + if updateData.Name != "" { + dataSource.Name = updateData.Name + } + if updateData.Type != "" { + dataSource.Type = updateData.Type + } + if updateData.Config != "" { + dataSource.Config = updateData.Config + } + if updateData.CacheDuration != 0 { + dataSource.CacheDuration = updateData.CacheDuration + } + dataSource.IsActive = updateData.IsActive + + // 使用服务更新数据源(会自动预加载) + if err := h.endpointService.UpdateDataSource(&dataSource); err != nil { + http.Error(w, fmt.Sprintf("Failed to update data source: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": dataSource, + }) +} + +// DeleteDataSource 删除数据源 +func (h *AdminHandler) DeleteDataSource(w http.ResponseWriter, r *http.Request) { + // 从URL路径中提取数据源ID + path := r.URL.Path + // 路径格式: /api/admin/data-sources/{id} + parts := strings.Split(path, "/") + if len(parts) < 4 { + http.Error(w, "Invalid data source ID", http.StatusBadRequest) + return + } + + dataSourceIDStr := parts[4] + dataSourceID, err := strconv.Atoi(dataSourceIDStr) + if err != nil { + http.Error(w, "Invalid data source ID", http.StatusBadRequest) + return + } + + // 使用服务删除数据源 + if err := h.endpointService.DeleteDataSource(uint(dataSourceID)); err != nil { + http.Error(w, fmt.Sprintf("Failed to delete data source: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Data source deleted successfully", + }) +} + +// SyncDataSource 同步数据源 +func (h *AdminHandler) SyncDataSource(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // 从URL路径中提取数据源ID + path := r.URL.Path + // 路径格式: /api/admin/data-sources/{id}/sync + parts := strings.Split(path, "/") + if len(parts) < 5 { + http.Error(w, "Invalid data source ID", http.StatusBadRequest) + return + } + + dataSourceIDStr := parts[4] + dataSourceID, err := strconv.Atoi(dataSourceIDStr) + if err != nil { + http.Error(w, "Invalid data source ID", http.StatusBadRequest) + return + } + + var dataSource models.DataSource + if err := database.DB.First(&dataSource, dataSourceID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + http.Error(w, "Data source not found", http.StatusNotFound) + return + } + http.Error(w, fmt.Sprintf("Failed to get data source: %v", err), http.StatusInternalServerError) + return + } + + // 使用服务刷新数据源 + if err := h.endpointService.RefreshDataSource(uint(dataSourceID)); err != nil { + http.Error(w, fmt.Sprintf("Failed to sync data source: %v", err), http.StatusInternalServerError) + return + } + + // 重新获取更新后的数据源信息 + if err := database.DB.First(&dataSource, dataSourceID).Error; err != nil { + http.Error(w, fmt.Sprintf("Failed to get updated data source: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Data source synced successfully", + "data": dataSource, + }) +} + +// ListURLReplaceRules 列出URL替换规则 +func (h *AdminHandler) ListURLReplaceRules(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var rules []models.URLReplaceRule + if err := database.DB.Preload("Endpoint").Order("created_at DESC").Find(&rules).Error; err != nil { + http.Error(w, fmt.Sprintf("Failed to query URL replace rules: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": rules, + }) +} + +// CreateURLReplaceRule 创建URL替换规则 +func (h *AdminHandler) CreateURLReplaceRule(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var rule models.URLReplaceRule + if err := json.NewDecoder(r.Body).Decode(&rule); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + // 验证必填字段 + if rule.Name == "" || rule.FromURL == "" || rule.ToURL == "" { + http.Error(w, "Name, FromURL and ToURL are required", http.StatusBadRequest) + return + } + + // 使用GORM创建URL替换规则 + if err := database.DB.Create(&rule).Error; err != nil { + http.Error(w, fmt.Sprintf("Failed to create URL replace rule: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": rule, + }) +} + +// HandleURLReplaceRuleByID 处理URL替换规则的更新和删除操作 +func (h *AdminHandler) HandleURLReplaceRuleByID(w http.ResponseWriter, r *http.Request) { + // 从URL路径中提取规则ID + path := r.URL.Path + // 路径格式: /api/admin/url-replace-rules/{id} + parts := strings.Split(path, "/") + if len(parts) < 5 { + http.Error(w, "Invalid rule ID", http.StatusBadRequest) + return + } + + ruleIDStr := parts[4] + ruleID, err := strconv.Atoi(ruleIDStr) + if err != nil { + http.Error(w, "Invalid rule ID", http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodPut: + h.updateURLReplaceRule(w, r, uint(ruleID)) + case http.MethodDelete: + h.deleteURLReplaceRule(w, r, uint(ruleID)) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +// updateURLReplaceRule 更新URL替换规则 +func (h *AdminHandler) updateURLReplaceRule(w http.ResponseWriter, r *http.Request, ruleID uint) { + var rule models.URLReplaceRule + if err := json.NewDecoder(r.Body).Decode(&rule); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + // 验证必填字段 + if rule.Name == "" || rule.FromURL == "" || rule.ToURL == "" { + http.Error(w, "Name, FromURL and ToURL are required", http.StatusBadRequest) + return + } + + // 检查规则是否存在 + var existingRule models.URLReplaceRule + if err := database.DB.First(&existingRule, ruleID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + http.Error(w, "URL replace rule not found", http.StatusNotFound) + return + } + http.Error(w, fmt.Sprintf("Failed to get URL replace rule: %v", err), http.StatusInternalServerError) + return + } + + // 更新规则 + rule.ID = ruleID + if err := database.DB.Save(&rule).Error; err != nil { + http.Error(w, fmt.Sprintf("Failed to update URL replace rule: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": rule, + }) +} + +// deleteURLReplaceRule 删除URL替换规则 +func (h *AdminHandler) deleteURLReplaceRule(w http.ResponseWriter, r *http.Request, ruleID uint) { + // 检查规则是否存在 + var rule models.URLReplaceRule + if err := database.DB.First(&rule, ruleID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + http.Error(w, "URL replace rule not found", http.StatusNotFound) + return + } + http.Error(w, fmt.Sprintf("Failed to get URL replace rule: %v", err), http.StatusInternalServerError) + return + } + + // 删除规则 + if err := database.DB.Delete(&rule, ruleID).Error; err != nil { + http.Error(w, fmt.Sprintf("Failed to delete URL replace rule: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "URL replace rule deleted successfully", + }) +} + +// GetHomePageConfig 获取首页配置 +func (h *AdminHandler) GetHomePageConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + content := database.GetConfig("homepage_content", "") + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": map[string]string{"content": content}, + }) +} + +// UpdateHomePageConfig 更新首页配置 +func (h *AdminHandler) UpdateHomePageConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost && r.Method != http.MethodPut { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var requestData struct { + Content string `json:"content"` + } + + if err := json.NewDecoder(r.Body).Decode(&requestData); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + // 设置首页配置 + if err := database.SetConfig("homepage_content", requestData.Content, "string"); err != nil { + http.Error(w, fmt.Sprintf("Failed to update home page config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Home page config updated successfully", + }) +} + +// GetOAuthConfig 获取OAuth配置 +func (h *AdminHandler) GetOAuthConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + cfg := config.Get() + + // 检查OAuth配置是否完整 + if cfg.OAuth.ClientID == "" || cfg.OAuth.ClientSecret == "" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "error": "OAuth配置未设置,请检查环境变量OAUTH_CLIENT_ID和OAUTH_CLIENT_SECRET", + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": map[string]string{ + "client_id": cfg.OAuth.ClientID, + "base_url": cfg.App.BaseURL, + // 不返回client_secret,出于安全考虑 + }, + }) +} + +// VerifyOAuthToken 验证OAuth令牌 +func (h *AdminHandler) VerifyOAuthToken(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var request struct { + Code string `json:"code"` + RedirectURI string `json:"redirect_uri,omitempty"` + } + + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + if request.Code == "" { + http.Error(w, "Authorization code is required", http.StatusBadRequest) + return + } + + // 如果没有提供redirect_uri,使用默认值 + redirectURI := request.RedirectURI + if redirectURI == "" { + // 从请求头中获取Origin,构建redirect_uri + origin := r.Header.Get("Origin") + if origin == "" { + origin = "http://localhost:3000" // 默认值 + } + redirectURI = origin + "/admin/callback" + } + + cfg := config.Get() + + // 使用授权码换取访问令牌 + tokenResp, err := h.exchangeCodeForToken(request.Code, cfg.OAuth.ClientID, cfg.OAuth.ClientSecret, redirectURI) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to exchange code for token: %v", err), http.StatusUnauthorized) + return + } + + // 验证令牌并获取用户信息 + userInfo, err := h.getUserInfo(tokenResp.AccessToken) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to get user info: %v", err), http.StatusUnauthorized) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": map[string]interface{}{ + "access_token": tokenResp.AccessToken, + "user_info": userInfo, + }, + }) +} + +// TokenResponse OAuth令牌响应结构 +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` +} + +// UserInfo 用户信息结构 +type UserInfo struct { + ID int `json:"id"` // CZL Connect返回的是数字ID + Username string `json:"username"` + Nickname string `json:"nickname"` + Email string `json:"email"` + Avatar string `json:"avatar"` +} + +// exchangeCodeForToken 使用授权码换取访问令牌 +func (h *AdminHandler) exchangeCodeForToken(code, clientID, clientSecret, redirectURI string) (*TokenResponse, error) { + // 检查必要的OAuth配置 + if clientID == "" || clientSecret == "" { + return nil, fmt.Errorf("OAuth配置缺失: client_id=%s, client_secret=%s", clientID, clientSecret) + } + + // 记录调试信息(不包含敏感信息) + log.Printf("OAuth token exchange: client_id=%s, redirect_uri=%s", clientID, redirectURI) + + // 尝试方法1:使用Basic Auth进行客户端认证 + tokenResp, err := h.tryTokenExchangeWithBasicAuth(code, clientID, clientSecret, redirectURI) + if err == nil { + return tokenResp, nil + } + + log.Printf("Basic Auth failed, trying with client credentials in body: %v", err) + + // 尝试方法2:在请求体中发送client credentials + return h.tryTokenExchangeWithBodyAuth(code, clientID, clientSecret, redirectURI) +} + +// tryTokenExchangeWithBasicAuth 使用Basic Auth进行token交换 +func (h *AdminHandler) tryTokenExchangeWithBasicAuth(code, clientID, clientSecret, redirectURI string) (*TokenResponse, error) { + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("code", code) + data.Set("redirect_uri", redirectURI) + + req, err := http.NewRequest("POST", "https://connect.czl.net/api/oauth2/token", strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(clientID, clientSecret) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %v", err) + } + defer resp.Body.Close() + + return h.parseTokenResponse(resp, "Basic Auth") +} + +// tryTokenExchangeWithBodyAuth 在请求体中发送client credentials +func (h *AdminHandler) tryTokenExchangeWithBodyAuth(code, clientID, clientSecret, redirectURI string) (*TokenResponse, error) { + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("code", code) + data.Set("client_id", clientID) + data.Set("client_secret", clientSecret) + data.Set("redirect_uri", redirectURI) + + resp, err := http.Post("https://connect.czl.net/api/oauth2/token", + "application/x-www-form-urlencoded", + strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %v", err) + } + defer resp.Body.Close() + + return h.parseTokenResponse(resp, "Body Auth") +} + +// parseTokenResponse 解析token响应 +func (h *AdminHandler) parseTokenResponse(resp *http.Response, method string) (*TokenResponse, error) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %v", err) + } + + log.Printf("OAuth token response (%s): status=%d, body_length=%d", method, resp.StatusCode, len(body)) + + if resp.StatusCode != http.StatusOK { + log.Printf("OAuth token exchange failed (%s): status=%d, response=%s", method, resp.StatusCode, string(body)) + return nil, fmt.Errorf("token request failed with status: %d, body: %s", resp.StatusCode, string(body)) + } + + var tokenResp TokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("failed to parse token response: %v, body: %s", err, string(body)) + } + + return &tokenResp, nil +} + +// getUserInfo 获取用户信息 +func (h *AdminHandler) getUserInfo(accessToken string) (*UserInfo, error) { + req, err := http.NewRequest("GET", "https://connect.czl.net/api/oauth2/userinfo", nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+accessToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("userinfo request failed with status: %d", resp.StatusCode) + } + + var userInfo UserInfo + if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil { + return nil, err + } + + return &userInfo, nil +} + +// HandleEndpoints 处理端点列表相关请求 +func (h *AdminHandler) HandleEndpoints(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + h.ListEndpoints(w, r) + case http.MethodPost: + h.CreateEndpoint(w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +// HandleEndpointByID 处理特定端点的请求 +func (h *AdminHandler) HandleEndpointByID(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + h.GetEndpoint(w, r) + case http.MethodPut: + h.UpdateEndpoint(w, r) + case http.MethodDelete: + h.DeleteEndpoint(w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +// HandleOAuthCallback 处理OAuth回调 +func (h *AdminHandler) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // 获取授权码和state + code := r.URL.Query().Get("code") + state := r.URL.Query().Get("state") + errorParam := r.URL.Query().Get("error") + + if errorParam != "" { + // OAuth授权失败,重定向到前端错误页面 + http.Redirect(w, r, fmt.Sprintf("/admin?error=%s", errorParam), http.StatusFound) + return + } + + if code == "" { + // 没有授权码,重定向到前端错误页面 + http.Redirect(w, r, "/admin?error=no_code", http.StatusFound) + return + } + + // 注意:在实际应用中,应该验证state参数防止CSRF攻击 + // 这里我们记录state但不强制验证,因为前端state存储在localStorage中 + log.Printf("OAuth callback: code received, state=%s", state) + + cfg := config.Get() + + // 使用配置的BASE_URL构建回调地址 + redirectURI := fmt.Sprintf("%s/api/admin/oauth/callback", cfg.App.BaseURL) + log.Printf("OAuth callback redirect_uri: %s (from BASE_URL: %s)", redirectURI, cfg.App.BaseURL) + + // 使用授权码换取访问令牌 + tokenResp, err := h.exchangeCodeForToken(code, cfg.OAuth.ClientID, cfg.OAuth.ClientSecret, redirectURI) + if err != nil { + // 令牌交换失败,重定向到前端错误页面 + http.Redirect(w, r, fmt.Sprintf("/admin?error=token_exchange_failed&details=%s", err.Error()), http.StatusFound) + return + } + + // 验证令牌并获取用户信息 + userInfo, err := h.getUserInfo(tokenResp.AccessToken) + if err != nil { + // 获取用户信息失败,重定向到前端错误页面 + http.Redirect(w, r, fmt.Sprintf("/admin?error=userinfo_failed&details=%s", err.Error()), http.StatusFound) + return + } + + // 成功获取令牌和用户信息,重定向到前端并传递token + // 注意:在生产环境中,应该使用更安全的方式传递token,比如设置HttpOnly cookie + redirectURL := fmt.Sprintf("/admin?token=%s&user=%s&state=%s", + tokenResp.AccessToken, + userInfo.Username, + state) + + http.Redirect(w, r, redirectURL, http.StatusFound) +} + +// UpdateEndpointSortOrder 更新端点排序 +func (h *AdminHandler) UpdateEndpointSortOrder(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var request struct { + EndpointOrders []struct { + ID uint `json:"id"` + SortOrder int `json:"sort_order"` + } `json:"endpoint_orders"` + } + + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + // 批量更新排序 + for _, order := range request.EndpointOrders { + if err := database.DB.Model(&models.APIEndpoint{}). + Where("id = ?", order.ID). + Update("sort_order", order.SortOrder).Error; err != nil { + http.Error(w, fmt.Sprintf("Failed to update sort order: %v", err), http.StatusInternalServerError) + return + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Sort order updated successfully", + }) +} + +// ListConfigs 列出所有配置 +func (h *AdminHandler) ListConfigs(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + configs, err := database.ListConfigs() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to list configs: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": configs, + }) +} + +// CreateOrUpdateConfig 创建或更新配置 +func (h *AdminHandler) CreateOrUpdateConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost && r.Method != http.MethodPut { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var requestData struct { + Key string `json:"key"` + Value string `json:"value"` + Type string `json:"type"` + } + + if err := json.NewDecoder(r.Body).Decode(&requestData); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + if requestData.Key == "" { + http.Error(w, "Key is required", http.StatusBadRequest) + return + } + + if requestData.Type == "" { + requestData.Type = "string" + } + + if err := database.SetConfig(requestData.Key, requestData.Value, requestData.Type); err != nil { + http.Error(w, fmt.Sprintf("Failed to set config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Config updated successfully", + }) +} + +// DeleteConfigByKey 删除配置 +func (h *AdminHandler) DeleteConfigByKey(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // 从URL路径中提取配置键 + path := r.URL.Path + parts := strings.Split(path, "/") + if len(parts) < 4 { + http.Error(w, "Invalid config key", http.StatusBadRequest) + return + } + key := parts[len(parts)-1] + + if key == "" { + http.Error(w, "Config key is required", http.StatusBadRequest) + return + } + + // 防止删除重要配置 + if key == "homepage_content" { + http.Error(w, "Cannot delete homepage_content config", http.StatusForbidden) + return + } + + if err := database.DeleteConfig(key); err != nil { + http.Error(w, fmt.Sprintf("Failed to delete config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Config deleted successfully", + }) +} diff --git a/handlers/api_handler.go b/handlers/api_handler.go deleted file mode 100644 index 42650f6..0000000 --- a/handlers/api_handler.go +++ /dev/null @@ -1,108 +0,0 @@ -package handlers - -import ( - "encoding/json" - "fmt" - "log" - "net/http" - "random-api-go/monitoring" - "random-api-go/services" - "random-api-go/stats" - "random-api-go/utils" - - "strings" - "time" -) - -var statsManager *stats.StatsManager - -// InitializeHandlers 初始化处理器 -func InitializeHandlers(sm *stats.StatsManager) error { - statsManager = sm - return services.InitializeCSVService() -} - -func HandleAPIRequest(w http.ResponseWriter, r *http.Request) { - start := time.Now() - realIP := utils.GetRealIP(r) - - path := strings.TrimPrefix(r.URL.Path, "/") - pathSegments := strings.Split(path, "/") - - if len(pathSegments) < 2 { - monitoring.LogRequest(monitoring.RequestLog{ - Time: time.Now().Unix(), - Path: r.URL.Path, - Method: r.Method, - StatusCode: http.StatusNotFound, - Latency: float64(time.Since(start).Microseconds()) / 1000, - IP: realIP, - Referer: r.Referer(), - }) - http.NotFound(w, r) - return - } - - prefix := pathSegments[0] - suffix := pathSegments[1] - - services.Mu.RLock() - csvPath, ok := services.CSVPathsCache[prefix][suffix] - services.Mu.RUnlock() - - if !ok { - http.NotFound(w, r) - return - } - - selector, err := services.GetCSVContent(csvPath) - if err != nil { - http.Error(w, "Failed to fetch CSV content", http.StatusInternalServerError) - log.Printf("Error fetching CSV content: %v", err) - return - } - - if len(selector.URLs) == 0 { - http.Error(w, "No content available", http.StatusNotFound) - return - } - - randomURL := selector.GetRandomURL() - - // 记录统计 - endpoint := fmt.Sprintf("%s/%s", prefix, suffix) - statsManager.IncrementCalls(endpoint) - - duration := time.Since(start) - - // 记录请求日志 - monitoring.LogRequest(monitoring.RequestLog{ - Time: time.Now().Unix(), - Path: r.URL.Path, - Method: r.Method, - StatusCode: http.StatusFound, - Latency: float64(duration.Microseconds()) / 1000, // 转换为毫秒 - IP: realIP, - Referer: r.Referer(), - }) - - log.Printf(" %-12s | %-15s | %-6s | %-20s | %-20s | %-50s", - duration, // 持续时间 - realIP, // 真实IP - r.Method, // HTTP方法 - r.URL.Path, // 请求路径 - r.Referer(), // 来源信息 - randomURL, // 重定向URL - ) - - http.Redirect(w, r, randomURL, http.StatusFound) -} - -func HandleStats(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - stats := statsManager.GetStats() - if err := json.NewEncoder(w).Encode(stats); err != nil { - http.Error(w, "Error encoding stats", http.StatusInternalServerError) - log.Printf("Error encoding stats: %v", err) - } -} diff --git a/handlers/handlers.go b/handlers/handlers.go index b778bd3..2236e80 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -20,7 +20,8 @@ type Router interface { } type Handlers struct { - Stats *stats.StatsManager + Stats *stats.StatsManager + endpointService *services.EndpointService } func (h *Handlers) HandleAPIRequest(w http.ResponseWriter, r *http.Request) { @@ -39,60 +40,15 @@ func (h *Handlers) HandleAPIRequest(w http.ResponseWriter, r *http.Request) { realIP := utils.GetRealIP(r) path := strings.TrimPrefix(r.URL.Path, "/") - pathSegments := strings.Split(path, "/") - if len(pathSegments) < 2 { - monitoring.LogRequest(monitoring.RequestLog{ - Time: time.Now().UnixMilli(), - Path: r.URL.Path, - Method: r.Method, - StatusCode: http.StatusNotFound, - Latency: float64(time.Since(start).Microseconds()) / 1000, - IP: realIP, - Referer: r.Referer(), - }) - resultChan <- result{err: fmt.Errorf("not found")} - return + // 初始化端点服务 + if h.endpointService == nil { + h.endpointService = services.GetEndpointService() } - prefix := pathSegments[0] - suffix := pathSegments[1] - - services.Mu.RLock() - csvPath, ok := services.CSVPathsCache[prefix][suffix] - services.Mu.RUnlock() - - if !ok { - monitoring.LogRequest(monitoring.RequestLog{ - Time: time.Now().UnixMilli(), - Path: r.URL.Path, - Method: r.Method, - StatusCode: http.StatusNotFound, - Latency: float64(time.Since(start).Microseconds()) / 1000, - IP: realIP, - Referer: r.Referer(), - }) - resultChan <- result{err: fmt.Errorf("not found")} - return - } - - selector, err := services.GetCSVContent(csvPath) + // 使用新的端点服务 + randomURL, err := h.endpointService.GetRandomURL(path) if err != nil { - log.Printf("Error fetching CSV content: %v", err) - monitoring.LogRequest(monitoring.RequestLog{ - Time: time.Now().UnixMilli(), - Path: r.URL.Path, - Method: r.Method, - StatusCode: http.StatusInternalServerError, - Latency: float64(time.Since(start).Microseconds()) / 1000, - IP: realIP, - Referer: r.Referer(), - }) - resultChan <- result{err: err} - return - } - - if len(selector.URLs) == 0 { monitoring.LogRequest(monitoring.RequestLog{ Time: time.Now().UnixMilli(), Path: r.URL.Path, @@ -102,13 +58,12 @@ func (h *Handlers) HandleAPIRequest(w http.ResponseWriter, r *http.Request) { IP: realIP, Referer: r.Referer(), }) - resultChan <- result{err: fmt.Errorf("no content available")} + resultChan <- result{err: fmt.Errorf("endpoint not found: %v", err)} return } - randomURL := selector.GetRandomURL() - endpoint := fmt.Sprintf("%s/%s", prefix, suffix) - h.Stats.IncrementCalls(endpoint) + // 成功获取到URL + h.Stats.IncrementCalls(path) duration := time.Since(start) monitoring.LogRequest(monitoring.RequestLog{ @@ -137,7 +92,7 @@ func (h *Handlers) HandleAPIRequest(w http.ResponseWriter, r *http.Request) { select { case res := <-resultChan: if res.err != nil { - http.Error(w, res.err.Error(), http.StatusInternalServerError) + http.Error(w, res.err.Error(), http.StatusNotFound) return } http.Redirect(w, r, res.url, http.StatusFound) @@ -148,8 +103,14 @@ func (h *Handlers) HandleAPIRequest(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleStats(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - stats := h.Stats.GetStats() - if err := json.NewEncoder(w).Encode(stats); err != nil { + stats := h.Stats.GetStatsForAPI() + + // 包装数据格式以匹配前端期望 + response := map[string]interface{}{ + "Stats": stats, + } + + if err := json.NewEncoder(w).Encode(response); err != nil { http.Error(w, "Error encoding stats", http.StatusInternalServerError) log.Printf("Error encoding stats: %v", err) } @@ -157,18 +118,51 @@ func (h *Handlers) HandleStats(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleURLStats(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - stats := services.GetURLCounts() + + // 使用新的端点服务获取统计信息 + if h.endpointService == nil { + h.endpointService = services.GetEndpointService() + } + + endpoints, err := h.endpointService.ListEndpoints() + if err != nil { + http.Error(w, "Error getting endpoint stats", http.StatusInternalServerError) + return + } // 转换为前端期望的格式 response := make(map[string]struct { TotalURLs int `json:"total_urls"` }) - for endpoint, stat := range stats { - response[endpoint] = struct { - TotalURLs int `json:"total_urls"` - }{ - TotalURLs: stat.TotalURLs, + for _, endpoint := range endpoints { + if endpoint.IsActive { + totalURLs := 0 + for _, ds := range endpoint.DataSources { + if ds.IsActive { + // 尝试获取实际的URL数量 + urls, err := h.endpointService.GetDataSourceURLCount(&ds) + if err != nil { + log.Printf("Failed to get URL count for data source %d: %v", ds.ID, err) + // 如果获取失败,使用估算值 + switch ds.Type { + case "manual": + totalURLs += 5 // 手动数据源估算 + case "lankong": + totalURLs += 50 // 兰空图床估算 + case "api_get", "api_post": + totalURLs += 1 // API数据源每次返回1个 + } + } else { + totalURLs += urls + } + } + } + response[endpoint.URL] = struct { + TotalURLs int `json:"total_urls"` + }{ + TotalURLs: totalURLs, + } } } @@ -185,12 +179,11 @@ func (h *Handlers) HandleMetrics(w http.ResponseWriter, r *http.Request) { } func (h *Handlers) Setup(r *router.Router) { - // 动态路由处理 - r.HandleFunc("/pic/", h.HandleAPIRequest) - r.HandleFunc("/video/", h.HandleAPIRequest) + // 通用路由处理 - 匹配所有路径 + r.HandleFunc("/", h.HandleAPIRequest) // API 统计和监控 - r.HandleFunc("/stats", h.HandleStats) - r.HandleFunc("/urlstats", h.HandleURLStats) - r.HandleFunc("/metrics", h.HandleMetrics) + r.HandleFunc("/api/stats", h.HandleStats) + r.HandleFunc("/api/urlstats", h.HandleURLStats) + r.HandleFunc("/api/metrics", h.HandleMetrics) } diff --git a/handlers/static_handler.go b/handlers/static_handler.go new file mode 100644 index 0000000..dd26aa0 --- /dev/null +++ b/handlers/static_handler.go @@ -0,0 +1,95 @@ +package handlers + +import ( + "net/http" + "os" + "path/filepath" + "strings" +) + +type StaticHandler struct { + staticDir string +} + +func NewStaticHandler(staticDir string) *StaticHandler { + return &StaticHandler{ + staticDir: staticDir, + } +} + +// ServeStatic 处理静态文件请求 +func (s *StaticHandler) ServeStatic(w http.ResponseWriter, r *http.Request) { + // 获取请求路径 + path := r.URL.Path + + // 如果是根路径,重定向到 index.html + if path == "/" { + path = "/index.html" + } + + // 构建文件路径 + filePath := filepath.Join(s.staticDir, path) + + // 检查文件是否存在 + if _, err := os.Stat(filePath); os.IsNotExist(err) { + // 如果文件不存在,检查是否是前端路由 + if s.isFrontendRoute(path) { + // 对于前端路由,返回 index.html + filePath = filepath.Join(s.staticDir, "index.html") + } else { + // 不是前端路由,返回 404 + http.NotFound(w, r) + return + } + } + + // 设置正确的 Content-Type + s.setContentType(w, filePath) + + // 服务文件 + http.ServeFile(w, r, filePath) +} + +// isFrontendRoute 判断是否是前端路由 +func (s *StaticHandler) isFrontendRoute(path string) bool { + // 前端路由通常以 /admin 开头 + if strings.HasPrefix(path, "/admin") { + return true + } + + // 排除 API 路径和静态资源 + if strings.HasPrefix(path, "/api/") || + strings.HasPrefix(path, "/_next/") || + strings.HasPrefix(path, "/static/") || + strings.Contains(path, ".") { + return false + } + + return false +} + +// setContentType 设置正确的 Content-Type +func (s *StaticHandler) setContentType(w http.ResponseWriter, filePath string) { + ext := strings.ToLower(filepath.Ext(filePath)) + + switch ext { + case ".html": + w.Header().Set("Content-Type", "text/html; charset=utf-8") + case ".css": + w.Header().Set("Content-Type", "text/css; charset=utf-8") + case ".js": + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + case ".json": + w.Header().Set("Content-Type", "application/json; charset=utf-8") + case ".png": + w.Header().Set("Content-Type", "image/png") + case ".jpg", ".jpeg": + w.Header().Set("Content-Type", "image/jpeg") + case ".gif": + w.Header().Set("Content-Type", "image/gif") + case ".svg": + w.Header().Set("Content-Type", "image/svg+xml") + case ".ico": + w.Header().Set("Content-Type", "image/x-icon") + } +} diff --git a/lankong_tools/album_mapping.json b/lankong_tools/album_mapping.json deleted file mode 100644 index ef0b732..0000000 --- a/lankong_tools/album_mapping.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "pic/loading.csv": ["19"], - "pic/ai.csv": ["18"], - "pic/fj.csv": ["16"], - "pic/ecy.csv": ["14"], - "pic/truegirl.csv": ["10"], - "pic/czlwb.csv": ["12"] -} diff --git a/lankong_tools/generate_csv.go b/lankong_tools/generate_csv.go deleted file mode 100644 index 558932e..0000000 --- a/lankong_tools/generate_csv.go +++ /dev/null @@ -1,164 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "path/filepath" -) - -const ( - BaseURL = "https://img.czl.net/api/v1/images" -) - -// API响应结构体 -type Response struct { - Status bool `json:"status"` - Message string `json:"message"` - Data struct { - CurrentPage int `json:"current_page"` - LastPage int `json:"last_page"` - Data []struct { - Links struct { - URL string `json:"url"` - } `json:"links"` - } `json:"data"` - } `json:"data"` -} - -// 修改映射类型为 map[string][]string,键为CSV文件路径,值为相册ID数组 -type AlbumMapping map[string][]string - -func main() { - apiToken := os.Getenv("API_TOKEN") - if apiToken == "" { - panic("API_TOKEN environment variable is required") - } - - // 读取本地的相册映射配置 - mappingFile, err := os.ReadFile("lankong_tools/album_mapping.json") - if err != nil { - panic(fmt.Sprintf("Failed to read album mapping file: %v", err)) - } - - var albumMapping AlbumMapping - if err := json.Unmarshal(mappingFile, &albumMapping); err != nil { - panic(fmt.Sprintf("Failed to parse album mapping: %v", err)) - } - - // 创建输出目录 - if err := os.MkdirAll("public", 0755); err != nil { - panic(fmt.Sprintf("Failed to create output directory: %v", err)) - } - - // 处理每个CSV文件的映射 - for csvPath, albumIDs := range albumMapping { - fmt.Printf("Processing CSV file: %s (Albums: %v)\n", csvPath, albumIDs) - - // 收集所有相册的URLs - var allURLs []string - for _, albumID := range albumIDs { - fmt.Printf("Fetching URLs for album %s\n", albumID) - urls := fetchAllURLs(albumID, apiToken) - allURLs = append(allURLs, urls...) - } - - // 确保目录存在 - dir := filepath.Dir(filepath.Join("public", csvPath)) - if err := os.MkdirAll(dir, 0755); err != nil { - panic(fmt.Sprintf("Failed to create directory for %s: %v", csvPath, err)) - } - - // 写入CSV文件 - if err := writeURLsToFile(allURLs, filepath.Join("public", csvPath)); err != nil { - panic(fmt.Sprintf("Failed to write URLs to file %s: %v", csvPath, err)) - } - - fmt.Printf("Finished processing %s: wrote %d URLs\n", csvPath, len(allURLs)) - } - - fmt.Println("All CSV files generated successfully!") -} - -func fetchAllURLs(albumID string, apiToken string) []string { - var allURLs []string - - client := &http.Client{} - - // 获取第一页以确定总页数 - firstPageURL := fmt.Sprintf("%s?album_id=%s&page=1", BaseURL, albumID) - response, err := fetchPage(firstPageURL, apiToken, client) - if err != nil { - panic(fmt.Sprintf("Failed to fetch first page: %v", err)) - } - - totalPages := response.Data.LastPage - fmt.Printf("Album %s has %d pages in total\n", albumID, totalPages) - - // 处理所有页面 - for page := 1; page <= totalPages; page++ { - reqURL := fmt.Sprintf("%s?album_id=%s&page=%d", BaseURL, albumID, page) - response, err := fetchPage(reqURL, apiToken, client) - if err != nil { - panic(fmt.Sprintf("Failed to fetch page %d: %v", page, err)) - } - - for _, item := range response.Data.Data { - if item.Links.URL != "" { - allURLs = append(allURLs, item.Links.URL) - } - } - - fmt.Printf("Fetched page %d of %d for album %s (total URLs so far: %d)\n", - page, totalPages, albumID, len(allURLs)) - } - - fmt.Printf("Finished album %s: collected %d URLs in total\n", albumID, len(allURLs)) - return allURLs -} - -func fetchPage(url string, apiToken string, client *http.Client) (*Response, error) { - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %v", err) - } - - req.Header.Set("Authorization", apiToken) - req.Header.Set("Accept", "application/json") - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to send request: %v", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %v", err) - } - - var response Response - if err := json.Unmarshal(body, &response); err != nil { - return nil, fmt.Errorf("failed to parse response: %v", err) - } - - return &response, nil -} - -func writeURLsToFile(urls []string, filepath string) error { - file, err := os.Create(filepath) - if err != nil { - return err - } - defer file.Close() - - for _, url := range urls { - if _, err := file.WriteString(url + "\n"); err != nil { - return err - } - } - - return nil -} diff --git a/main.go b/main.go index 04348a3..15edad7 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,9 @@ import ( "net/http" "os" "os/signal" + "path/filepath" "random-api-go/config" + "random-api-go/database" "random-api-go/handlers" "random-api-go/logging" "random-api-go/router" @@ -19,9 +21,11 @@ import ( ) type App struct { - server *http.Server - router *router.Router - Stats *stats.StatsManager + server *http.Server + router *router.Router + Stats *stats.StatsManager + adminHandler *handlers.AdminHandler + staticHandler *handlers.StaticHandler } func NewApp() *App { @@ -32,7 +36,7 @@ func NewApp() *App { func (a *App) Initialize() error { // 先加载配置 - if err := config.Load("/root/data/config.json"); err != nil { + if err := config.Load(); err != nil { return err } @@ -45,15 +49,35 @@ func (a *App) Initialize() error { return fmt.Errorf("failed to create data directory: %w", err) } + // 初始化数据库 + if err := database.Initialize(config.Get().Storage.DataDir); err != nil { + return fmt.Errorf("failed to initialize database: %w", err) + } + // 初始化日志 logging.SetupLogging() // 初始化统计管理器 - a.Stats = stats.NewStatsManager(config.Get().Storage.StatsFile) + statsFile := config.Get().Storage.DataDir + "/stats.json" + a.Stats = stats.NewStatsManager(statsFile) - // 初始化服务 - if err := services.InitializeCSVService(); err != nil { - return err + // 初始化端点服务 + services.GetEndpointService() + + // 创建管理后台处理器 + a.adminHandler = handlers.NewAdminHandler() + + // 创建静态文件处理器 + staticDir := "./web/out" + if _, err := os.Stat(staticDir); os.IsNotExist(err) { + log.Printf("Warning: Static directory %s does not exist, static file serving will be disabled", staticDir) + } else { + absStaticDir, err := filepath.Abs(staticDir) + if err != nil { + return fmt.Errorf("failed to get absolute path for static directory: %w", err) + } + a.staticHandler = handlers.NewStaticHandler(absStaticDir) + log.Printf("Static file serving enabled from: %s", absStaticDir) } // 创建 handlers @@ -63,6 +87,12 @@ func (a *App) Initialize() error { // 设置路由 a.router.Setup(handlers) + a.router.SetupAdminRoutes(a.adminHandler) + + // 设置静态文件路由(如果静态文件处理器存在) + if a.staticHandler != nil { + a.router.SetupStaticRoutes(a.staticHandler) + } // 创建 HTTP 服务器 cfg := config.Get().Server @@ -81,6 +111,10 @@ func (a *App) Run() error { // 启动服务器 go func() { log.Printf("Server starting on %s...\n", a.server.Addr) + if a.staticHandler != nil { + log.Printf("Frontend available at: http://localhost%s", a.server.Addr) + log.Printf("Admin panel available at: http://localhost%s/admin", a.server.Addr) + } if err := a.server.ListenAndServe(); err != http.ErrServerClosed { log.Fatalf("Server failed: %v", err) } @@ -102,6 +136,11 @@ func (a *App) gracefulShutdown() error { a.Stats.Shutdown() + // 关闭数据库连接 + if err := database.Close(); err != nil { + log.Printf("Error closing database: %v", err) + } + if err := a.server.Shutdown(ctx); err != nil { return err } diff --git a/models/api_endpoint.go b/models/api_endpoint.go new file mode 100644 index 0000000..7c7bcce --- /dev/null +++ b/models/api_endpoint.go @@ -0,0 +1,120 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// APIEndpoint API端点模型 +type APIEndpoint struct { + ID uint `json:"id" gorm:"primaryKey"` + Name string `json:"name" gorm:"uniqueIndex;not null"` + URL string `json:"url" gorm:"uniqueIndex;not null"` + Description string `json:"description"` + IsActive bool `json:"is_active" gorm:"default:true"` + ShowOnHomepage bool `json:"show_on_homepage" gorm:"default:true"` + SortOrder int `json:"sort_order" gorm:"default:0;index"` // 排序字段,数值越小越靠前 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + + // 关联 + DataSources []DataSource `json:"data_sources,omitempty" gorm:"foreignKey:EndpointID"` + URLReplaceRules []URLReplaceRule `json:"url_replace_rules,omitempty" gorm:"foreignKey:EndpointID"` +} + +// DataSource 数据源模型 +type DataSource struct { + ID uint `json:"id" gorm:"primaryKey"` + EndpointID uint `json:"endpoint_id" gorm:"not null;index"` + Name string `json:"name" gorm:"not null"` + Type string `json:"type" gorm:"not null;check:type IN ('lankong', 'manual', 'api_get', 'api_post', 'endpoint')"` + Config string `json:"config" gorm:"not null"` + CacheDuration int `json:"cache_duration" gorm:"default:3600"` // 缓存时长(秒) + IsActive bool `json:"is_active" gorm:"default:true"` + LastSync *time.Time `json:"last_sync,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + + // 关联 + Endpoint APIEndpoint `json:"-" gorm:"foreignKey:EndpointID"` + CachedURLs []CachedURL `json:"-" gorm:"foreignKey:DataSourceID"` +} + +// URLReplaceRule URL替换规则模型 +type URLReplaceRule struct { + ID uint `json:"id" gorm:"primaryKey"` + EndpointID *uint `json:"endpoint_id" gorm:"index"` // 可以为空,表示全局规则 + Name string `json:"name" gorm:"not null"` + FromURL string `json:"from_url" gorm:"not null"` + ToURL string `json:"to_url" gorm:"not null"` + IsActive bool `json:"is_active" gorm:"default:true"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + + // 关联 + Endpoint *APIEndpoint `json:"endpoint,omitempty" gorm:"foreignKey:EndpointID"` +} + +// CachedURL 缓存URL模型 +type CachedURL struct { + ID uint `json:"id" gorm:"primaryKey"` + DataSourceID uint `json:"data_source_id" gorm:"not null;index"` + OriginalURL string `json:"original_url" gorm:"not null"` + FinalURL string `json:"final_url" gorm:"not null"` + ExpiresAt time.Time `json:"expires_at" gorm:"index"` + CreatedAt time.Time `json:"created_at"` + + // 关联 + DataSource DataSource `json:"-" gorm:"foreignKey:DataSourceID"` +} + +// Config 通用配置表 +type Config struct { + ID uint `json:"id" gorm:"primaryKey"` + Key string `json:"key" gorm:"uniqueIndex;not null"` // 配置键,如 "homepage_content" + Value string `json:"value" gorm:"type:text"` // 配置值 + Type string `json:"type" gorm:"default:'string'"` // 配置类型:string, json, number, boolean + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// DataSourceConfig 数据源配置结构体 +type DataSourceConfig struct { + // 兰空图床配置 + LankongConfig *LankongConfig `json:"lankong_config,omitempty"` + + // 手动数据配置 + ManualConfig *ManualConfig `json:"manual_config,omitempty"` + + // API配置 + APIConfig *APIConfig `json:"api_config,omitempty"` + + // 端点配置 + EndpointConfig *EndpointConfig `json:"endpoint_config,omitempty"` +} + +type LankongConfig struct { + APIToken string `json:"api_token"` + AlbumIDs []string `json:"album_ids"` + BaseURL string `json:"base_url"` +} + +type ManualConfig struct { + URLs []string `json:"urls"` +} + +type APIConfig struct { + URL string `json:"url"` + Method string `json:"method"` // GET, POST + Headers map[string]string `json:"headers,omitempty"` + Body string `json:"body,omitempty"` + URLField string `json:"url_field"` // JSON字段路径,如 "data.url" 或 "urls[0]" +} + +type EndpointConfig struct { + EndpointIDs []uint `json:"endpoint_ids"` // 选中的端点ID列表 +} diff --git a/public/config/endpoint.json b/public/config/endpoint.json deleted file mode 100644 index 015571c..0000000 --- a/public/config/endpoint.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "pic": { - "all": "随机图片", - "fj": "随机风景", - "loading": "随机加载图" - }, - "video": { - "all": "随机视频" - } -} \ No newline at end of file diff --git a/public/css/main.css b/public/css/main.css deleted file mode 100644 index 15067f5..0000000 --- a/public/css/main.css +++ /dev/null @@ -1,406 +0,0 @@ -html, -body { - height: 100%; - margin: 0; - font-weight: 300; - background: transparent; - overflow: auto; -} - -body::before { - content: ''; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-image: url(https://random-api.czl.net/pic/normal); - background-size: cover; - background-position: center; - z-index: -1; - opacity: 0.8; -} - -.overlay { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.7); - z-index: 2; - overflow-y: auto; -} - -#markdown-content { - position: relative; - z-index: 3; - background-color: transparent; - padding: 20px; - max-width: 800px; - margin: 0 auto; -} - -* { - box-sizing: border-box; -} - -main { - padding: 1vw; - max-width: 1000px; - margin-left: auto; - margin-right: auto; -} - -img { - max-width: 100%; - height: auto; -} - -.stats-summary { - background: rgba(255, 255, 255, 0.05); - padding: 20px; - border-radius: 8px; - margin: 20px 0; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - width: 100%; -} - -.stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 15px; - margin-top: 15px; -} - -.stats-item { - background: rgba(255, 255, 255, 0.05); - padding: 12px 15px; - border-radius: 6px; - font-size: 0.95em; - color: #999; -} - -.stats-header { - display: flex; - justify-content: space-between; - align-items: center; - margin: 0 0 10px 0; -} - -.stats-header h2 { - margin: 0; - padding: 0; - color: #fff; -} - -.refresh-icon { - font-size: 16px; - margin-left: 10px; - display: inline-block; - animation: none; -} - -@keyframes spin { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } -} - -.spinning { - animation: spin 1s linear infinite; -} - -.stats-summary, -table { - transition: opacity 0.3s ease; -} - -/* .fade { - opacity: 0.6; -} */ - -.endpoint-link { - color: #2196f3; - padding: 4px 8px; - border-radius: 4px; - transition: all 0.2s ease; -} - -.endpoint-link:hover { - background: rgba(33, 150, 243, 0.1); - color: #2196f3; - transform: translateY(-1px); -} - -/* 点击时的效果 */ -.endpoint-link:active { - transform: translateY(0); - box-shadow: none; -} - -/* 提示框样式也稍作优化 */ -.toast { - position: fixed; - bottom: 20px; - left: 50%; - transform: translateX(-50%); - background-color: #2196f3; /* 改为蓝色背景 */ - color: white; - padding: 12px 24px; - border-radius: 4px; - z-index: 1000; - animation: fadeInOut 2s ease; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); - font-weight: 500; -} - -@keyframes fadeInOut { - 0% { - opacity: 0; - transform: translate(-50%, 20px); - } - - 15% { - opacity: 1; - transform: translate(-50%, 0); - } - - 85% { - opacity: 1; - transform: translate(-50%, 0); - } - - 100% { - opacity: 0; - transform: translate(-50%, -20px); - } -} - -/* 系统监控样式 */ -.metrics-container { - background: rgba(0, 0, 0, 0.2); - border-radius: 8px; - padding: 20px; - margin: 20px 0; -} - -.metrics-section { - margin-bottom: 20px; -} - -.metrics-section h3 { - color: #2196f3; - margin-bottom: 15px; - font-size: 1.1em; - border-bottom: 1px solid rgba(33, 150, 243, 0.2); - padding-bottom: 5px; -} - -.metrics-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 10px; -} - -.metric-item { - background: rgba(255, 255, 255, 0.1); - padding: 12px; - border-radius: 6px; - font-size: 0.9em; -} - -.status-codes { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); - gap: 10px; -} - -.status-code-item { - background: rgba(255, 255, 255, 0.1); - padding: 8px 12px; - border-radius: 6px; - display: flex; - justify-content: space-between; - align-items: center; -} - -.recent-requests table { - width: 100%; - border-collapse: collapse; - font-size: 0.9em; -} - -.recent-requests th, -.recent-requests td { - padding: 8px; - text-align: left; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); -} - -.recent-requests th { - color: #2196f3; - font-weight: 500; -} - -.top-referers { - display: grid; - gap: 8px; -} - -.referer-item { - background: rgba(255, 255, 255, 0.1); - padding: 8px 12px; - border-radius: 6px; - display: flex; - justify-content: space-between; - align-items: center; -} - -.referer { - max-width: 70%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.count { - color: #2196f3; - font-weight: 500; -} - -/* 更新表格样式 */ -.stats-table { - margin-top: 20px; - overflow-x: auto; -} - -table { - width: 100%; - border-collapse: collapse; - background: rgba(255, 255, 255, 0.05); - border-radius: 8px; - overflow: hidden; - margin: 20px 0; -} - -th, td { - padding: 12px 15px; - text-align: left; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - color: #999; -} - -th { - background: rgba(33, 150, 243, 0.1); - font-weight: 500; - color: #2196f3; -} - -tr:hover { - background: rgba(255, 255, 255, 0.05); -} - -/* 操作按钮样式 */ -td a { - color: #2196f3; - text-decoration: none; - margin-right: 8px; - padding: 4px 8px; - border-radius: 4px; - transition: all 0.2s ease; -} - -td a:hover { - background: rgba(33, 150, 243, 0.1); -} - -/* 响应式优化 */ -@media (max-width: 768px) { - .stats-grid { - grid-template-columns: 1fr; - } - - th, td { - padding: 8px 10px; - } - - .stats-table { - margin: 10px -15px; - width: calc(100% + 30px); - } -} - -/* 系统指标样式 */ -.metrics-section { - margin-bottom: 20px; -} - -.metric-label { - color: #999; - font-size: 0.9em; - margin-bottom: 4px; -} - -.metric-value { - font-size: 1.1em; - font-weight: 500; - color: #2196f3; -} - -.error-message { - background: rgba(255, 0, 0, 0.1); - color: #ff4444; - padding: 12px; - border-radius: 6px; - margin: 10px 0; - text-align: center; -} - -/* 确保系统指标和统计数据之间有适当间距 */ -#system-metrics { - max-width: 800px; - margin: 0 auto 30px auto; -} - -/* 优化移动端显示 */ -@media (max-width: 768px) { - .stats-grid { - grid-template-columns: 1fr; - } - - .metric-label { - font-size: 0.85em; - } - - .metric-value { - font-size: 1em; - } -} - -.main-title { - text-align: center; - color: #fff; - margin: 20px 0; - font-size: 2em; - text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); - position: relative; - z-index: 3; -} - -/* 修改统计数据容器的宽度限制 */ -.stats-container { - max-width: 800px; - margin: 0 auto; -} - -/* 移动端适配 */ -@media (max-width: 768px) { - .stats-container { - padding: 0 15px; - } -} diff --git a/public/index.html b/public/index.html deleted file mode 100644 index 0fdee97..0000000 --- a/public/index.html +++ /dev/null @@ -1,318 +0,0 @@ - - - - - 随机文件api - - - - - - - - - - -

Random-Api 随机文件API

-
-
-
-
-
-
-
-
-
-
-
- - - - - \ No newline at end of file diff --git a/public/index.md b/public/index.md deleted file mode 100644 index 8498dc7..0000000 --- a/public/index.md +++ /dev/null @@ -1,19 +0,0 @@ -
- -
-
-
-
- ---- - -## 部署和原理 - -请见我的帖子:[https://www.q58.club/t/topic/127](https://www.q58.club/t/topic/127) - -## 讨论 - -请在帖子下留言,我看到后会回复,谢谢。 - -**永久可用** - diff --git a/readme.md b/readme.md index 1bf6e2c..2496f80 100644 --- a/readme.md +++ b/readme.md @@ -1,18 +1,119 @@ -# Random API +# Random API Go -介绍,使用方法和更新记录: https://q58.club/t/topic/127 +一个基于Go的随机API服务,支持多种数据源和管理后台。 -Random API 是一个用 Go 语言编写的简单而强大的随机图片/视频 API 服务。它允许用户通过配置文件轻松管理和提供随机媒体内容。 +## 功能特性 -## 压测 +- 🎯 支持多种数据源:兰空图床API、手动配置、通用API接口 +- 🔐 OAuth2.0 管理后台登录(CZL Connect) +- 💾 SQLite数据库存储 +- ⚡ 内存缓存机制 +- 🔄 URL替换规则 +- 📝 可配置首页内容 +- 🎨 现代化Web管理界面 -![d3d80d258e1e4805289b607666681e8b](https://github.com/user-attachments/assets/aeeacf76-02ec-4ea3-b38d-b8b25a94f92a) +## 环境变量配置 +复制 `env.example` 为 `.env` 并配置以下环境变量: -## 贡献 +```bash +# 服务器配置 +PORT=:5003 # 服务端口 +READ_TIMEOUT=30s # 读取超时 +WRITE_TIMEOUT=30s # 写入超时 +MAX_HEADER_BYTES=1048576 # 最大请求头大小 -欢迎贡献!请提交 pull request 或创建 issue 来提出建议和报告 bug。 +# 数据存储目录 +DATA_DIR=./data # 数据存储目录 -## 许可 +# OAuth2.0 配置 (必需) +OAUTH_CLIENT_ID=your-oauth-client-id # CZL Connect 客户端ID +OAUTH_CLIENT_SECRET=your-oauth-client-secret # CZL Connect 客户端密钥 +``` -[MIT License](LICENSE) +## 快速开始 + +1. 克隆项目 +```bash +git clone +cd random-api-go +``` + +2. 配置环境变量 +```bash +cp env.example .env +# 编辑 .env 文件,填入正确的 OAuth 配置 +``` + +3. 运行服务 +```bash +go run main.go +``` + +4. 访问服务 +- 首页: http://localhost:5003 +- 管理后台: http://localhost:5003/admin + +## OAuth2.0 配置 + +本项目使用 CZL Connect 作为 OAuth2.0 提供商: + +- 授权端点: https://connect.czl.net/oauth2/authorize +- 令牌端点: https://connect.czl.net/api/oauth2/token +- 用户信息端点: https://connect.czl.net/api/oauth2/userinfo + +请在 CZL Connect 中注册应用并获取 `client_id` 和 `client_secret`。 + +## API 端点 + +### 公开API +- `GET /` - 首页 +- `GET /{endpoint}` - 随机API端点 + +### 管理API +- `GET /admin/api/oauth-config` - 获取OAuth配置 +- `POST /admin/api/oauth-verify` - 验证OAuth授权码 +- `GET /admin/api/endpoints` - 列出所有端点 +- `POST /admin/api/endpoints/` - 创建端点 +- `GET /admin/api/endpoints/{id}` - 获取端点详情 +- `PUT /admin/api/endpoints/{id}` - 更新端点 +- `DELETE /admin/api/endpoints/{id}` - 删除端点 +- `POST /admin/api/data-sources` - 创建数据源 +- `GET /admin/api/url-replace-rules` - 列出URL替换规则 +- `POST /admin/api/url-replace-rules/` - 创建URL替换规则 +- `GET /admin/api/home-config` - 获取首页配置 +- `PUT /admin/api/home-config/` - 更新首页配置 + +## 数据源类型 + +1. **兰空图床 (lankong)**: 从兰空图床API获取图片 +2. **手动配置 (manual)**: 手动配置的URL列表 +3. **API GET (api_get)**: 从GET接口获取数据 +4. **API POST (api_post)**: 从POST接口获取数据 + +## 部署 + +### Docker 部署 + +```dockerfile +FROM golang:1.21-alpine AS builder +WORKDIR /app +COPY . . +RUN go build -o random-api-server main.go + +FROM alpine:latest +RUN apk --no-cache add ca-certificates +WORKDIR /root/ +COPY --from=builder /app/random-api-server . +COPY --from=builder /app/web ./web +EXPOSE 5003 +CMD ["./random-api-server"] +``` + +### 环境变量部署 + +确保在生产环境中正确设置所有必需的环境变量,特别是OAuth配置。 + +## 许可证 + +MIT License diff --git a/router/router.go b/router/router.go index 4a126ec..a1433f3 100644 --- a/router/router.go +++ b/router/router.go @@ -2,17 +2,56 @@ package router import ( "net/http" - "random-api-go/middleware" + "strings" ) type Router struct { - mux *http.ServeMux + mux *http.ServeMux + staticHandler StaticHandler } type Handler interface { Setup(r *Router) } +// StaticHandler 接口定义静态文件处理器需要的方法 +type StaticHandler interface { + ServeStatic(w http.ResponseWriter, r *http.Request) +} + +// AdminHandler 接口定义管理后台处理器需要的方法 +type AdminHandler interface { + // OAuth相关 + GetOAuthConfig(w http.ResponseWriter, r *http.Request) + VerifyOAuthToken(w http.ResponseWriter, r *http.Request) + HandleOAuthCallback(w http.ResponseWriter, r *http.Request) + + // 端点管理 + HandleEndpoints(w http.ResponseWriter, r *http.Request) + HandleEndpointByID(w http.ResponseWriter, r *http.Request) + HandleEndpointDataSources(w http.ResponseWriter, r *http.Request) + UpdateEndpointSortOrder(w http.ResponseWriter, r *http.Request) + + // 数据源管理 + CreateDataSource(w http.ResponseWriter, r *http.Request) + HandleDataSourceByID(w http.ResponseWriter, r *http.Request) + SyncDataSource(w http.ResponseWriter, r *http.Request) + + // URL替换规则 + ListURLReplaceRules(w http.ResponseWriter, r *http.Request) + CreateURLReplaceRule(w http.ResponseWriter, r *http.Request) + HandleURLReplaceRuleByID(w http.ResponseWriter, r *http.Request) + + // 首页配置 + GetHomePageConfig(w http.ResponseWriter, r *http.Request) + UpdateHomePageConfig(w http.ResponseWriter, r *http.Request) + + // 通用配置管理 + ListConfigs(w http.ResponseWriter, r *http.Request) + CreateOrUpdateConfig(w http.ResponseWriter, r *http.Request) + DeleteConfigByKey(w http.ResponseWriter, r *http.Request) +} + func New() *Router { return &Router{ mux: http.NewServeMux(), @@ -20,21 +59,154 @@ func New() *Router { } func (r *Router) Setup(h Handler) { - // 静态文件服务 - fileServer := http.FileServer(http.Dir("/root/data/public")) - r.mux.Handle("/", middleware.Chain( - middleware.Recovery, - middleware.MetricsMiddleware, - )(fileServer)) - // 设置API路由 h.Setup(r) } +// SetupStaticRoutes 设置静态文件路由 +func (r *Router) SetupStaticRoutes(staticHandler StaticHandler) { + r.staticHandler = staticHandler +} + +// SetupAdminRoutes 设置管理后台路由 +func (r *Router) SetupAdminRoutes(adminHandler AdminHandler) { + // OAuth配置API(前端需要获取client_id等信息) + r.HandleFunc("/api/admin/oauth-config", adminHandler.GetOAuthConfig) + // OAuth令牌验证API(保留,以防需要) + r.HandleFunc("/api/admin/oauth-verify", adminHandler.VerifyOAuthToken) + // OAuth回调处理(使用API前缀以便区分前后端) + r.HandleFunc("/api/admin/oauth/callback", adminHandler.HandleOAuthCallback) + + // 管理后台API路由 + r.HandleFunc("/api/admin/endpoints", adminHandler.HandleEndpoints) + + // 端点排序路由 + r.HandleFunc("/api/admin/endpoints/sort-order", adminHandler.UpdateEndpointSortOrder) + + // 数据源路由 - 需要在端点路由之前注册,因为路径更具体 + r.HandleFunc("/api/admin/data-sources", adminHandler.CreateDataSource) + + // 端点相关路由 - 使用通配符处理所有端点相关请求 + r.HandleFunc("/api/admin/endpoints/", func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if strings.Contains(path, "/data-sources") { + adminHandler.HandleEndpointDataSources(w, r) + } else { + adminHandler.HandleEndpointByID(w, r) + } + }) + + // 数据源操作路由 - 使用通配符处理所有数据源相关请求 + r.HandleFunc("/api/admin/data-sources/", func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if strings.Contains(path, "/sync") { + adminHandler.SyncDataSource(w, r) + } else { + adminHandler.HandleDataSourceByID(w, r) + } + }) + + // URL替换规则路由 + r.HandleFunc("/api/admin/url-replace-rules", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + adminHandler.ListURLReplaceRules(w, r) + } else if r.Method == http.MethodPost { + adminHandler.CreateURLReplaceRule(w, r) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + r.HandleFunc("/api/admin/url-replace-rules/", adminHandler.HandleURLReplaceRuleByID) + + // 首页配置路由 + r.HandleFunc("/api/admin/home-config", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + adminHandler.GetHomePageConfig(w, r) + } else { + adminHandler.UpdateHomePageConfig(w, r) + } + }) + + // 通用配置管理路由 + r.HandleFunc("/api/admin/configs", adminHandler.ListConfigs) + r.HandleFunc("/api/admin/configs/", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + adminHandler.DeleteConfigByKey(w, r) + } else { + adminHandler.CreateOrUpdateConfig(w, r) + } + }) +} + func (r *Router) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) { r.mux.HandleFunc(pattern, handler) } func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { + // 首先检查是否是静态文件请求或前端路由 + if r.staticHandler != nil && r.shouldServeStatic(req.URL.Path) { + r.staticHandler.ServeStatic(w, req) + return + } + + // 否则使用默认的路由处理 r.mux.ServeHTTP(w, req) } + +// shouldServeStatic 判断是否应该由静态文件处理器处理 +func (r *Router) shouldServeStatic(path string) bool { + // API 路径不由静态文件处理器处理 + if strings.HasPrefix(path, "/api/") { + return false + } + + // 根路径由静态文件处理器处理(返回首页) + if path == "/" { + return true + } + + // 前端路由(以 /admin 开头)由静态文件处理器处理 + if strings.HasPrefix(path, "/admin") { + return true + } + + // 静态资源文件(包含文件扩展名或特定前缀) + if strings.HasPrefix(path, "/_next/") || + strings.HasPrefix(path, "/static/") || + strings.HasPrefix(path, "/favicon.ico") || + r.hasFileExtension(path) { + return true + } + + // 其他路径可能是动态API端点,不由静态文件处理器处理 + return false +} + +// hasFileExtension 检查路径是否包含文件扩展名 +func (r *Router) hasFileExtension(path string) bool { + // 获取路径的最后一部分 + parts := strings.Split(path, "/") + if len(parts) == 0 { + return false + } + + lastPart := parts[len(parts)-1] + + // 检查是否包含点号且不是隐藏文件 + if strings.Contains(lastPart, ".") && !strings.HasPrefix(lastPart, ".") { + // 常见的文件扩展名 + commonExts := []string{ + ".html", ".css", ".js", ".json", ".png", ".jpg", ".jpeg", + ".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".eot", + ".txt", ".xml", ".pdf", ".zip", ".mp4", ".mp3", + } + + for _, ext := range commonExts { + if strings.HasSuffix(strings.ToLower(lastPart), ext) { + return true + } + } + } + + return false +} diff --git a/services/README.md b/services/README.md new file mode 100644 index 0000000..8ac4ccf --- /dev/null +++ b/services/README.md @@ -0,0 +1,79 @@ +# Services 架构说明 + +## 文件结构 + +### 核心服务 +- **endpoint_service.go** - 主要的端点服务,提供API端点的CRUD操作和随机URL获取 +- **cache_manager.go** - 缓存管理器,负责内存缓存和数据库缓存的管理 +- **preloader.go** - 预加载管理器,负责主动预加载和定时刷新数据 + +### 数据获取器 +- **data_source_fetcher.go** - 数据源获取器,统一管理不同类型数据源的获取逻辑 +- **lankong_fetcher.go** - 兰空图床专用获取器,处理兰空图床API的分页获取 +- **api_fetcher.go** - API接口获取器,支持GET/POST接口的批量预获取 + +### 其他 +- **url_counter.go** - URL计数器(原有功能) + +## 主要改进 + +### 1. 主动预加载机制 +- **保存时预加载**: 创建或更新数据源时,立即在后台预加载数据 +- **定时刷新**: 每30分钟检查一次,自动刷新过期或需要更新的数据源 +- **智能刷新策略**: + - 兰空图床: 每2小时刷新一次 + - API接口: 每1小时刷新一次 + - 手动数据: 不自动刷新 + +### 2. 优化的缓存策略 +- **双层缓存**: 内存缓存(5分钟) + 数据库缓存(可配置) +- **智能更新**: 只有当上游数据变化时才更新数据库缓存 +- **自动清理**: 定期清理过期的内存和数据库缓存 + +### 3. API接口预获取优化 +- **批量获取**: GET接口预获取100次,POST接口预获取200次 +- **去重处理**: 自动去除重复的URL +- **智能停止**: GET接口如果效率太低会提前停止预获取 + +### 4. 错误处理和日志 +- **详细日志**: 记录每个步骤的执行情况 +- **错误恢复**: 单个数据源失败不影响其他数据源 +- **进度显示**: 大批量操作时显示进度信息 + +## 使用方式 + +### 基本操作 +```go +// 获取服务实例 +service := GetEndpointService() + +// 创建端点(会自动预加载) +endpoint := &models.APIEndpoint{...} +service.CreateEndpoint(endpoint) + +// 获取随机URL(优先使用缓存) +url, err := service.GetRandomURL("/api/random") +``` + +### 手动刷新 +```go +// 刷新单个数据源 +service.RefreshDataSource(dataSourceID) + +// 刷新整个端点 +service.RefreshEndpoint(endpointID) +``` + +### 控制预加载器 +```go +preloader := service.GetPreloader() +preloader.Stop() // 停止自动刷新 +preloader.Start() // 重新启动 +``` + +## 性能优化 + +1. **并发处理**: 多个数据源并行获取数据 +2. **请求限制**: 添加延迟避免请求过快 +3. **缓存优先**: 优先使用缓存数据,减少API调用 +4. **智能刷新**: 根据数据源类型设置不同的刷新策略 \ No newline at end of file diff --git a/services/api_fetcher.go b/services/api_fetcher.go new file mode 100644 index 0000000..529d03b --- /dev/null +++ b/services/api_fetcher.go @@ -0,0 +1,176 @@ +package services + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "random-api-go/models" + "strings" + "time" +) + +// APIFetcher API接口获取器 +type APIFetcher struct { + client *http.Client +} + +// NewAPIFetcher 创建API接口获取器 +func NewAPIFetcher() *APIFetcher { + return &APIFetcher{ + client: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// FetchURLs 从API接口获取URL列表 +func (af *APIFetcher) FetchURLs(config *models.APIConfig) ([]string, error) { + var allURLs []string + + // 对于GET/POST接口,我们预获取多次以获得不同的URL + maxFetches := 200 + if config.Method == "GET" { + maxFetches = 100 // GET接口可能返回相同结果,减少请求次数 + } + + log.Printf("开始从 %s 接口预获取 %d 次URL", config.Method, maxFetches) + + urlSet := make(map[string]bool) // 用于去重 + + for i := 0; i < maxFetches; i++ { + urls, err := af.fetchSingleRequest(config) + if err != nil { + log.Printf("第 %d 次请求失败: %v", i+1, err) + continue + } + + // 添加到集合中(自动去重) + for _, url := range urls { + if url != "" && !urlSet[url] { + urlSet[url] = true + allURLs = append(allURLs, url) + } + } + + // 如果是GET接口且连续几次都没有新URL,提前结束 + if config.Method == "GET" && i > 10 && len(allURLs) > 0 { + // 检查最近10次是否有新增URL + if i%10 == 0 { + currentCount := len(allURLs) + // 如果URL数量没有显著增长,可能接口返回固定结果 + if currentCount < i/5 { // 如果平均每5次请求才有1个新URL,可能效率太低 + log.Printf("GET接口效率较低,在第 %d 次请求后停止预获取", i+1) + break + } + } + } + + // 添加小延迟避免请求过快 + if i < maxFetches-1 { + time.Sleep(50 * time.Millisecond) + } + + // 每50次请求输出一次进度 + if (i+1)%50 == 0 { + log.Printf("已完成 %d/%d 次请求,获得 %d 个唯一URL", i+1, maxFetches, len(allURLs)) + } + } + + log.Printf("完成API预获取: 总共获得 %d 个唯一URL", len(allURLs)) + return allURLs, nil +} + +// FetchSingleURL 实时获取单个URL (用于GET/POST实时请求) +func (af *APIFetcher) FetchSingleURL(config *models.APIConfig) ([]string, error) { + log.Printf("实时请求 %s 接口: %s", config.Method, config.URL) + return af.fetchSingleRequest(config) +} + +// fetchSingleRequest 执行单次API请求 +func (af *APIFetcher) fetchSingleRequest(config *models.APIConfig) ([]string, error) { + var req *http.Request + var err error + + if config.Method == "POST" { + var body io.Reader + if config.Body != "" { + body = strings.NewReader(config.Body) + } + req, err = http.NewRequest("POST", config.URL, body) + if config.Body != "" { + req.Header.Set("Content-Type", "application/json") + } + } else { + req, err = http.NewRequest("GET", config.URL, nil) + } + + if err != nil { + return nil, err + } + + // 设置请求头 + for key, value := range config.Headers { + req.Header.Set(key, value) + } + + resp, err := af.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var data interface{} + if err := json.Unmarshal(body, &data); err != nil { + return nil, fmt.Errorf("failed to parse JSON response: %w", err) + } + + return af.extractURLsFromJSON(data, config.URLField) +} + +// extractURLsFromJSON 从JSON数据中提取URL +func (af *APIFetcher) extractURLsFromJSON(data interface{}, fieldPath string) ([]string, error) { + var urls []string + + // 分割字段路径 + fields := strings.Split(fieldPath, ".") + + // 递归提取URL + af.extractURLsRecursive(data, fields, 0, &urls) + + return urls, nil +} + +// extractURLsRecursive 递归提取URL +func (af *APIFetcher) extractURLsRecursive(data interface{}, fields []string, depth int, urls *[]string) { + if depth >= len(fields) { + // 到达目标字段,提取URL + if url, ok := data.(string); ok && url != "" { + *urls = append(*urls, url) + } + return + } + + currentField := fields[depth] + + switch v := data.(type) { + case map[string]interface{}: + if value, exists := v[currentField]; exists { + af.extractURLsRecursive(value, fields, depth+1, urls) + } + case []interface{}: + for _, item := range v { + af.extractURLsRecursive(item, fields, depth, urls) + } + } +} diff --git a/services/cache_manager.go b/services/cache_manager.go new file mode 100644 index 0000000..3eca073 --- /dev/null +++ b/services/cache_manager.go @@ -0,0 +1,176 @@ +package services + +import ( + "log" + "random-api-go/database" + "random-api-go/models" + "sync" + "time" +) + +// CacheManager 缓存管理器 +type CacheManager struct { + memoryCache map[string]*CachedEndpoint + mutex sync.RWMutex +} + +// 注意:CachedEndpoint 类型定义在 endpoint_service.go 中 + +// NewCacheManager 创建缓存管理器 +func NewCacheManager() *CacheManager { + cm := &CacheManager{ + memoryCache: make(map[string]*CachedEndpoint), + } + + // 启动定期清理过期缓存的协程 + go cm.cleanupExpiredCache() + + return cm +} + +// GetFromMemoryCache 从内存缓存获取数据 +func (cm *CacheManager) GetFromMemoryCache(key string) ([]string, bool) { + cm.mutex.RLock() + defer cm.mutex.RUnlock() + + cached, exists := cm.memoryCache[key] + if !exists || len(cached.URLs) == 0 { + return nil, false + } + + return cached.URLs, true +} + +// SetMemoryCache 设置内存缓存(duration参数保留以兼容现有接口,但不再使用) +func (cm *CacheManager) SetMemoryCache(key string, urls []string, duration time.Duration) { + cm.mutex.Lock() + defer cm.mutex.Unlock() + + cm.memoryCache[key] = &CachedEndpoint{ + URLs: urls, + } +} + +// InvalidateMemoryCache 清理指定key的内存缓存 +func (cm *CacheManager) InvalidateMemoryCache(key string) { + cm.mutex.Lock() + defer cm.mutex.Unlock() + + delete(cm.memoryCache, key) +} + +// GetFromDBCache 从数据库缓存获取URL +func (cm *CacheManager) GetFromDBCache(dataSourceID uint) ([]string, error) { + var cachedURLs []models.CachedURL + if err := database.DB.Where("data_source_id = ? AND expires_at > ?", dataSourceID, time.Now()). + Find(&cachedURLs).Error; err != nil { + return nil, err + } + + var urls []string + for _, cached := range cachedURLs { + urls = append(urls, cached.FinalURL) + } + + return urls, nil +} + +// SetDBCache 设置数据库缓存 +func (cm *CacheManager) SetDBCache(dataSourceID uint, urls []string, duration time.Duration) error { + // 先删除旧的缓存 + if err := database.DB.Where("data_source_id = ?", dataSourceID).Delete(&models.CachedURL{}).Error; err != nil { + log.Printf("Failed to delete old cache for data source %d: %v", dataSourceID, err) + } + + // 插入新的缓存 + expiresAt := time.Now().Add(duration) + for _, url := range urls { + cachedURL := models.CachedURL{ + DataSourceID: dataSourceID, + OriginalURL: url, + FinalURL: url, + ExpiresAt: expiresAt, + } + if err := database.DB.Create(&cachedURL).Error; err != nil { + log.Printf("Failed to cache URL: %v", err) + } + } + + return nil +} + +// UpdateDBCacheIfChanged 只有当数据变化时才更新数据库缓存,并返回是否需要清理内存缓存 +func (cm *CacheManager) UpdateDBCacheIfChanged(dataSourceID uint, newURLs []string, duration time.Duration) (bool, error) { + // 获取现有缓存 + existingURLs, err := cm.GetFromDBCache(dataSourceID) + if err != nil { + // 如果获取失败,直接设置新缓存 + return true, cm.SetDBCache(dataSourceID, newURLs, duration) + } + + // 比较URL列表是否相同 + if cm.urlSlicesEqual(existingURLs, newURLs) { + // 数据没有变化,只更新过期时间 + expiresAt := time.Now().Add(duration) + if err := database.DB.Model(&models.CachedURL{}). + Where("data_source_id = ?", dataSourceID). + Update("expires_at", expiresAt).Error; err != nil { + log.Printf("Failed to update cache expiry for data source %d: %v", dataSourceID, err) + } + return false, nil + } + + // 数据有变化,更新缓存 + return true, cm.SetDBCache(dataSourceID, newURLs, duration) +} + +// InvalidateMemoryCacheForDataSource 清理与数据源相关的内存缓存 +func (cm *CacheManager) InvalidateMemoryCacheForDataSource(dataSourceID uint) error { + // 获取数据源信息 + var dataSource models.DataSource + if err := database.DB.Preload("Endpoint").First(&dataSource, dataSourceID).Error; err != nil { + return err + } + + // 清理该端点的内存缓存 + cm.InvalidateMemoryCache(dataSource.Endpoint.URL) + log.Printf("已清理端点 %s 的内存缓存(数据源 %d 数据发生变化)", dataSource.Endpoint.URL, dataSourceID) + + return nil +} + +// urlSlicesEqual 比较两个URL切片是否相等 +func (cm *CacheManager) urlSlicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + + // 创建map来比较 + urlMap := make(map[string]bool) + for _, url := range a { + urlMap[url] = true + } + + for _, url := range b { + if !urlMap[url] { + return false + } + } + + return true +} + +// cleanupExpiredCache 定期清理过期的数据库缓存(内存缓存不再自动过期) +func (cm *CacheManager) cleanupExpiredCache() { + ticker := time.NewTicker(10 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + now := time.Now() + + // 内存缓存不再自动过期,只清理数据库中的过期缓存 + if err := database.DB.Where("expires_at < ?", now).Delete(&models.CachedURL{}).Error; err != nil { + log.Printf("Failed to cleanup expired cache: %v", err) + } + } +} diff --git a/services/csv_service.go b/services/csv_service.go deleted file mode 100644 index d91b44a..0000000 --- a/services/csv_service.go +++ /dev/null @@ -1,271 +0,0 @@ -package services - -import ( - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "os" - "path/filepath" - "random-api-go/config" - "random-api-go/models" - "random-api-go/utils" - "strings" - "sync" - "time" -) - -type CSVCache struct { - selector *models.URLSelector - lastCheck time.Time - mu sync.RWMutex -} - -var ( - CSVPathsCache map[string]map[string]string - csvCache = make(map[string]*CSVCache) - cacheTTL = 1 * time.Hour - Mu sync.RWMutex -) - -// InitializeCSVService 初始化CSV服务 -func InitializeCSVService() error { - // 加载url.json - if err := LoadCSVPaths(); err != nil { - return fmt.Errorf("failed to load CSV paths: %v", err) - } - - // 获取一个CSVPathsCache的副本,避免长时间持有锁 - Mu.RLock() - pathsCopy := make(map[string]map[string]string) - for prefix, suffixMap := range CSVPathsCache { - pathsCopy[prefix] = make(map[string]string) - for suffix, path := range suffixMap { - pathsCopy[prefix][suffix] = path - } - } - Mu.RUnlock() - - // 使用副本进行初始化 - for prefix, suffixMap := range pathsCopy { - for suffix, csvPath := range suffixMap { - selector, err := GetCSVContent(csvPath) - if err != nil { - log.Printf("Warning: Failed to load CSV content for %s/%s: %v", prefix, suffix, err) - continue - } - - // 更新URL计数 - endpoint := fmt.Sprintf("%s/%s", prefix, suffix) - UpdateURLCount(endpoint, csvPath, len(selector.URLs)) - - log.Printf("Loaded %d URLs for endpoint: %s/%s", len(selector.URLs), prefix, suffix) - } - } - - return nil -} - -func LoadCSVPaths() error { - var data []byte - var err error - - // 获取环境变量中的基础URL - baseURL := os.Getenv(config.EnvBaseURL) - - if baseURL != "" { - // 构建完整的URL - var fullURL string - if strings.HasPrefix(baseURL, "http://") || strings.HasPrefix(baseURL, "https://") { - fullURL = utils.JoinURLPath(baseURL, "url.json") - } else { - fullURL = "https://" + utils.JoinURLPath(baseURL, "url.json") - } - - log.Printf("Attempting to read url.json from: %s", fullURL) - - // 创建HTTP客户端 - client := &http.Client{ - Timeout: config.RequestTimeout, - } - - resp, err := client.Get(fullURL) - if err != nil { - return fmt.Errorf("failed to fetch url.json: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("failed to fetch url.json, status code: %d", resp.StatusCode) - } - - data, err = io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read url.json response: %w", err) - } - } else { - // 从本地文件读取 - jsonPath := filepath.Join("public", "url.json") - log.Printf("Attempting to read local file: %s", jsonPath) - - data, err = os.ReadFile(jsonPath) - if err != nil { - return fmt.Errorf("failed to read local url.json: %w", err) - } - } - - var result map[string]map[string]string - if err := json.Unmarshal(data, &result); err != nil { - return fmt.Errorf("failed to unmarshal url.json: %w", err) - } - - Mu.Lock() - CSVPathsCache = result - Mu.Unlock() - - log.Println("CSV paths loaded from url.json") - return nil -} - -func GetCSVContent(path string) (*models.URLSelector, error) { - cache, ok := csvCache[path] - if ok { - cache.mu.RLock() - if time.Since(cache.lastCheck) < cacheTTL { - defer cache.mu.RUnlock() - return cache.selector, nil - } - cache.mu.RUnlock() - } - - // 更新缓存 - selector, err := loadCSVContent(path) - if err != nil { - return nil, err - } - - cache = &CSVCache{ - selector: selector, - lastCheck: time.Now(), - } - csvCache[path] = cache - return selector, nil -} - -func loadCSVContent(path string) (*models.URLSelector, error) { - Mu.RLock() - selector, exists := csvCache[path] - Mu.RUnlock() - - if exists { - return selector.selector, nil - } - - var fileContent []byte - var err error - - baseURL := os.Getenv(config.EnvBaseURL) - - if baseURL != "" { - var fullURL string - if strings.HasPrefix(baseURL, "http://") || strings.HasPrefix(baseURL, "https://") { - fullURL = utils.JoinURLPath(baseURL, path) - } else { - fullURL = "https://" + utils.JoinURLPath(baseURL, path) - } - - log.Printf("尝试从URL获取: %s", fullURL) - - client := &http.Client{ - Timeout: config.RequestTimeout, - } - - resp, err := client.Get(fullURL) - if err != nil { - log.Printf("HTTP请求失败: %v", err) - return nil, fmt.Errorf("HTTP请求失败: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - log.Printf("HTTP请求返回非200状态码: %d", resp.StatusCode) - return nil, fmt.Errorf("HTTP请求返回非200状态码: %d", resp.StatusCode) - } - - fileContent, err = io.ReadAll(resp.Body) - if err != nil { - log.Printf("读取响应内容失败: %v", err) - return nil, fmt.Errorf("读取响应内容失败: %w", err) - } - - log.Printf("成功读取到CSV内容,长度: %d bytes", len(fileContent)) - } else { - // 如果没有设置基础URL,从本地文件读取 - fullPath := filepath.Join("public", path) - log.Printf("尝试读取本地文件: %s", fullPath) - - fileContent, err = os.ReadFile(fullPath) - if err != nil { - return nil, fmt.Errorf("读取CSV内容时出错: %w", err) - } - } - - lines := strings.Split(string(fileContent), "\n") - log.Printf("CSV文件包含 %d 行", len(lines)) - - if len(lines) == 0 { - return nil, fmt.Errorf("CSV文件为空") - } - - uniqueURLs := make(map[string]bool) - var fileArray []string - var invalidLines []string - - for i, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - continue - } - - // 验证URL格式 - if !strings.HasPrefix(trimmed, "http://") && !strings.HasPrefix(trimmed, "https://") { - invalidLines = append(invalidLines, fmt.Sprintf("第%d行: %s (无效的URL格式)", i+1, trimmed)) - continue - } - - // 检查URL是否包含非法字符 - if strings.ContainsAny(trimmed, "\"'") { - invalidLines = append(invalidLines, fmt.Sprintf("第%d行: %s (URL包含非法字符)", i+1, trimmed)) - continue - } - - if !uniqueURLs[trimmed] { - fileArray = append(fileArray, trimmed) - uniqueURLs[trimmed] = true - } - } - - if len(invalidLines) > 0 { - errMsg := "发现无效的URL格式:\n" + strings.Join(invalidLines, "\n") - log.Printf("%s", errMsg) - return nil, fmt.Errorf("%s", errMsg) - } - - if len(fileArray) == 0 { - return nil, fmt.Errorf("CSV文件中没有有效的URL") - } - - log.Printf("处理后得到 %d 个有效的唯一URL", len(fileArray)) - - urlSelector := models.NewURLSelector(fileArray) - - Mu.Lock() - csvCache[path] = &CSVCache{ - selector: urlSelector, - lastCheck: time.Now(), - } - Mu.Unlock() - - return urlSelector, nil -} diff --git a/services/data_source_fetcher.go b/services/data_source_fetcher.go new file mode 100644 index 0000000..39e4821 --- /dev/null +++ b/services/data_source_fetcher.go @@ -0,0 +1,179 @@ +package services + +import ( + "encoding/json" + "fmt" + "log" + "random-api-go/models" + "strings" + "time" +) + +// DataSourceFetcher 数据源获取器 +type DataSourceFetcher struct { + cacheManager *CacheManager + lankongFetcher *LankongFetcher + apiFetcher *APIFetcher +} + +// NewDataSourceFetcher 创建数据源获取器 +func NewDataSourceFetcher(cacheManager *CacheManager) *DataSourceFetcher { + return &DataSourceFetcher{ + cacheManager: cacheManager, + lankongFetcher: NewLankongFetcher(), + apiFetcher: NewAPIFetcher(), + } +} + +// FetchURLs 从数据源获取URL列表 +func (dsf *DataSourceFetcher) FetchURLs(dataSource *models.DataSource) ([]string, error) { + // API类型的数据源直接实时请求,不使用缓存 + if dataSource.Type == "api_get" || dataSource.Type == "api_post" { + log.Printf("实时请求API数据源 (类型: %s, ID: %d)", dataSource.Type, dataSource.ID) + return dsf.fetchAPIURLs(dataSource) + } + + // 其他类型的数据源先检查数据库缓存 + if cachedURLs, err := dsf.cacheManager.GetFromDBCache(dataSource.ID); err == nil && len(cachedURLs) > 0 { + log.Printf("从数据库缓存获取到 %d 个URL (数据源ID: %d)", len(cachedURLs), dataSource.ID) + return cachedURLs, nil + } + + var urls []string + var err error + + log.Printf("开始从数据源获取URL (类型: %s, ID: %d)", dataSource.Type, dataSource.ID) + + switch dataSource.Type { + case "lankong": + urls, err = dsf.fetchLankongURLs(dataSource) + case "manual": + urls, err = dsf.fetchManualURLs(dataSource) + case "endpoint": + urls, err = dsf.fetchEndpointURLs(dataSource) + default: + return nil, fmt.Errorf("unsupported data source type: %s", dataSource.Type) + } + + if err != nil { + return nil, fmt.Errorf("failed to fetch URLs from %s data source: %w", dataSource.Type, err) + } + + if len(urls) == 0 { + log.Printf("警告: 数据源 %d 没有获取到任何URL", dataSource.ID) + return urls, nil + } + + // 缓存结果到数据库 + cacheDuration := time.Duration(dataSource.CacheDuration) * time.Second + changed, err := dsf.cacheManager.UpdateDBCacheIfChanged(dataSource.ID, urls, cacheDuration) + if err != nil { + log.Printf("Failed to cache URLs for data source %d: %v", dataSource.ID, err) + } else if changed { + log.Printf("数据源 %d 的数据已更新,缓存了 %d 个URL", dataSource.ID, len(urls)) + // 数据发生变化,清理相关的内存缓存 + if err := dsf.cacheManager.InvalidateMemoryCacheForDataSource(dataSource.ID); err != nil { + log.Printf("Failed to invalidate memory cache for data source %d: %v", dataSource.ID, err) + } + } else { + log.Printf("数据源 %d 的数据未变化,仅更新了过期时间", dataSource.ID) + } + + // 更新最后同步时间 + now := time.Now() + dataSource.LastSync = &now + if err := dsf.updateDataSourceSyncTime(dataSource); err != nil { + log.Printf("Failed to update sync time for data source %d: %v", dataSource.ID, err) + } + + return urls, nil +} + +// fetchLankongURLs 获取兰空图床URL +func (dsf *DataSourceFetcher) fetchLankongURLs(dataSource *models.DataSource) ([]string, error) { + var config models.LankongConfig + if err := json.Unmarshal([]byte(dataSource.Config), &config); err != nil { + return nil, fmt.Errorf("invalid lankong config: %w", err) + } + + return dsf.lankongFetcher.FetchURLs(&config) +} + +// fetchManualURLs 获取手动配置的URL +func (dsf *DataSourceFetcher) fetchManualURLs(dataSource *models.DataSource) ([]string, error) { + // 手动配置可能是JSON格式或者纯文本格式 + config := strings.TrimSpace(dataSource.Config) + + // 尝试解析为JSON格式 + var manualConfig models.ManualConfig + if err := json.Unmarshal([]byte(config), &manualConfig); err == nil { + return manualConfig.URLs, nil + } + + // 如果不是JSON,按行分割处理 + lines := strings.Split(config, "\n") + var urls []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "#") { // 忽略空行和注释 + urls = append(urls, line) + } + } + + return urls, nil +} + +// fetchAPIURLs 获取API接口URL (实时请求,不缓存) +func (dsf *DataSourceFetcher) fetchAPIURLs(dataSource *models.DataSource) ([]string, error) { + var config models.APIConfig + if err := json.Unmarshal([]byte(dataSource.Config), &config); err != nil { + return nil, fmt.Errorf("invalid API config: %w", err) + } + + // 对于API类型的数据源,直接进行实时请求,不使用预存储的数据 + return dsf.apiFetcher.FetchSingleURL(&config) +} + +// fetchEndpointURLs 获取端点URL (直接返回端点URL列表) +func (dsf *DataSourceFetcher) fetchEndpointURLs(dataSource *models.DataSource) ([]string, error) { + var config models.EndpointConfig + if err := json.Unmarshal([]byte(dataSource.Config), &config); err != nil { + return nil, fmt.Errorf("invalid endpoint config: %w", err) + } + + if len(config.EndpointIDs) == 0 { + return nil, fmt.Errorf("no endpoints configured") + } + + // 这里我们需要导入database包来查询端点信息 + // 为了避免循环依赖,我们返回一个特殊的URL格式,让服务层处理 + var urls []string + for _, endpointID := range config.EndpointIDs { + // 使用特殊格式标记这是一个端点引用 + urls = append(urls, fmt.Sprintf("endpoint://%d", endpointID)) + } + + return urls, nil +} + +// updateDataSourceSyncTime 更新数据源的同步时间 +func (dsf *DataSourceFetcher) updateDataSourceSyncTime(dataSource *models.DataSource) error { + // 这里需要导入database包来更新数据库 + // 为了避免循环依赖,我们通过回调或者接口来处理 + // 暂时先记录日志,具体实现在主服务中处理 + log.Printf("需要更新数据源 %d 的同步时间", dataSource.ID) + return nil +} + +// PreloadDataSource 预加载数据源(在保存时调用) +func (dsf *DataSourceFetcher) PreloadDataSource(dataSource *models.DataSource) error { + log.Printf("开始预加载数据源 (类型: %s, ID: %d)", dataSource.Type, dataSource.ID) + + _, err := dsf.FetchURLs(dataSource) + if err != nil { + return fmt.Errorf("failed to preload data source %d: %w", dataSource.ID, err) + } + + log.Printf("数据源 %d 预加载完成", dataSource.ID) + return nil +} diff --git a/services/endpoint_service.go b/services/endpoint_service.go new file mode 100644 index 0000000..8c34718 --- /dev/null +++ b/services/endpoint_service.go @@ -0,0 +1,362 @@ +package services + +import ( + "fmt" + "log" + "math/rand" + "random-api-go/database" + "random-api-go/models" + "strconv" + "strings" + "sync" +) + +// CachedEndpoint 缓存的端点数据 +type CachedEndpoint struct { + URLs []string + // 移除ExpiresAt字段,内存缓存不再自动过期 +} + +// EndpointService API端点服务 +type EndpointService struct { + cacheManager *CacheManager + dataSourceFetcher *DataSourceFetcher + preloader *Preloader +} + +var endpointService *EndpointService +var once sync.Once + +// GetEndpointService 获取端点服务单例 +func GetEndpointService() *EndpointService { + once.Do(func() { + // 创建组件 + cacheManager := NewCacheManager() + dataSourceFetcher := NewDataSourceFetcher(cacheManager) + preloader := NewPreloader(dataSourceFetcher, cacheManager) + + endpointService = &EndpointService{ + cacheManager: cacheManager, + dataSourceFetcher: dataSourceFetcher, + preloader: preloader, + } + + // 启动预加载器 + preloader.Start() + }) + return endpointService +} + +// CreateEndpoint 创建API端点 +func (s *EndpointService) CreateEndpoint(endpoint *models.APIEndpoint) error { + if err := database.DB.Create(endpoint).Error; err != nil { + return fmt.Errorf("failed to create endpoint: %w", err) + } + + // 清理缓存 + s.cacheManager.InvalidateMemoryCache(endpoint.URL) + + // 预加载数据源 + s.preloader.PreloadEndpointOnSave(endpoint) + + return nil +} + +// GetEndpoint 获取API端点 +func (s *EndpointService) GetEndpoint(id uint) (*models.APIEndpoint, error) { + var endpoint models.APIEndpoint + if err := database.DB.Preload("DataSources").Preload("URLReplaceRules").First(&endpoint, id).Error; err != nil { + return nil, fmt.Errorf("failed to get endpoint: %w", err) + } + return &endpoint, nil +} + +// GetEndpointByURL 根据URL获取端点 +func (s *EndpointService) GetEndpointByURL(url string) (*models.APIEndpoint, error) { + var endpoint models.APIEndpoint + if err := database.DB.Preload("DataSources").Preload("URLReplaceRules"). + Where("url = ? AND is_active = ?", url, true).First(&endpoint).Error; err != nil { + return nil, fmt.Errorf("failed to get endpoint by URL: %w", err) + } + return &endpoint, nil +} + +// ListEndpoints 列出所有端点 +func (s *EndpointService) ListEndpoints() ([]*models.APIEndpoint, error) { + var endpoints []*models.APIEndpoint + if err := database.DB.Preload("DataSources").Preload("URLReplaceRules"). + Order("sort_order ASC, created_at DESC").Find(&endpoints).Error; err != nil { + return nil, fmt.Errorf("failed to list endpoints: %w", err) + } + return endpoints, nil +} + +// UpdateEndpoint 更新API端点 +func (s *EndpointService) UpdateEndpoint(endpoint *models.APIEndpoint) error { + if err := database.DB.Save(endpoint).Error; err != nil { + return fmt.Errorf("failed to update endpoint: %w", err) + } + + // 清理缓存 + s.cacheManager.InvalidateMemoryCache(endpoint.URL) + + // 预加载数据源 + s.preloader.PreloadEndpointOnSave(endpoint) + + return nil +} + +// DeleteEndpoint 删除API端点 +func (s *EndpointService) DeleteEndpoint(id uint) error { + // 先获取URL用于清理缓存 + endpoint, err := s.GetEndpoint(id) + if err != nil { + return fmt.Errorf("failed to get endpoint for deletion: %w", err) + } + + // 删除相关的数据源和URL替换规则 + if err := database.DB.Select("DataSources", "URLReplaceRules").Delete(&models.APIEndpoint{}, id).Error; err != nil { + return fmt.Errorf("failed to delete endpoint: %w", err) + } + + // 清理缓存 + s.cacheManager.InvalidateMemoryCache(endpoint.URL) + + return nil +} + +// GetRandomURL 获取随机URL +func (s *EndpointService) GetRandomURL(url string) (string, error) { + // 获取端点信息 + endpoint, err := s.GetEndpointByURL(url) + if err != nil { + return "", fmt.Errorf("endpoint not found: %w", err) + } + + // 检查是否包含API类型或端点类型的数据源 + hasRealtimeDataSource := false + for _, dataSource := range endpoint.DataSources { + if dataSource.IsActive && (dataSource.Type == "api_get" || dataSource.Type == "api_post" || dataSource.Type == "endpoint") { + hasRealtimeDataSource = true + break + } + } + + // 如果包含实时数据源,不使用内存缓存,直接实时获取 + if hasRealtimeDataSource { + log.Printf("端点包含实时数据源,使用实时请求模式: %s", url) + return s.getRandomURLRealtime(endpoint) + } + + // 非实时数据源,使用缓存模式但也先选择数据源 + return s.getRandomURLWithCache(endpoint) +} + +// getRandomURLRealtime 实时获取随机URL(用于包含API数据源的端点) +func (s *EndpointService) getRandomURLRealtime(endpoint *models.APIEndpoint) (string, error) { + // 收集所有激活的数据源 + var activeDataSources []models.DataSource + for _, dataSource := range endpoint.DataSources { + if dataSource.IsActive { + activeDataSources = append(activeDataSources, dataSource) + } + } + + if len(activeDataSources) == 0 { + return "", fmt.Errorf("no active data sources for endpoint: %s", endpoint.URL) + } + + // 先随机选择一个数据源 + selectedDataSource := activeDataSources[rand.Intn(len(activeDataSources))] + log.Printf("随机选择数据源: %s (ID: %d)", selectedDataSource.Type, selectedDataSource.ID) + + // 只从选中的数据源获取URL + urls, err := s.dataSourceFetcher.FetchURLs(&selectedDataSource) + if err != nil { + return "", fmt.Errorf("failed to get URLs from selected data source %d: %w", selectedDataSource.ID, err) + } + + if len(urls) == 0 { + return "", fmt.Errorf("no URLs available from selected data source %d", selectedDataSource.ID) + } + + // 从选中数据源的URL中随机选择一个 + randomURL := urls[rand.Intn(len(urls))] + + // 如果是端点类型的URL,需要递归调用 + if strings.HasPrefix(randomURL, "endpoint://") { + endpointIDStr := strings.TrimPrefix(randomURL, "endpoint://") + endpointID, err := strconv.ParseUint(endpointIDStr, 10, 32) + if err != nil { + return "", fmt.Errorf("invalid endpoint ID in URL: %s", randomURL) + } + + // 获取目标端点信息 + targetEndpoint, err := s.GetEndpoint(uint(endpointID)) + if err != nil { + return "", fmt.Errorf("target endpoint not found: %w", err) + } + + // 递归调用获取目标端点的随机URL + return s.GetRandomURL(targetEndpoint.URL) + } + + return s.applyURLReplaceRules(randomURL, endpoint.URL), nil +} + +// getRandomURLWithCache 使用缓存模式获取随机URL(先选择数据源) +func (s *EndpointService) getRandomURLWithCache(endpoint *models.APIEndpoint) (string, error) { + // 收集所有激活的数据源 + var activeDataSources []models.DataSource + for _, dataSource := range endpoint.DataSources { + if dataSource.IsActive { + activeDataSources = append(activeDataSources, dataSource) + } + } + + if len(activeDataSources) == 0 { + return "", fmt.Errorf("no active data sources for endpoint: %s", endpoint.URL) + } + + // 先随机选择一个数据源 + selectedDataSource := activeDataSources[rand.Intn(len(activeDataSources))] + log.Printf("随机选择数据源: %s (ID: %d)", selectedDataSource.Type, selectedDataSource.ID) + + // 从选中的数据源获取URL(会使用缓存) + urls, err := s.dataSourceFetcher.FetchURLs(&selectedDataSource) + if err != nil { + return "", fmt.Errorf("failed to get URLs from selected data source %d: %w", selectedDataSource.ID, err) + } + + if len(urls) == 0 { + return "", fmt.Errorf("no URLs available from selected data source %d", selectedDataSource.ID) + } + + // 从选中数据源的URL中随机选择一个 + randomURL := urls[rand.Intn(len(urls))] + + // 如果是端点类型的URL,需要递归调用 + if strings.HasPrefix(randomURL, "endpoint://") { + endpointIDStr := strings.TrimPrefix(randomURL, "endpoint://") + endpointID, err := strconv.ParseUint(endpointIDStr, 10, 32) + if err != nil { + return "", fmt.Errorf("invalid endpoint ID in URL: %s", randomURL) + } + + // 获取目标端点信息 + targetEndpoint, err := s.GetEndpoint(uint(endpointID)) + if err != nil { + return "", fmt.Errorf("target endpoint not found: %w", err) + } + + // 递归调用获取目标端点的随机URL + return s.GetRandomURL(targetEndpoint.URL) + } + + return s.applyURLReplaceRules(randomURL, endpoint.URL), nil +} + +// applyURLReplaceRules 应用URL替换规则 +func (s *EndpointService) applyURLReplaceRules(url, endpointURL string) string { + // 获取端点的替换规则 + endpoint, err := s.GetEndpointByURL(endpointURL) + if err != nil { + log.Printf("Failed to get endpoint for URL replacement: %v", err) + return url + } + + result := url + for _, rule := range endpoint.URLReplaceRules { + if rule.IsActive { + result = strings.ReplaceAll(result, rule.FromURL, rule.ToURL) + } + } + + return result +} + +// CreateDataSource 创建数据源 +func (s *EndpointService) CreateDataSource(dataSource *models.DataSource) error { + if err := database.DB.Create(dataSource).Error; err != nil { + return fmt.Errorf("failed to create data source: %w", err) + } + + // 获取关联的端点URL用于清理缓存 + if endpoint, err := s.GetEndpoint(dataSource.EndpointID); err == nil { + s.cacheManager.InvalidateMemoryCache(endpoint.URL) + } + + // 预加载数据源 + s.preloader.PreloadDataSourceOnSave(dataSource) + + return nil +} + +// UpdateDataSource 更新数据源 +func (s *EndpointService) UpdateDataSource(dataSource *models.DataSource) error { + if err := database.DB.Save(dataSource).Error; err != nil { + return fmt.Errorf("failed to update data source: %w", err) + } + + // 获取关联的端点URL用于清理缓存 + if endpoint, err := s.GetEndpoint(dataSource.EndpointID); err == nil { + s.cacheManager.InvalidateMemoryCache(endpoint.URL) + } + + // 预加载数据源 + s.preloader.PreloadDataSourceOnSave(dataSource) + + return nil +} + +// DeleteDataSource 删除数据源 +func (s *EndpointService) DeleteDataSource(id uint) error { + // 先获取数据源信息 + var dataSource models.DataSource + if err := database.DB.First(&dataSource, id).Error; err != nil { + return fmt.Errorf("failed to get data source: %w", err) + } + + // 删除数据源 + if err := database.DB.Delete(&dataSource).Error; err != nil { + return fmt.Errorf("failed to delete data source: %w", err) + } + + // 获取关联的端点URL用于清理缓存 + if endpoint, err := s.GetEndpoint(dataSource.EndpointID); err == nil { + s.cacheManager.InvalidateMemoryCache(endpoint.URL) + } + + return nil +} + +// RefreshDataSource 手动刷新数据源 +func (s *EndpointService) RefreshDataSource(dataSourceID uint) error { + return s.preloader.RefreshDataSource(dataSourceID) +} + +// RefreshEndpoint 手动刷新端点 +func (s *EndpointService) RefreshEndpoint(endpointID uint) error { + return s.preloader.RefreshEndpoint(endpointID) +} + +// GetPreloader 获取预加载器(用于外部控制) +func (s *EndpointService) GetPreloader() *Preloader { + return s.preloader +} + +// GetDataSourceURLCount 获取数据源的URL数量 +func (s *EndpointService) GetDataSourceURLCount(dataSource *models.DataSource) (int, error) { + // 对于API类型和端点类型的数据源,返回1(因为每次都是实时请求) + if dataSource.Type == "api_get" || dataSource.Type == "api_post" || dataSource.Type == "endpoint" { + return 1, nil + } + + // 对于其他类型的数据源,尝试获取实际的URL数量 + urls, err := s.dataSourceFetcher.FetchURLs(dataSource) + if err != nil { + return 0, err + } + + return len(urls), nil +} diff --git a/services/lankong_fetcher.go b/services/lankong_fetcher.go new file mode 100644 index 0000000..db9f086 --- /dev/null +++ b/services/lankong_fetcher.go @@ -0,0 +1,126 @@ +package services + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "random-api-go/models" + "time" +) + +// LankongFetcher 兰空图床获取器 +type LankongFetcher struct { + client *http.Client +} + +// NewLankongFetcher 创建兰空图床获取器 +func NewLankongFetcher() *LankongFetcher { + return &LankongFetcher{ + client: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// LankongResponse 兰空图床API响应 +type LankongResponse struct { + Status bool `json:"status"` + Message string `json:"message"` + Data struct { + CurrentPage int `json:"current_page"` + LastPage int `json:"last_page"` + Data []struct { + Links struct { + URL string `json:"url"` + } `json:"links"` + } `json:"data"` + } `json:"data"` +} + +// FetchURLs 从兰空图床获取URL列表 +func (lf *LankongFetcher) FetchURLs(config *models.LankongConfig) ([]string, error) { + var allURLs []string + baseURL := config.BaseURL + if baseURL == "" { + baseURL = "https://img.czl.net/api/v1/images" + } + + for _, albumID := range config.AlbumIDs { + log.Printf("开始获取相册 %s 的图片", albumID) + + // 获取第一页以确定总页数 + firstPageURL := fmt.Sprintf("%s?album_id=%s&page=1", baseURL, albumID) + response, err := lf.fetchPage(firstPageURL, config.APIToken) + if err != nil { + log.Printf("Failed to fetch first page for album %s: %v", albumID, err) + continue + } + + totalPages := response.Data.LastPage + log.Printf("相册 %s 共有 %d 页", albumID, totalPages) + + // 处理所有页面 + for page := 1; page <= totalPages; page++ { + reqURL := fmt.Sprintf("%s?album_id=%s&page=%d", baseURL, albumID, page) + pageResponse, err := lf.fetchPage(reqURL, config.APIToken) + if err != nil { + log.Printf("Failed to fetch page %d for album %s: %v", page, albumID, err) + continue + } + + for _, item := range pageResponse.Data.Data { + if item.Links.URL != "" { + allURLs = append(allURLs, item.Links.URL) + } + } + + // 添加小延迟避免请求过快 + if page < totalPages { + time.Sleep(100 * time.Millisecond) + } + } + + log.Printf("完成相册 %s: 收集到 %d 个URL", albumID, len(allURLs)) + } + + return allURLs, nil +} + +// fetchPage 获取兰空图床单页数据 +func (lf *LankongFetcher) fetchPage(url string, apiToken string) (*LankongResponse, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+apiToken) + req.Header.Set("Accept", "application/json") + + resp, err := lf.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var lankongResp LankongResponse + if err := json.Unmarshal(body, &lankongResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if !lankongResp.Status { + return nil, fmt.Errorf("API error: %s", lankongResp.Message) + } + + return &lankongResp, nil +} diff --git a/services/preloader.go b/services/preloader.go new file mode 100644 index 0000000..031937d --- /dev/null +++ b/services/preloader.go @@ -0,0 +1,275 @@ +package services + +import ( + "log" + "random-api-go/database" + "random-api-go/models" + "sync" + "time" +) + +// Preloader 预加载管理器 +type Preloader struct { + dataSourceFetcher *DataSourceFetcher + cacheManager *CacheManager + running bool + stopChan chan struct{} + mutex sync.RWMutex +} + +// NewPreloader 创建预加载管理器 +func NewPreloader(dataSourceFetcher *DataSourceFetcher, cacheManager *CacheManager) *Preloader { + return &Preloader{ + dataSourceFetcher: dataSourceFetcher, + cacheManager: cacheManager, + stopChan: make(chan struct{}), + } +} + +// Start 启动预加载器 +func (p *Preloader) Start() { + p.mutex.Lock() + defer p.mutex.Unlock() + + if p.running { + return + } + + p.running = true + go p.runPeriodicRefresh() + log.Println("预加载器已启动") +} + +// Stop 停止预加载器 +func (p *Preloader) Stop() { + p.mutex.Lock() + defer p.mutex.Unlock() + + if !p.running { + return + } + + p.running = false + close(p.stopChan) + log.Println("预加载器已停止") +} + +// PreloadDataSourceOnSave 在保存数据源时预加载数据 +func (p *Preloader) PreloadDataSourceOnSave(dataSource *models.DataSource) { + // API类型的数据源不需要预加载,使用实时请求 + if dataSource.Type == "api_get" || dataSource.Type == "api_post" { + log.Printf("API数据源 %d (%s) 使用实时请求,跳过预加载", dataSource.ID, dataSource.Type) + return + } + + // 异步预加载,避免阻塞保存操作 + go func() { + log.Printf("开始预加载数据源 %d (%s)", dataSource.ID, dataSource.Type) + + if err := p.dataSourceFetcher.PreloadDataSource(dataSource); err != nil { + log.Printf("预加载数据源 %d 失败: %v", dataSource.ID, err) + } else { + log.Printf("数据源 %d 预加载成功", dataSource.ID) + } + }() +} + +// PreloadEndpointOnSave 在保存端点时预加载所有相关数据源 +func (p *Preloader) PreloadEndpointOnSave(endpoint *models.APIEndpoint) { + // 异步预加载,避免阻塞保存操作 + go func() { + log.Printf("开始预加载端点 %d 的所有数据源", endpoint.ID) + + var wg sync.WaitGroup + for _, dataSource := range endpoint.DataSources { + if !dataSource.IsActive { + continue + } + + // API类型和端点类型的数据源跳过预加载 + if dataSource.Type == "api_get" || dataSource.Type == "api_post" || dataSource.Type == "endpoint" { + log.Printf("实时数据源 %d (%s) 使用实时请求,跳过预加载", dataSource.ID, dataSource.Type) + continue + } + + wg.Add(1) + go func(ds models.DataSource) { + defer wg.Done() + + if err := p.dataSourceFetcher.PreloadDataSource(&ds); err != nil { + log.Printf("预加载数据源 %d 失败: %v", ds.ID, err) + } + }(dataSource) + } + + wg.Wait() + log.Printf("端点 %d 的所有数据源预加载完成", endpoint.ID) + + // 预加载完成后,清理该端点的内存缓存,强制下次访问时重新构建 + p.cacheManager.InvalidateMemoryCache(endpoint.URL) + }() +} + +// RefreshDataSource 手动刷新指定数据源 +func (p *Preloader) RefreshDataSource(dataSourceID uint) error { + var dataSource models.DataSource + if err := database.DB.First(&dataSource, dataSourceID).Error; err != nil { + return err + } + + log.Printf("手动刷新数据源 %d", dataSourceID) + return p.dataSourceFetcher.PreloadDataSource(&dataSource) +} + +// RefreshEndpoint 手动刷新指定端点的所有数据源 +func (p *Preloader) RefreshEndpoint(endpointID uint) error { + var endpoint models.APIEndpoint + if err := database.DB.Preload("DataSources").First(&endpoint, endpointID).Error; err != nil { + return err + } + + log.Printf("手动刷新端点 %d 的所有数据源", endpointID) + + var wg sync.WaitGroup + var lastErr error + + for _, dataSource := range endpoint.DataSources { + if !dataSource.IsActive { + continue + } + + // API类型和端点类型的数据源跳过刷新 + if dataSource.Type == "api_get" || dataSource.Type == "api_post" || dataSource.Type == "endpoint" { + log.Printf("实时数据源 %d (%s) 使用实时请求,跳过刷新", dataSource.ID, dataSource.Type) + continue + } + + wg.Add(1) + go func(ds models.DataSource) { + defer wg.Done() + + if err := p.dataSourceFetcher.PreloadDataSource(&ds); err != nil { + log.Printf("刷新数据源 %d 失败: %v", ds.ID, err) + lastErr = err + } + }(dataSource) + } + + wg.Wait() + + // 刷新完成后,清理该端点的内存缓存 + p.cacheManager.InvalidateMemoryCache(endpoint.URL) + + return lastErr +} + +// runPeriodicRefresh 运行定期刷新任务 +func (p *Preloader) runPeriodicRefresh() { + // 定期刷新间隔:每30分钟检查一次 + ticker := time.NewTicker(30 * time.Minute) + defer ticker.Stop() + + // 启动时立即执行一次检查 + p.checkAndRefreshExpiredData() + + for { + select { + case <-ticker.C: + p.checkAndRefreshExpiredData() + case <-p.stopChan: + return + } + } +} + +// checkAndRefreshExpiredData 检查并刷新过期数据 +func (p *Preloader) checkAndRefreshExpiredData() { + log.Println("开始检查过期数据...") + + // 获取所有活跃的数据源 + var dataSources []models.DataSource + if err := database.DB.Where("is_active = ?", true).Find(&dataSources).Error; err != nil { + log.Printf("获取数据源列表失败: %v", err) + return + } + + var refreshCount int + var wg sync.WaitGroup + + for _, dataSource := range dataSources { + // API类型和端点类型的数据源跳过定期刷新 + if dataSource.Type == "api_get" || dataSource.Type == "api_post" || dataSource.Type == "endpoint" { + continue + } + + // 检查缓存是否即将过期(提前5分钟刷新) + cachedURLs, err := p.cacheManager.GetFromDBCache(dataSource.ID) + if err != nil || len(cachedURLs) == 0 { + // 没有缓存数据,需要刷新 + refreshCount++ + wg.Add(1) + go func(ds models.DataSource) { + defer wg.Done() + p.refreshDataSourceAsync(&ds) + }(dataSource) + continue + } + + // 检查是否需要定期刷新(兰空图床需要定期刷新) + if p.shouldPeriodicRefresh(&dataSource) { + refreshCount++ + wg.Add(1) + go func(ds models.DataSource) { + defer wg.Done() + p.refreshDataSourceAsync(&ds) + }(dataSource) + } + } + + if refreshCount > 0 { + log.Printf("正在刷新 %d 个数据源...", refreshCount) + wg.Wait() + log.Printf("数据源刷新完成") + } else { + log.Println("所有数据源都是最新的,无需刷新") + } +} + +// shouldPeriodicRefresh 判断是否需要定期刷新 +func (p *Preloader) shouldPeriodicRefresh(dataSource *models.DataSource) bool { + // 手动数据、API数据和端点数据不需要定期刷新 + if dataSource.Type == "manual" || dataSource.Type == "api_get" || dataSource.Type == "api_post" || dataSource.Type == "endpoint" { + return false + } + + // 如果没有最后同步时间,需要刷新 + if dataSource.LastSync == nil { + return true + } + + // 根据数据源类型设置不同的刷新间隔 + var refreshInterval time.Duration + switch dataSource.Type { + case "lankong": + refreshInterval = 24 * time.Hour // 兰空图床每24小时刷新一次 + default: + return false + } + + return time.Since(*dataSource.LastSync) > refreshInterval +} + +// refreshDataSourceAsync 异步刷新数据源 +func (p *Preloader) refreshDataSourceAsync(dataSource *models.DataSource) { + if err := p.dataSourceFetcher.PreloadDataSource(dataSource); err != nil { + log.Printf("定期刷新数据源 %d 失败: %v", dataSource.ID, err) + } else { + log.Printf("数据源 %d 定期刷新成功", dataSource.ID) + + // 更新数据库中的同步时间 + now := time.Now() + if err := database.DB.Model(dataSource).Update("last_sync", now).Error; err != nil { + log.Printf("更新数据源 %d 同步时间失败: %v", dataSource.ID, err) + } + } +} diff --git a/start.sh b/start.sh deleted file mode 100644 index ff5242d..0000000 --- a/start.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/sh - -# 如果挂载的 public 目录为空,则从临时位置复制文件 -if [ ! "$(ls -A /root/data/public)" ]; then - mkdir -p /root/data/public - cp -r /tmp/public/* /root/data/public/ -fi - -# 创建其他必要的目录 -mkdir -p /root/data/logs - -# 启动应用 -./random-api diff --git a/stats/stats.go b/stats/stats.go index 4035907..8819edd 100644 --- a/stats/stats.go +++ b/stats/stats.go @@ -13,6 +13,13 @@ type EndpointStats struct { LastResetDate string `json:"last_reset_date"` } +// EndpointStatsResponse 用于API响应的结构体,使用PascalCase +type EndpointStatsResponse struct { + TotalCalls int64 `json:"TotalCalls"` + TodayCalls int64 `json:"TodayCalls"` + LastResetDate string `json:"LastResetDate"` +} + type StatsManager struct { Stats map[string]*EndpointStats `json:"stats"` mu sync.RWMutex @@ -171,6 +178,22 @@ func (sm *StatsManager) GetStats() map[string]*EndpointStats { return statsCopy } +func (sm *StatsManager) GetStatsForAPI() map[string]*EndpointStatsResponse { + sm.mu.RLock() + defer sm.mu.RUnlock() + + statsCopy := make(map[string]*EndpointStatsResponse) + for k, v := range sm.Stats { + statsCopy[k] = &EndpointStatsResponse{ + TotalCalls: v.TotalCalls, + TodayCalls: v.TodayCalls, + LastResetDate: v.LastResetDate, + } + } + + return statsCopy +} + func (sm *StatsManager) LastSaveTime() time.Time { sm.mu.RLock() defer sm.mu.RUnlock() diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..8d10d06 --- /dev/null +++ b/web/README.md @@ -0,0 +1,36 @@ +# 端点拖拽排序功能 + +## 功能说明 + +在管理页面的API端点管理中,现在支持通过拖拽来重新排序端点。 + +## 使用方法 + +1. 进入管理页面 (`/admin`) +2. 在API端点管理表格中,每行左侧有一个拖拽图标 (⋮⋮) +3. 点击并拖拽该图标可以重新排列端点的顺序 +4. 松开鼠标后,新的排序会自动保存到后端 + +## 技术实现 + +- 使用 `@dnd-kit` 库实现拖拽功能 +- 支持鼠标和键盘操作 +- 拖拽过程中有视觉反馈(透明度变化) +- 自动保存排序到数据库 + +## 特性 + +- **无障碍支持**: 支持键盘操作 +- **视觉反馈**: 拖拽时元素半透明显示 +- **自动保存**: 排序变更后自动同步到后端 +- **错误处理**: 如果保存失败会显示错误提示 + +## 依赖 + +```json +{ + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2" +} +``` \ No newline at end of file diff --git a/web/app/admin/home/page.tsx b/web/app/admin/home/page.tsx new file mode 100644 index 0000000..62f82fd --- /dev/null +++ b/web/app/admin/home/page.tsx @@ -0,0 +1,48 @@ +'use client' + +import { useState, useEffect } from 'react' +import HomeConfigTab from '@/components/admin/HomeConfigTab' +import { authenticatedFetch } from '@/lib/auth' + +export default function HomePage() { + const [homeConfig, setHomeConfig] = useState('') + + useEffect(() => { + loadHomeConfig() + }, []) + + const loadHomeConfig = async () => { + try { + const response = await authenticatedFetch('/api/admin/home-config') + if (response.ok) { + const data = await response.json() + setHomeConfig(data.data?.content || '') + } + } catch (error) { + console.error('Failed to load home config:', error) + } + } + + const updateHomeConfig = async (content: string) => { + try { + const response = await authenticatedFetch('/api/admin/home-config', { + method: 'POST', + body: JSON.stringify({ content }), + }) + + if (response.ok) { + alert('首页配置更新成功') + setHomeConfig(content) // 更新本地状态 + } else { + alert('首页配置更新失败') + } + } catch (error) { + console.error('Failed to update home config:', error) + alert('首页配置更新失败') + } + } + + return ( + + ) +} \ No newline at end of file diff --git a/web/app/admin/layout.tsx b/web/app/admin/layout.tsx new file mode 100644 index 0000000..c273a08 --- /dev/null +++ b/web/app/admin/layout.tsx @@ -0,0 +1,127 @@ +'use client' + +import { useState, useEffect } from 'react' +import { usePathname, useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import LoginPage from '@/components/admin/LoginPage' +import { + getUserInfo, + clearAuthInfo, + isAuthenticated, + type AuthUser +} from '@/lib/auth' + +const navItems = [ + { key: 'endpoints', label: 'API端点', href: '/admin' }, + { key: 'rules', label: 'URL替换规则', href: '/admin/rules' }, + { key: 'home', label: '首页配置', href: '/admin/home' }, +] + +export default function AdminLayout({ + children, +}: { + children: React.ReactNode +}) { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + const pathname = usePathname() + const router = useRouter() + + useEffect(() => { + checkAuth() + }, []) + + const checkAuth = async () => { + if (!isAuthenticated()) { + setLoading(false) + return + } + + const savedUser = getUserInfo() + if (savedUser) { + setUser(savedUser) + setLoading(false) + return + } + + // 如果没有用户信息,清除认证状态 + clearAuthInfo() + setLoading(false) + } + + const handleLoginSuccess = (userInfo: AuthUser) => { + setUser(userInfo) + setLoading(false) + } + + const handleLogout = () => { + clearAuthInfo() + setUser(null) + router.push('/admin') + } + + if (loading) { + return ( +
+
+
+ ) + } + + if (!user) { + return + } + + return ( +
+ {/* Header */} +
+
+
+
+

+ 随机API管理后台 +

+ + {/* Navigation */} + +
+ +
+ + 欢迎, {user.name} + + +
+
+
+
+ + {/* Main Content */} +
+ {children} +
+
+ ) +} \ No newline at end of file diff --git a/web/app/admin/page.tsx b/web/app/admin/page.tsx new file mode 100644 index 0000000..86b9b69 --- /dev/null +++ b/web/app/admin/page.tsx @@ -0,0 +1,54 @@ +'use client' + +import { useState, useEffect } from 'react' +import EndpointsTab from '@/components/admin/EndpointsTab' +import type { APIEndpoint } from '@/types/admin' +import { authenticatedFetch } from '@/lib/auth' + +export default function AdminPage() { + const [endpoints, setEndpoints] = useState([]) + + useEffect(() => { + loadEndpoints() + }, []) + + const loadEndpoints = async () => { + try { + const response = await authenticatedFetch('/api/admin/endpoints') + if (response.ok) { + const data = await response.json() + setEndpoints(data.data || []) + } + } catch (error) { + console.error('Failed to load endpoints:', error) + } + } + + const createEndpoint = async (endpointData: Partial) => { + try { + const response = await authenticatedFetch('/api/admin/endpoints/', { + method: 'POST', + body: JSON.stringify(endpointData), + }) + + if (response.ok) { + loadEndpoints() // 重新加载数据 + } else { + alert('创建端点失败') + } + } catch (error) { + console.error('Failed to create endpoint:', error) + alert('创建端点失败') + } + } + + return ( + + ) +} + + \ No newline at end of file diff --git a/web/app/admin/rules/page.tsx b/web/app/admin/rules/page.tsx new file mode 100644 index 0000000..231d292 --- /dev/null +++ b/web/app/admin/rules/page.tsx @@ -0,0 +1,106 @@ +'use client' + +import { useState, useEffect } from 'react' +import URLRulesTab from '@/components/admin/URLRulesTab' +import type { URLReplaceRule, APIEndpoint } from '@/types/admin' +import { authenticatedFetch } from '@/lib/auth' + +export default function RulesPage() { + const [urlRules, setUrlRules] = useState([]) + const [endpoints, setEndpoints] = useState([]) + + useEffect(() => { + loadURLRules() + loadEndpoints() + }, []) + + const loadURLRules = async () => { + try { + const response = await authenticatedFetch('/api/admin/url-replace-rules') + if (response.ok) { + const data = await response.json() + setUrlRules(data.data || []) + } + } catch (error) { + console.error('Failed to load URL rules:', error) + } + } + + const loadEndpoints = async () => { + try { + const response = await authenticatedFetch('/api/admin/endpoints') + if (response.ok) { + const data = await response.json() + setEndpoints(data.data || []) + } + } catch (error) { + console.error('Failed to load endpoints:', error) + } + } + + const createURLRule = async (ruleData: Partial) => { + try { + const response = await authenticatedFetch('/api/admin/url-replace-rules', { + method: 'POST', + body: JSON.stringify(ruleData), + }) + + if (response.ok) { + loadURLRules() // 重新加载数据 + alert('URL替换规则创建成功') + } else { + alert('创建URL替换规则失败') + } + } catch (error) { + console.error('Failed to create URL rule:', error) + alert('创建URL替换规则失败') + } + } + + const updateURLRule = async (id: number, ruleData: Partial) => { + try { + const response = await authenticatedFetch(`/api/admin/url-replace-rules/${id}`, { + method: 'PUT', + body: JSON.stringify(ruleData), + }) + + if (response.ok) { + loadURLRules() // 重新加载数据 + alert('URL替换规则更新成功') + } else { + alert('更新URL替换规则失败') + } + } catch (error) { + console.error('Failed to update URL rule:', error) + alert('更新URL替换规则失败') + } + } + + const deleteURLRule = async (id: number) => { + try { + const response = await authenticatedFetch(`/api/admin/url-replace-rules/${id}`, { + method: 'DELETE', + }) + + if (response.ok) { + loadURLRules() // 重新加载数据 + alert('URL替换规则删除成功') + } else { + alert('删除URL替换规则失败') + } + } catch (error) { + console.error('Failed to delete URL rule:', error) + alert('删除URL替换规则失败') + } + } + + return ( + + ) +} \ No newline at end of file diff --git a/web/app/favicon.ico b/web/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/web/app/favicon.ico differ diff --git a/web/app/globals.css b/web/app/globals.css new file mode 100644 index 0000000..a4d2b6d --- /dev/null +++ b/web/app/globals.css @@ -0,0 +1,122 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: -apple-system, BlinkMacSystemFont, 'Noto Sans SC', system-ui, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; + --font-mono: -apple-system, BlinkMacSystemFont, 'Noto Sans SC', system-ui, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.129 0.042 264.695); + --card: oklch(1 0 0); + --card-foreground: oklch(0.129 0.042 264.695); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.129 0.042 264.695); + --primary: oklch(0.208 0.042 265.755); + --primary-foreground: oklch(0.984 0.003 247.858); + --secondary: oklch(0.968 0.007 247.896); + --secondary-foreground: oklch(0.208 0.042 265.755); + --muted: oklch(0.968 0.007 247.896); + --muted-foreground: oklch(0.554 0.046 257.417); + --accent: oklch(0.968 0.007 247.896); + --accent-foreground: oklch(0.208 0.042 265.755); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.929 0.013 255.508); + --input: oklch(0.929 0.013 255.508); + --ring: oklch(0.704 0.04 256.788); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.984 0.003 247.858); + --sidebar-foreground: oklch(0.129 0.042 264.695); + --sidebar-primary: oklch(0.208 0.042 265.755); + --sidebar-primary-foreground: oklch(0.984 0.003 247.858); + --sidebar-accent: oklch(0.968 0.007 247.896); + --sidebar-accent-foreground: oklch(0.208 0.042 265.755); + --sidebar-border: oklch(0.929 0.013 255.508); + --sidebar-ring: oklch(0.704 0.04 256.788); +} + +.dark { + --background: oklch(0.129 0.042 264.695); + --foreground: oklch(0.984 0.003 247.858); + --card: oklch(0.208 0.042 265.755); + --card-foreground: oklch(0.984 0.003 247.858); + --popover: oklch(0.208 0.042 265.755); + --popover-foreground: oklch(0.984 0.003 247.858); + --primary: oklch(0.929 0.013 255.508); + --primary-foreground: oklch(0.208 0.042 265.755); + --secondary: oklch(0.279 0.041 260.031); + --secondary-foreground: oklch(0.984 0.003 247.858); + --muted: oklch(0.279 0.041 260.031); + --muted-foreground: oklch(0.704 0.04 256.788); + --accent: oklch(0.279 0.041 260.031); + --accent-foreground: oklch(0.984 0.003 247.858); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.551 0.027 264.364); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.208 0.042 265.755); + --sidebar-foreground: oklch(0.984 0.003 247.858); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.984 0.003 247.858); + --sidebar-accent: oklch(0.279 0.041 260.031); + --sidebar-accent-foreground: oklch(0.984 0.003 247.858); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.551 0.027 264.364); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/web/app/layout.tsx b/web/app/layout.tsx new file mode 100644 index 0000000..d803cf1 --- /dev/null +++ b/web/app/layout.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; +import "./globals.css"; + + +export const metadata: Metadata = { + title: "Random-Api 随机文件API", + description: "随机图API, 随机视频等 ", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/web/app/page.tsx b/web/app/page.tsx new file mode 100644 index 0000000..bdac866 --- /dev/null +++ b/web/app/page.tsx @@ -0,0 +1,457 @@ +'use client' + +import { useState, useEffect } from 'react' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' + +import { Button } from '@/components/ui/button' +import Link from 'next/link' +import { apiFetch } from '@/lib/config' + +interface Endpoint { + id: number; + name: string; + url: string; + description?: string; + is_active: boolean; + show_on_homepage: boolean; + sort_order: number; +} + +interface SystemMetrics { + uptime: number; // 纳秒 + start_time: string; + num_cpu: number; + num_goroutine: number; + average_latency: number; + memory_stats: { + heap_alloc: number; + heap_sys: number; + }; +} + + +async function getHomePageConfig() { + try { + const res = await apiFetch('/api/admin/home-config') + if (!res.ok) { + throw new Error('Failed to fetch home page config') + } + const data = await res.json() + return data.data?.content || '# 欢迎使用随机API服务\n\n服务正在启动中...' + } catch (error) { + console.error('Error fetching home page config:', error) + return '# 欢迎使用随机API服务\n\n这是一个可配置的随机API服务。' + } +} + +async function getStats() { + try { + const res = await apiFetch('/api/stats') + if (!res.ok) { + throw new Error('Failed to fetch stats') + } + return await res.json() + } catch (error) { + console.error('Error fetching stats:', error) + return {} + } +} + +async function getURLStats() { + try { + const res = await apiFetch('/api/urlstats') + if (!res.ok) { + throw new Error('Failed to fetch URL stats') + } + return await res.json() + } catch (error) { + console.error('Error fetching URL stats:', error) + return {} + } +} + +async function getSystemMetrics(): Promise { + try { + const res = await apiFetch('/api/metrics') + if (!res.ok) { + throw new Error('Failed to fetch system metrics') + } + return await res.json() + } catch (error) { + console.error('Error fetching system metrics:', error) + return null + } +} + +async function getEndpoints() { + try { + const res = await apiFetch('/api/admin/endpoints') + if (!res.ok) { + throw new Error('Failed to fetch endpoints') + } + const data = await res.json() + return data.data || [] + } catch (error) { + console.error('Error fetching endpoints:', error) + return [] + } +} + + + +function formatUptime(uptimeNs: number): string { + const uptimeMs = uptimeNs / 1000000; // 纳秒转毫秒 + const days = Math.floor(uptimeMs / (1000 * 60 * 60 * 24)); + const hours = Math.floor((uptimeMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((uptimeMs % (1000 * 60 * 60)) / (1000 * 60)); + + if (days > 0) { + return `${days}天 ${hours}小时 ${minutes}分钟`; + } else if (hours > 0) { + return `${hours}小时 ${minutes}分钟`; + } else { + return `${minutes}分钟`; + } +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +function formatStartTime(startTime: string): string { + const date = new Date(startTime); + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); +} + +// 复制到剪贴板的函数 +function copyToClipboard(text: string) { + if (navigator.clipboard && window.isSecureContext) { + return navigator.clipboard.writeText(text); + } else { + // 降级方案 - 抑制弃用警告 + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + textArea.style.top = '-999999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + return new Promise((resolve, reject) => { + try { + const success = document.execCommand('copy'); + if (success) { + resolve(); + } else { + reject(new Error('Copy command failed')); + } + } catch (err) { + reject(err); + } finally { + textArea.remove(); + } + }); + } +} + +export default function Home() { + const [content, setContent] = useState('') + const [stats, setStats] = useState<{Stats?: Record}>({}) + const [urlStats, setUrlStats] = useState>({}) + const [systemMetrics, setSystemMetrics] = useState(null) + const [endpoints, setEndpoints] = useState([]) + const [copiedUrl, setCopiedUrl] = useState(null) + + useEffect(() => { + const loadData = async () => { + const [contentData, statsData, urlStatsData, systemMetricsData, endpointsData] = await Promise.all([ + getHomePageConfig(), + getStats(), + getURLStats(), + getSystemMetrics(), + getEndpoints() + ]) + + setContent(contentData) + setStats(statsData) + setUrlStats(urlStatsData) + setSystemMetrics(systemMetricsData) + setEndpoints(endpointsData) + } + + loadData() + }, []) + + + + // 过滤出首页可见的端点 + const visibleEndpoints = endpoints.filter((endpoint: Endpoint) => + endpoint.is_active && endpoint.show_on_homepage + ) + + const handleCopyUrl = async (endpoint: Endpoint) => { + const fullUrl = `${window.location.origin}/${endpoint.url}` + try { + await copyToClipboard(fullUrl) + setCopiedUrl(endpoint.url) + setTimeout(() => setCopiedUrl(null), 2000) // 2秒后清除复制状态 + } catch (err) { + console.error('复制失败:', err) + } + } + + return ( +
+ {/* 背景遮罩 */} +
+ +
+
+ {/* Header - 更简洁 */} +
+
+ + + +
+
+ + {/* System Status Section - 性冷淡风格 */} + {systemMetrics && ( +
+

+ 系统状态 +

+
+ {/* 运行时间 */} +
+
+

运行时间

+
+
+

+ {formatUptime(systemMetrics.uptime)} +

+

+ {formatStartTime(systemMetrics.start_time)} +

+
+ + {/* CPU核心数 */} +
+
+

CPU核心

+ + + +
+

+ {systemMetrics.num_cpu} 核 +

+
+ + {/* Goroutine数量 */} +
+
+

协程数

+ + + +
+

+ {systemMetrics.num_goroutine} +

+
+ + {/* 平均延迟 */} +
+
+

平均延迟

+ + + +
+

+ {systemMetrics.average_latency.toFixed(2)} ms +

+
+ + {/* 堆内存分配 */} +
+
+

堆内存

+ + + +
+

+ {formatBytes(systemMetrics.memory_stats.heap_alloc)} +

+

+ 系统: {formatBytes(systemMetrics.memory_stats.heap_sys)} +

+
+ + {/* 当前时间 +
+
+

当前时间

+ + + +
+

+ {new Date().toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + })} +

+
*/} +
+
+ )} + + + + {/* API端点统计 - 全宽布局 */} + {visibleEndpoints.length > 0 && ( +
+

+ API 端点统计 +

+
+ {visibleEndpoints.map((endpoint: Endpoint) => { + const endpointStats = stats.Stats?.[endpoint.url] || { TotalCalls: 0, TodayCalls: 0 } + const urlCount = urlStats[endpoint.url]?.total_urls || 0 + + return ( +
+
+
+

+ {endpoint.name} +

+

+ /{endpoint.url} +

+
+
+ + +
+
+ +
+
+

今日

+

+ {endpointStats.TodayCalls} +

+
+
+

总计

+

+ {endpointStats.TotalCalls} +

+
+
+

URL

+

+ {urlCount} +

+
+
+ + {endpoint.description && ( +

+ {endpoint.description} +

+ )} +
+ ) + })} +
+
+ )} + + {/* Main Content - 半透明 */} +
+
+

{children}

, + h2: ({children}) =>

{children}

, + h3: ({children}) =>

{children}

, + p: ({children}) =>

{children}

, + ul: ({children}) =>
    {children}
, + ol: ({children}) =>
    {children}
, + li: ({children}) =>
  • {children}
  • , + strong: ({children}) => {children}, + em: ({children}) => {children}, + code: ({children}) => {children}, + pre: ({children}) =>
    {children}
    , + blockquote: ({children}) =>
    {children}
    , + a: ({href, children}) => {children}, + }} + > + {content} +
    +
    +
    + + {/* Footer - 包含管理后台链接 */} +
    +

    随机API服务 - 基于 Next.js 和 Go 构建

    +

    + + 管理后台 + +

    +
    +
    +
    +
    + ) +} diff --git a/web/components.json b/web/components.json new file mode 100644 index 0000000..a08feaa --- /dev/null +++ b/web/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/web/components/admin/DataSourceConfigForm.tsx b/web/components/admin/DataSourceConfigForm.tsx new file mode 100644 index 0000000..3608a6a --- /dev/null +++ b/web/components/admin/DataSourceConfigForm.tsx @@ -0,0 +1,564 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Checkbox } from '@/components/ui/checkbox' +import { Trash2, Plus } from 'lucide-react' +import { authenticatedFetch } from '@/lib/auth' + +interface DataSourceConfigFormProps { + type: 'lankong' | 'manual' | 'api_get' | 'api_post' | 'endpoint' + config: string + onChange: (config: string) => void +} + +interface LankongConfig { + api_token: string + album_ids: string[] + base_url?: string +} + +interface APIConfig { + url: string + method?: string + headers: { [key: string]: string } + body?: string + url_field: string +} + +interface SavedToken { + id: string + name: string + token: string +} + +interface EndpointConfig { + endpoint_ids: number[] +} + +export default function DataSourceConfigForm({ type, config, onChange }: DataSourceConfigFormProps) { + const [lankongConfig, setLankongConfig] = useState({ + api_token: '', + album_ids: [''], + base_url: '' + }) + + const [apiConfig, setAPIConfig] = useState({ + url: '', + method: type === 'api_post' ? 'POST' : 'GET', + headers: {}, + body: '', + url_field: 'url' + }) + + const [endpointConfig, setEndpointConfig] = useState({ + endpoint_ids: [] + }) + + const [availableEndpoints, setAvailableEndpoints] = useState>([]) + + const [headerPairs, setHeaderPairs] = useState>([{key: '', value: ''}]) + const [savedTokens, setSavedTokens] = useState([]) + + const [newTokenName, setNewTokenName] = useState('') + + // 从localStorage加载保存的token + useEffect(() => { + const saved = localStorage.getItem('lankong_tokens') + if (saved) { + try { + setSavedTokens(JSON.parse(saved)) + } catch (error) { + console.error('Failed to parse saved tokens:', error) + } + } + }, []) + + // 获取可用端点列表 + useEffect(() => { + if (type === 'endpoint') { + loadAvailableEndpoints() + } + }, [type]) + + const loadAvailableEndpoints = async () => { + try { + const response = await authenticatedFetch('/api/admin/endpoints') + if (response.ok) { + const data = await response.json() + setAvailableEndpoints(data.data || []) + } + } catch (error) { + console.error('Failed to load endpoints:', error) + } + } + + // 解析现有配置 + useEffect(() => { + if (!config) return + + try { + const parsed = JSON.parse(config) + + if (type === 'lankong') { + setLankongConfig({ + api_token: parsed.api_token || '', + album_ids: parsed.album_ids || [''], + base_url: parsed.base_url || '' + }) + } else if (type === 'api_get' || type === 'api_post') { + setAPIConfig({ + url: parsed.url || '', + method: parsed.method || (type === 'api_post' ? 'POST' : 'GET'), + headers: parsed.headers || {}, + body: parsed.body || '', + url_field: parsed.url_field || 'url' + }) + + // 转换headers为键值对数组 + const pairs = Object.entries(parsed.headers || {}).map(([key, value]) => ({key, value: value as string})) + if (pairs.length === 0) pairs.push({key: '', value: ''}) + setHeaderPairs(pairs) + } else if (type === 'endpoint') { + setEndpointConfig({ + endpoint_ids: parsed.endpoint_ids || [] + }) + } + } catch (error) { + console.error('Failed to parse config:', error) + } + }, [config, type]) + + // 保存token到localStorage + const saveToken = () => { + if (!newTokenName.trim() || !lankongConfig.api_token.trim()) { + alert('请输入token名称和token值') + return + } + + const newToken: SavedToken = { + id: Date.now().toString(), + name: newTokenName.trim(), + token: lankongConfig.api_token + } + + const updated = [...savedTokens, newToken] + setSavedTokens(updated) + localStorage.setItem('lankong_tokens', JSON.stringify(updated)) + setNewTokenName('') + alert('Token保存成功') + } + + // 删除保存的token + const deleteToken = (tokenId: string) => { + if (!confirm('确定要删除这个token吗?')) return + + const updated = savedTokens.filter(t => t.id !== tokenId) + setSavedTokens(updated) + localStorage.setItem('lankong_tokens', JSON.stringify(updated)) + } + + // 更新兰空图床配置 + const updateConfig = (newConfig: LankongConfig | APIConfig) => { + onChange(JSON.stringify(newConfig)) + } + + // 添加相册ID + const addAlbumId = () => { + const newConfig = { + ...lankongConfig, + album_ids: [...lankongConfig.album_ids, ''] + } + setLankongConfig(newConfig) + updateConfig(newConfig) + } + + // 删除相册ID + const removeAlbumId = (index: number) => { + const newConfig = { + ...lankongConfig, + album_ids: lankongConfig.album_ids.filter((_, i) => i !== index) + } + setLankongConfig(newConfig) + updateConfig(newConfig) + } + + // 更新相册ID + const updateAlbumId = (index: number, value: string) => { + const newConfig = { + ...lankongConfig, + album_ids: lankongConfig.album_ids.map((id, i) => i === index ? value : id) + } + setLankongConfig(newConfig) + updateConfig(newConfig) + } + + // 添加请求头 + const addHeader = () => { + setHeaderPairs([...headerPairs, {key: '', value: ''}]) + } + + // 删除请求头 + const removeHeader = (index: number) => { + const newPairs = headerPairs.filter((_, i) => i !== index) + setHeaderPairs(newPairs) + updateAPIHeaders(newPairs) + } + + // 更新请求头 + const updateHeader = (index: number, field: 'key' | 'value', value: string) => { + const newPairs = headerPairs.map((pair, i) => + i === index ? { ...pair, [field]: value } : pair + ) + setHeaderPairs(newPairs) + updateAPIHeaders(newPairs) + } + + // 更新API配置的headers + const updateAPIHeaders = (pairs: Array<{key: string, value: string}>) => { + const headers: { [key: string]: string } = {} + pairs.forEach(pair => { + if (pair.key.trim() && pair.value.trim()) { + headers[pair.key.trim()] = pair.value.trim() + } + }) + + const newConfig = { ...apiConfig, headers } + setAPIConfig(newConfig) + updateConfig(newConfig) + } + + // 更新API配置 + const updateAPIConfig = (field: keyof APIConfig, value: string) => { + // 对URL字段进行trim处理,去除前后空格 + const trimmedValue = field === 'url' ? value.trim() : value + const newConfig = { ...apiConfig, [field]: trimmedValue } + setAPIConfig(newConfig) + updateConfig(newConfig) + } + + // 更新端点配置 + const updateEndpointConfig = (endpointIds: number[]) => { + const newConfig = { endpoint_ids: endpointIds } + setEndpointConfig(newConfig) + onChange(JSON.stringify(newConfig)) + } + + // 切换端点选择 + const toggleEndpoint = (endpointId: number) => { + const currentIds = endpointConfig.endpoint_ids + const newIds = currentIds.includes(endpointId) + ? currentIds.filter(id => id !== endpointId) + : [...currentIds, endpointId] + updateEndpointConfig(newIds) + } + + if (type === 'manual') { + return ( +
    + +