新增对S3对象存储的支持,更新数据源配置和管理界面,优化数据源预加载逻辑,重构相关数据处理逻辑,提升系统灵活性和可维护性。

This commit is contained in:
wood chen 2025-06-15 23:38:55 +08:00
parent e21b5dac5f
commit 4150df03cf
14 changed files with 856 additions and 110 deletions

View File

@ -61,6 +61,11 @@ func Initialize(dataDir string) error {
sqlDB.SetMaxIdleConns(1) sqlDB.SetMaxIdleConns(1)
sqlDB.SetConnMaxLifetime(time.Hour) sqlDB.SetConnMaxLifetime(time.Hour)
// 在自动迁移之前清理旧的CHECK约束
if err := cleanupOldConstraints(); err != nil {
return fmt.Errorf("failed to cleanup old constraints: %w", err)
}
// 自动迁移数据库结构 // 自动迁移数据库结构
if err := autoMigrate(); err != nil { if err := autoMigrate(); err != nil {
return fmt.Errorf("failed to migrate database: %w", err) return fmt.Errorf("failed to migrate database: %w", err)
@ -80,6 +85,80 @@ func autoMigrate() error {
) )
} }
// cleanupOldConstraints 清理旧的CHECK约束
func cleanupOldConstraints() error {
// 检查data_sources表是否存在且包含CHECK约束
var count int64
err := DB.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='data_sources'").Scan(&count).Error
if err != nil {
return err
}
// 如果表不存在,直接返回
if count == 0 {
log.Println("data_sources表不存在跳过约束清理")
return nil
}
// 检查是否有CHECK约束
var constraintCount int64
err = DB.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='data_sources' AND sql LIKE '%CHECK%'").Scan(&constraintCount).Error
if err != nil {
return err
}
// 如果没有CHECK约束直接返回
if constraintCount == 0 {
log.Println("data_sources表没有CHECK约束跳过清理")
return nil
}
log.Println("检测到旧的CHECK约束开始清理...")
// 重建表去掉CHECK约束
// 1. 创建新表不包含CHECK约束
createNewTableSQL := `
CREATE TABLE data_sources_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
endpoint_id INTEGER NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
config TEXT NOT NULL,
is_active BOOLEAN DEFAULT true,
last_sync DATETIME,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME
)`
if err := DB.Exec(createNewTableSQL).Error; err != nil {
return fmt.Errorf("创建新表失败: %w", err)
}
// 2. 复制数据
copyDataSQL := `
INSERT INTO data_sources_new (id, endpoint_id, name, type, config, is_active, last_sync, created_at, updated_at, deleted_at)
SELECT id, endpoint_id, name, type, config, is_active, last_sync, created_at, updated_at, deleted_at
FROM data_sources`
if err := DB.Exec(copyDataSQL).Error; err != nil {
return fmt.Errorf("复制数据失败: %w", err)
}
// 3. 删除旧表
if err := DB.Exec("DROP TABLE data_sources").Error; err != nil {
return fmt.Errorf("删除旧表失败: %w", err)
}
// 4. 重命名新表
if err := DB.Exec("ALTER TABLE data_sources_new RENAME TO data_sources").Error; err != nil {
return fmt.Errorf("重命名表失败: %w", err)
}
log.Println("旧的CHECK约束清理完成")
return nil
}
// Close 关闭数据库连接 // Close 关闭数据库连接
func Close() error { func Close() error {
if DB != nil { if DB != nil {

18
go.mod
View File

@ -5,12 +5,30 @@ go 1.23.0
toolchain go1.23.1 toolchain go1.23.1
require ( require (
github.com/aws/aws-sdk-go-v2 v1.36.4
github.com/aws/aws-sdk-go-v2/config v1.29.16
github.com/aws/aws-sdk-go-v2/credentials v1.17.69
github.com/aws/aws-sdk-go-v2/service/s3 v1.80.2
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
golang.org/x/time v0.12.0 golang.org/x/time v0.12.0
gorm.io/gorm v1.30.0 gorm.io/gorm v1.30.0
) )
require ( require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.31 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.35 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.35 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.35 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.16 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.25.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.21 // indirect
github.com/aws/smithy-go v1.22.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/google/uuid v1.5.0 // indirect github.com/google/uuid v1.5.0 // indirect

36
go.sum
View File

@ -1,3 +1,39 @@
github.com/aws/aws-sdk-go-v2 v1.36.4 h1:GySzjhVvx0ERP6eyfAbAuAXLtAda5TEy19E5q5W8I9E=
github.com/aws/aws-sdk-go-v2 v1.36.4/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14=
github.com/aws/aws-sdk-go-v2/config v1.29.16 h1:XkruGnXX1nEZ+Nyo9v84TzsX+nj86icbFAeust6uo8A=
github.com/aws/aws-sdk-go-v2/config v1.29.16/go.mod h1:uCW7PNjGwZ5cOGZ5jr8vCWrYkGIhPoTNV23Q/tpHKzg=
github.com/aws/aws-sdk-go-v2/credentials v1.17.69 h1:8B8ZQboRc3uaIKjshve/XlvJ570R7BKNy3gftSbS178=
github.com/aws/aws-sdk-go-v2/credentials v1.17.69/go.mod h1:gPME6I8grR1jCqBFEGthULiolzf/Sexq/Wy42ibKK9c=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.31 h1:oQWSGexYasNpYp4epLGZxxjsDo8BMBh6iNWkTXQvkwk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.31/go.mod h1:nc332eGUU+djP3vrMI6blS0woaCfHTe3KiSQUVTMRq0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.35 h1:o1v1VFfPcDVlK3ll1L5xHsaQAFdNtZ5GXnNR7SwueC4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.35/go.mod h1:rZUQNYMNG+8uZxz9FOerQJ+FceCiodXvixpeRtdESrU=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.35 h1:R5b82ubO2NntENm3SAm0ADME+H630HomNJdgv+yZ3xw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.35/go.mod h1:FuA+nmgMRfkzVKYDNEqQadvEMxtxl9+RLT9ribCwEMs=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.35 h1:th/m+Q18CkajTw1iqx2cKkLCij/uz8NMwJFPK91p2ug=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.35/go.mod h1:dkJuf0a1Bc8HAA0Zm2MoTGm/WDC18Td9vSbrQ1+VqE8=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.3 h1:VHPZakq2L7w+RLzV54LmQavbvheFaR2u1NomJRSEfcU=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.3/go.mod h1:DX1e/lkbsAt0MkY3NgLYuH4jQvRfw8MYxTe9feR7aXM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.16 h1:/ldKrPPXTC421bTNWrUIpq3CxwHwRI/kpc+jPUTJocM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.16/go.mod h1:5vkf/Ws0/wgIMJDQbjI4p2op86hNW6Hie5QtebrDgT8=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.16 h1:2HuI7vWKhFWsBhIr2Zq8KfFZT6xqaId2XXnXZjkbEuc=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.16/go.mod h1:BrwWnsfbFtFeRjdx0iM1ymvlqDX1Oz68JsQaibX/wG8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.80.2 h1:T6Wu+8E2LeTUqzqQ/Bh1EoFNj1u4jUyveMgmTlu9fDU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.80.2/go.mod h1:chSY8zfqmS0OnhZoO/hpPx/BHfAIL80m77HwhRLYScY=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.4 h1:EU58LP8ozQDVroOEyAfcq0cGc5R/FTZjVoYJ6tvby3w=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.4/go.mod h1:CrtOgCcysxMvrCoHnvNAD7PHWclmoFG78Q2xLK0KKcs=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.2 h1:XB4z0hbQtpmBnb1FQYvKaCM7UsS6Y/u8jVBwIUGeCTk=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.2/go.mod h1:hwRpqkRxnQ58J9blRDrB4IanlXCpcKmsC83EhG77upg=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.21 h1:nyLjs8sYJShFYj6aiyjCBI3EcLn1udWrQTjEF+SOXB0=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.21/go.mod h1:EhdxtZ+g84MSGrSrHzZiUm9PYiZkrADNja15wtRJSJo=
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 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/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 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=

View File

@ -32,19 +32,34 @@ func InitData() error {
// 4. 统计需要预加载的数据源 // 4. 统计需要预加载的数据源
var activeDataSources []model.DataSource var activeDataSources []model.DataSource
var totalDataSources, disabledDataSources, apiDataSources int
for _, endpoint := range endpoints { for _, endpoint := range endpoints {
if !endpoint.IsActive { if !endpoint.IsActive {
continue continue
} }
for _, ds := range endpoint.DataSources { for _, ds := range endpoint.DataSources {
if ds.IsActive && ds.Type != "api_get" && ds.Type != "api_post" { totalDataSources++
// API类型的数据源不需要预加载使用实时请求
activeDataSources = append(activeDataSources, ds) if !ds.IsActive {
disabledDataSources++
log.Printf("跳过禁用的数据源: %s (ID: %d)", ds.Name, ds.ID)
continue
} }
if ds.Type == "api_get" || ds.Type == "api_post" {
apiDataSources++
log.Printf("跳过API类型数据源: %s (ID: %d, 类型: %s) - 使用实时请求", ds.Name, ds.ID, ds.Type)
continue
}
// 需要预加载的数据源
activeDataSources = append(activeDataSources, ds)
} }
} }
log.Printf("发现 %d 个端点,%d 个需要预加载的数据源", len(endpoints), len(activeDataSources)) log.Printf("发现 %d 个端点,总共 %d 个数据源", len(endpoints), totalDataSources)
log.Printf("其中: 禁用 %d 个API类型 %d 个,需要预加载 %d 个", disabledDataSources, apiDataSources, len(activeDataSources))
if len(activeDataSources) == 0 { if len(activeDataSources) == 0 {
log.Println("✓ 没有需要预加载的数据源") log.Println("✓ 没有需要预加载的数据源")

View File

@ -29,7 +29,7 @@ type DataSource struct {
ID uint `json:"id" gorm:"primaryKey"` ID uint `json:"id" gorm:"primaryKey"`
EndpointID uint `json:"endpoint_id" gorm:"not null;index"` EndpointID uint `json:"endpoint_id" gorm:"not null;index"`
Name string `json:"name" gorm:"not null"` Name string `json:"name" gorm:"not null"`
Type string `json:"type" gorm:"not null;check:type IN ('lankong', 'manual', 'api_get', 'api_post', 'endpoint')"` Type string `json:"type" gorm:"not null"`
Config string `json:"config" gorm:"not null"` Config string `json:"config" gorm:"not null"`
IsActive bool `json:"is_active" gorm:"default:true"` IsActive bool `json:"is_active" gorm:"default:true"`
LastSync *time.Time `json:"last_sync,omitempty"` LastSync *time.Time `json:"last_sync,omitempty"`
@ -80,6 +80,9 @@ type DataSourceConfig struct {
// 端点配置 // 端点配置
EndpointConfig *EndpointConfig `json:"endpoint_config,omitempty"` EndpointConfig *EndpointConfig `json:"endpoint_config,omitempty"`
// S3配置
S3Config *S3Config `json:"s3_config,omitempty"`
} }
type LankongConfig struct { type LankongConfig struct {
@ -103,3 +106,26 @@ type APIConfig struct {
type EndpointConfig struct { type EndpointConfig struct {
EndpointIDs []uint `json:"endpoint_ids"` // 选中的端点ID列表 EndpointIDs []uint `json:"endpoint_ids"` // 选中的端点ID列表
} }
// S3Config S3通用协议配置
type S3Config struct {
// 基础配置
Endpoint string `json:"endpoint"` // S3端点地址
BucketName string `json:"bucket_name"` // 存储桶名称
Region string `json:"region"` // 地区
AccessKeyID string `json:"access_key_id"` // 访问密钥ID
SecretAccessKey string `json:"secret_access_key"` // 访问密钥
// 高级配置
ListObjectsVersion string `json:"list_objects_version"` // 列出对象版本 (v1/v2)默认v2
UsePathStyle bool `json:"use_path_style"` // 是否使用path style
RemoveBucket bool `json:"remove_bucket"` // 是否从路径中删除bucket名称
// 自定义域名配置
CustomDomain string `json:"custom_domain"` // 自定义访问域名,支持路径
// 文件过滤配置
FolderPath string `json:"folder_path"` // 提取的文件夹路径,例如: /img
IncludeSubfolders bool `json:"include_subfolders"` // 是否提取所有子文件夹
FileExtensions []string `json:"file_extensions"` // 提取的文件格式后缀,支持正则匹配
}

104
readme.md
View File

@ -4,7 +4,7 @@
## 功能特性 ## 功能特性
- 🎯 支持多种数据源兰空图床API、手动配置、通用API接口 - 🎯 支持多种数据源兰空图床API, s3协议对象存储, 手动配置, 通用API接口(GET/POST)
- 🔐 OAuth2.0 管理后台登录CZL Connect - 🔐 OAuth2.0 管理后台登录CZL Connect
- 💾 SQLite数据库存储 - 💾 SQLite数据库存储
- ⚡ 内存缓存机制 - ⚡ 内存缓存机制
@ -12,107 +12,9 @@
- 📝 可配置首页内容 - 📝 可配置首页内容
- 🎨 现代化Web管理界面 - 🎨 现代化Web管理界面
## 环境变量配置 ## 部署and讨论
复制 `env.example``.env` 并配置以下环境变量: <https://www.q58.club/t/topic/127>
```bash
# 服务器配置
PORT=:5003 # 服务端口
READ_TIMEOUT=30s # 读取超时
WRITE_TIMEOUT=30s # 写入超时
MAX_HEADER_BYTES=1048576 # 最大请求头大小
# 数据存储目录
DATA_DIR=./data # 数据存储目录
# OAuth2.0 配置 (必需)
OAUTH_CLIENT_ID=your-oauth-client-id # CZL Connect 客户端ID
OAUTH_CLIENT_SECRET=your-oauth-client-secret # CZL Connect 客户端密钥
```
## 快速开始
1. 克隆项目
```bash
git clone <repository-url>
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配置。
## 许可证 ## 许可证

View File

@ -16,6 +16,7 @@ type DataSourceFetcher struct {
cacheManager *CacheManager cacheManager *CacheManager
lankongFetcher *LankongFetcher lankongFetcher *LankongFetcher
apiFetcher *APIFetcher apiFetcher *APIFetcher
s3Fetcher *S3Fetcher
} }
// NewDataSourceFetcher 创建数据源获取器 // NewDataSourceFetcher 创建数据源获取器
@ -38,6 +39,7 @@ func NewDataSourceFetcher(cacheManager *CacheManager) *DataSourceFetcher {
cacheManager: cacheManager, cacheManager: cacheManager,
lankongFetcher: lankongFetcher, lankongFetcher: lankongFetcher,
apiFetcher: NewAPIFetcher(), apiFetcher: NewAPIFetcher(),
s3Fetcher: NewS3Fetcher(),
} }
} }
@ -86,6 +88,8 @@ func (dsf *DataSourceFetcher) FetchURLs(dataSource *model.DataSource) ([]string,
urls, err = dsf.fetchManualURLs(dataSource) urls, err = dsf.fetchManualURLs(dataSource)
case "endpoint": case "endpoint":
urls, err = dsf.fetchEndpointURLs(dataSource) urls, err = dsf.fetchEndpointURLs(dataSource)
case "s3":
urls, err = dsf.fetchS3URLs(dataSource)
default: default:
return nil, fmt.Errorf("unsupported data source type: %s", dataSource.Type) return nil, fmt.Errorf("unsupported data source type: %s", dataSource.Type)
} }
@ -180,6 +184,16 @@ func (dsf *DataSourceFetcher) fetchEndpointURLs(dataSource *model.DataSource) ([
return urls, nil return urls, nil
} }
// fetchS3URLs 获取S3存储桶URL
func (dsf *DataSourceFetcher) fetchS3URLs(dataSource *model.DataSource) ([]string, error) {
var config model.S3Config
if err := json.Unmarshal([]byte(dataSource.Config), &config); err != nil {
return nil, fmt.Errorf("invalid S3 config: %w", err)
}
return dsf.s3Fetcher.FetchURLs(&config)
}
// updateDataSourceSyncTime 更新数据源的同步时间 // updateDataSourceSyncTime 更新数据源的同步时间
func (dsf *DataSourceFetcher) updateDataSourceSyncTime(dataSource *model.DataSource) error { func (dsf *DataSourceFetcher) updateDataSourceSyncTime(dataSource *model.DataSource) error {
// 这里需要导入database包来更新数据库 // 这里需要导入database包来更新数据库

View File

@ -22,6 +22,26 @@ type EndpointService struct {
var endpointService *EndpointService var endpointService *EndpointService
var once sync.Once var once sync.Once
// 支持的数据源类型列表
var supportedDataSourceTypes = []string{
"lankong",
"manual",
"api_get",
"api_post",
"endpoint",
"s3",
}
// validateDataSourceType 验证数据源类型
func validateDataSourceType(dataSourceType string) error {
for _, supportedType := range supportedDataSourceTypes {
if dataSourceType == supportedType {
return nil
}
}
return fmt.Errorf("unsupported data source type: %s, supported types: %v", dataSourceType, supportedDataSourceTypes)
}
// GetEndpointService 获取端点服务单例 // GetEndpointService 获取端点服务单例
func GetEndpointService() *EndpointService { func GetEndpointService() *EndpointService {
once.Do(func() { once.Do(func() {
@ -287,6 +307,11 @@ func (s *EndpointService) applyURLReplaceRules(url, endpointURL string) string {
// CreateDataSource 创建数据源 // CreateDataSource 创建数据源
func (s *EndpointService) CreateDataSource(dataSource *model.DataSource) error { func (s *EndpointService) CreateDataSource(dataSource *model.DataSource) error {
// 验证数据源类型
if err := validateDataSourceType(dataSource.Type); err != nil {
return err
}
if err := database.DB.Create(dataSource).Error; err != nil { if err := database.DB.Create(dataSource).Error; err != nil {
return fmt.Errorf("failed to create data source: %w", err) return fmt.Errorf("failed to create data source: %w", err)
} }
@ -304,6 +329,11 @@ func (s *EndpointService) CreateDataSource(dataSource *model.DataSource) error {
// UpdateDataSource 更新数据源 // UpdateDataSource 更新数据源
func (s *EndpointService) UpdateDataSource(dataSource *model.DataSource) error { func (s *EndpointService) UpdateDataSource(dataSource *model.DataSource) error {
// 验证数据源类型
if err := validateDataSourceType(dataSource.Type); err != nil {
return err
}
if err := database.DB.Save(dataSource).Error; err != nil { if err := database.DB.Save(dataSource).Error; err != nil {
return fmt.Errorf("failed to update data source: %w", err) return fmt.Errorf("failed to update data source: %w", err)
} }

View File

@ -74,6 +74,12 @@ func (p *Preloader) ResumePeriodicRefresh() {
// PreloadDataSourceOnSave 在保存数据源时预加载数据 // PreloadDataSourceOnSave 在保存数据源时预加载数据
func (p *Preloader) PreloadDataSourceOnSave(dataSource *model.DataSource) { func (p *Preloader) PreloadDataSourceOnSave(dataSource *model.DataSource) {
// 检查数据源是否处于活跃状态
if !dataSource.IsActive {
log.Printf("数据源 %d 已禁用,跳过预加载", dataSource.ID)
return
}
// API类型的数据源不需要预加载使用实时请求 // API类型的数据源不需要预加载使用实时请求
if dataSource.Type == "api_get" || dataSource.Type == "api_post" { if dataSource.Type == "api_get" || dataSource.Type == "api_post" {
log.Printf("API数据源 %d (%s) 使用实时请求,跳过预加载", dataSource.ID, dataSource.Type) log.Printf("API数据源 %d (%s) 使用实时请求,跳过预加载", dataSource.ID, dataSource.Type)
@ -135,6 +141,18 @@ func (p *Preloader) RefreshDataSource(dataSourceID uint) error {
return err return err
} }
// 检查数据源是否处于活跃状态
if !dataSource.IsActive {
log.Printf("数据源 %d 已禁用,跳过刷新", dataSourceID)
return nil
}
// API类型的数据源不需要预加载使用实时请求
if dataSource.Type == "api_get" || dataSource.Type == "api_post" {
log.Printf("API数据源 %d (%s) 使用实时请求,跳过刷新", dataSource.ID, dataSource.Type)
return nil
}
log.Printf("手动刷新数据源 %d", dataSourceID) log.Printf("手动刷新数据源 %d", dataSourceID)
return p.dataSourceFetcher.PreloadDataSource(&dataSource) return p.dataSourceFetcher.PreloadDataSource(&dataSource)
} }
@ -281,6 +299,8 @@ func (p *Preloader) shouldPeriodicRefresh(dataSource *model.DataSource) bool {
switch dataSource.Type { switch dataSource.Type {
case "lankong": case "lankong":
refreshInterval = 24 * time.Hour // 兰空图床每24小时刷新一次 refreshInterval = 24 * time.Hour // 兰空图床每24小时刷新一次
case "s3":
refreshInterval = 6 * time.Hour // S3存储每6小时刷新一次
default: default:
return false return false
} }

330
service/s3_fetcher.go Normal file
View File

@ -0,0 +1,330 @@
package service
import (
"context"
"fmt"
"log"
"net/url"
"random-api-go/model"
"regexp"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
)
// S3Fetcher S3获取器
type S3Fetcher struct {
timeout time.Duration
}
// NewS3Fetcher 创建S3获取器
func NewS3Fetcher() *S3Fetcher {
return &S3Fetcher{
timeout: 30 * time.Second,
}
}
// FetchURLs 从S3存储桶获取文件URL列表
func (sf *S3Fetcher) FetchURLs(s3Config *model.S3Config) ([]string, error) {
if s3Config == nil {
return nil, fmt.Errorf("S3配置不能为空")
}
// 验证必需的配置
if s3Config.Endpoint == "" {
return nil, fmt.Errorf("S3端点地址不能为空")
}
if s3Config.BucketName == "" {
return nil, fmt.Errorf("存储桶名称不能为空")
}
if s3Config.AccessKeyID == "" {
return nil, fmt.Errorf("访问密钥ID不能为空")
}
if s3Config.SecretAccessKey == "" {
return nil, fmt.Errorf("访问密钥不能为空")
}
// 创建S3客户端
client, err := sf.createS3Client(s3Config)
if err != nil {
return nil, fmt.Errorf("创建S3客户端失败: %w", err)
}
// 获取对象列表
objects, err := sf.listObjects(client, s3Config)
if err != nil {
return nil, fmt.Errorf("获取对象列表失败: %w", err)
}
// 过滤和转换为URL
urls := sf.convertObjectsToURLs(objects, s3Config)
log.Printf("从S3存储桶 %s 获取到 %d 个文件URL", s3Config.BucketName, len(urls))
return urls, nil
}
// createS3Client 创建S3客户端
func (sf *S3Fetcher) createS3Client(s3Config *model.S3Config) (*s3.Client, error) {
// 设置默认地区
region := s3Config.Region
if region == "" {
region = "us-east-1"
}
// 创建凭证
creds := credentials.NewStaticCredentialsProvider(
s3Config.AccessKeyID,
s3Config.SecretAccessKey,
"",
)
// 创建配置
cfg, err := config.LoadDefaultConfig(context.TODO(),
config.WithRegion(region),
config.WithCredentialsProvider(creds),
)
if err != nil {
return nil, fmt.Errorf("加载AWS配置失败: %w", err)
}
// 创建S3客户端选项
options := func(o *s3.Options) {
if s3Config.Endpoint != "" {
o.BaseEndpoint = aws.String(s3Config.Endpoint)
}
o.UsePathStyle = s3Config.UsePathStyle
}
client := s3.NewFromConfig(cfg, options)
return client, nil
}
// listObjects 列出存储桶中的对象
func (sf *S3Fetcher) listObjects(client *s3.Client, s3Config *model.S3Config) ([]types.Object, error) {
ctx, cancel := context.WithTimeout(context.Background(), sf.timeout)
defer cancel()
var allObjects []types.Object
var continuationToken *string
// 设置前缀(文件夹路径)
prefix := strings.TrimPrefix(s3Config.FolderPath, "/")
if prefix != "" && !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
// 设置分隔符(如果不包含子文件夹)
var delimiter *string
if !s3Config.IncludeSubfolders {
delimiter = aws.String("/")
}
// 确定使用的ListObjects版本
listVersion := s3Config.ListObjectsVersion
if listVersion == "" {
listVersion = "v2" // 默认使用v2
}
for {
if listVersion == "v1" {
// 使用ListObjects (v1)
input := &s3.ListObjectsInput{
Bucket: aws.String(s3Config.BucketName),
Prefix: aws.String(prefix),
Delimiter: delimiter,
MaxKeys: aws.Int32(1000),
}
if continuationToken != nil {
input.Marker = continuationToken
}
result, err := client.ListObjects(ctx, input)
if err != nil {
return nil, fmt.Errorf("ListObjects失败: %w", err)
}
allObjects = append(allObjects, result.Contents...)
if !aws.ToBool(result.IsTruncated) {
break
}
if len(result.Contents) > 0 {
continuationToken = result.Contents[len(result.Contents)-1].Key
}
} else {
// 使用ListObjectsV2 (v2)
input := &s3.ListObjectsV2Input{
Bucket: aws.String(s3Config.BucketName),
Prefix: aws.String(prefix),
Delimiter: delimiter,
MaxKeys: aws.Int32(1000),
ContinuationToken: continuationToken,
}
result, err := client.ListObjectsV2(ctx, input)
if err != nil {
return nil, fmt.Errorf("ListObjectsV2失败: %w", err)
}
allObjects = append(allObjects, result.Contents...)
if !aws.ToBool(result.IsTruncated) {
break
}
continuationToken = result.NextContinuationToken
}
}
return allObjects, nil
}
// convertObjectsToURLs 将S3对象转换为URL列表
func (sf *S3Fetcher) convertObjectsToURLs(objects []types.Object, s3Config *model.S3Config) []string {
var urls []string
// 编译文件扩展名正则表达式
var extensionRegexes []*regexp.Regexp
for _, ext := range s3Config.FileExtensions {
if ext != "" {
// 确保扩展名以点开头
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
// 转义特殊字符并创建正则表达式
pattern := regexp.QuoteMeta(ext) + "$"
if regex, err := regexp.Compile("(?i)" + pattern); err == nil {
extensionRegexes = append(extensionRegexes, regex)
} else {
log.Printf("警告: 无效的文件扩展名正则表达式 '%s': %v", ext, err)
}
}
}
for _, obj := range objects {
if obj.Key == nil {
continue
}
key := aws.ToString(obj.Key)
// 跳过以/结尾的对象(文件夹)
if strings.HasSuffix(key, "/") {
continue
}
// 如果设置了文件扩展名过滤,检查是否匹配
if len(extensionRegexes) > 0 {
matched := false
for _, regex := range extensionRegexes {
if regex.MatchString(key) {
matched = true
break
}
}
if !matched {
continue
}
}
// 生成URL
fileURL := sf.generateURL(key, s3Config)
if fileURL != "" {
urls = append(urls, fileURL)
}
}
return urls
}
// generateURL 生成文件的访问URL
func (sf *S3Fetcher) generateURL(key string, s3Config *model.S3Config) string {
// 如果设置了自定义域名
if s3Config.CustomDomain != "" {
return sf.generateCustomDomainURL(key, s3Config)
}
// 使用S3标准URL格式
return sf.generateS3URL(key, s3Config)
}
// generateCustomDomainURL 生成自定义域名URL
func (sf *S3Fetcher) generateCustomDomainURL(key string, s3Config *model.S3Config) string {
baseURL := strings.TrimSuffix(s3Config.CustomDomain, "/")
// 处理key路径
path := key
if s3Config.RemoveBucket {
// 如果需要移除bucket名称并且key以bucket名称开头
bucketPrefix := s3Config.BucketName + "/"
if strings.HasPrefix(path, bucketPrefix) {
path = strings.TrimPrefix(path, bucketPrefix)
}
}
// 对路径进行适当的URL编码但保留路径分隔符
path = sf.encodeURLPath(path)
// 确保路径以/开头
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return baseURL + path
}
// generateS3URL 生成S3标准URL
func (sf *S3Fetcher) generateS3URL(key string, s3Config *model.S3Config) string {
// 对key进行适当的URL编码但保留路径分隔符
encodedKey := sf.encodeURLPath(key)
if s3Config.UsePathStyle {
// Path-style URL: http://endpoint/bucket/key
endpoint := strings.TrimSuffix(s3Config.Endpoint, "/")
return fmt.Sprintf("%s/%s/%s", endpoint, s3Config.BucketName, encodedKey)
} else {
// Virtual-hosted-style URL: http://bucket.endpoint/key
endpoint := strings.TrimSuffix(s3Config.Endpoint, "/")
// 解析endpoint以获取主机名
if parsedURL, err := url.Parse(endpoint); err == nil {
scheme := parsedURL.Scheme
if scheme == "" {
scheme = "https"
}
host := parsedURL.Host
if host == "" {
host = parsedURL.Path
}
return fmt.Sprintf("%s://%s.%s/%s", scheme, s3Config.BucketName, host, encodedKey)
}
// 如果解析失败回退到path-style
return fmt.Sprintf("%s/%s/%s", endpoint, s3Config.BucketName, encodedKey)
}
}
// encodeURLPath 对URL路径进行编码保留路径分隔符但编码其他特殊字符
func (sf *S3Fetcher) encodeURLPath(path string) string {
// 分割路径为各个部分
parts := strings.Split(path, "/")
// 对每个部分进行URL编码
for i, part := range parts {
if part != "" {
// 使用PathEscape对每个路径段进行编码
// 这会将空格编码为%20这是URL路径中的标准做法
parts[i] = url.PathEscape(part)
}
}
// 重新组合路径
return strings.Join(parts, "/")
}

View File

@ -11,7 +11,7 @@ import { Trash2, Plus } from 'lucide-react'
import { authenticatedFetch } from '@/lib/auth' import { authenticatedFetch } from '@/lib/auth'
interface DataSourceConfigFormProps { interface DataSourceConfigFormProps {
type: 'lankong' | 'manual' | 'api_get' | 'api_post' | 'endpoint' type: 'lankong' | 'manual' | 'api_get' | 'api_post' | 'endpoint' | 's3'
config: string config: string
onChange: (config: string) => void onChange: (config: string) => void
} }
@ -40,6 +40,21 @@ interface EndpointConfig {
endpoint_ids: number[] endpoint_ids: number[]
} }
interface S3Config {
endpoint: string
bucket_name: string
region: string
access_key_id: string
secret_access_key: string
list_objects_version: string
use_path_style: boolean
remove_bucket: boolean
custom_domain: string
folder_path: string
include_subfolders: boolean
file_extensions: string[]
}
export default function DataSourceConfigForm({ type, config, onChange }: DataSourceConfigFormProps) { export default function DataSourceConfigForm({ type, config, onChange }: DataSourceConfigFormProps) {
const [lankongConfig, setLankongConfig] = useState<LankongConfig>({ const [lankongConfig, setLankongConfig] = useState<LankongConfig>({
api_token: '', api_token: '',
@ -59,9 +74,25 @@ export default function DataSourceConfigForm({ type, config, onChange }: DataSou
endpoint_ids: [] endpoint_ids: []
}) })
const [s3Config, setS3Config] = useState<S3Config>({
endpoint: '',
bucket_name: '',
region: '',
access_key_id: '',
secret_access_key: '',
list_objects_version: 'v2',
use_path_style: false,
remove_bucket: false,
custom_domain: '',
folder_path: '',
include_subfolders: true,
file_extensions: []
})
const [availableEndpoints, setAvailableEndpoints] = useState<Array<{id: number, name: string, url: string}>>([]) const [availableEndpoints, setAvailableEndpoints] = useState<Array<{id: number, name: string, url: string}>>([])
const [headerPairs, setHeaderPairs] = useState<Array<{key: string, value: string}>>([{key: '', value: ''}]) const [headerPairs, setHeaderPairs] = useState<Array<{key: string, value: string}>>([{key: '', value: ''}])
const [extensionInputs, setExtensionInputs] = useState<string[]>([''])
const [savedTokens, setSavedTokens] = useState<SavedToken[]>([]) const [savedTokens, setSavedTokens] = useState<SavedToken[]>([])
const [newTokenName, setNewTokenName] = useState<string>('') const [newTokenName, setNewTokenName] = useState<string>('')
@ -127,6 +158,26 @@ export default function DataSourceConfigForm({ type, config, onChange }: DataSou
setEndpointConfig({ setEndpointConfig({
endpoint_ids: parsed.endpoint_ids || [] endpoint_ids: parsed.endpoint_ids || []
}) })
} else if (type === 's3') {
setS3Config({
endpoint: parsed.endpoint || '',
bucket_name: parsed.bucket_name || '',
region: parsed.region || '',
access_key_id: parsed.access_key_id || '',
secret_access_key: parsed.secret_access_key || '',
list_objects_version: parsed.list_objects_version || 'v2',
use_path_style: parsed.use_path_style || false,
remove_bucket: parsed.remove_bucket || false,
custom_domain: parsed.custom_domain || '',
folder_path: parsed.folder_path || '',
include_subfolders: parsed.include_subfolders !== false,
file_extensions: parsed.file_extensions || []
})
// 设置文件扩展名输入框
const extensions = parsed.file_extensions || ['']
if (extensions.length === 0) extensions.push('')
setExtensionInputs(extensions)
} }
} catch (error) { } catch (error) {
console.error('Failed to parse config:', error) console.error('Failed to parse config:', error)
@ -257,6 +308,33 @@ export default function DataSourceConfigForm({ type, config, onChange }: DataSou
updateEndpointConfig(newIds) updateEndpointConfig(newIds)
} }
// 更新S3配置
const updateS3Config = (field: keyof S3Config, value: string | boolean | string[]) => {
const newConfig = { ...s3Config, [field]: value }
setS3Config(newConfig)
onChange(JSON.stringify(newConfig))
}
// 添加文件扩展名
const addExtension = () => {
const newExtensions = [...extensionInputs, '']
setExtensionInputs(newExtensions)
}
// 删除文件扩展名
const removeExtension = (index: number) => {
const newExtensions = extensionInputs.filter((_, i) => i !== index)
setExtensionInputs(newExtensions)
updateS3Config('file_extensions', newExtensions.filter(ext => ext.trim() !== ''))
}
// 更新文件扩展名
const updateExtension = (index: number, value: string) => {
const newExtensions = extensionInputs.map((ext, i) => i === index ? value : ext)
setExtensionInputs(newExtensions)
updateS3Config('file_extensions', newExtensions.filter(ext => ext.trim() !== ''))
}
if (type === 'manual') { if (type === 'manual') {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
@ -560,5 +638,193 @@ export default function DataSourceConfigForm({ type, config, onChange }: DataSou
) )
} }
if (type === 's3') {
return (
<div className="space-y-4">
{/* 基础配置 */}
<Card>
<CardHeader>
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="s3-endpoint">S3端点地址</Label>
<Input
id="s3-endpoint"
value={s3Config.endpoint}
onChange={(e) => updateS3Config('endpoint', e.target.value)}
placeholder="https://s3.amazonaws.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="s3-bucket"></Label>
<Input
id="s3-bucket"
value={s3Config.bucket_name}
onChange={(e) => updateS3Config('bucket_name', e.target.value)}
placeholder="my-bucket"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="s3-region"></Label>
<Input
id="s3-region"
value={s3Config.region}
onChange={(e) => updateS3Config('region', e.target.value)}
placeholder="us-east-1"
/>
</div>
<div className="space-y-2">
<Label htmlFor="s3-version"></Label>
<select
id="s3-version"
value={s3Config.list_objects_version}
onChange={(e) => updateS3Config('list_objects_version', e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="v2">v2</option>
<option value="v1">v1</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="s3-access-key">访ID</Label>
<Input
id="s3-access-key"
value={s3Config.access_key_id}
onChange={(e) => updateS3Config('access_key_id', e.target.value)}
placeholder="AKIAIOSFODNN7EXAMPLE"
/>
</div>
<div className="space-y-2">
<Label htmlFor="s3-secret-key">访</Label>
<Input
id="s3-secret-key"
type="password"
value={s3Config.secret_access_key}
onChange={(e) => updateS3Config('secret_access_key', e.target.value)}
placeholder="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
/>
</div>
</div>
</CardContent>
</Card>
{/* 高级配置 */}
<Card>
<CardHeader>
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="s3-path-style"
checked={s3Config.use_path_style}
onCheckedChange={(checked) => updateS3Config('use_path_style', checked as boolean)}
/>
<Label htmlFor="s3-path-style">使Path Style URL</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="s3-remove-bucket"
checked={s3Config.remove_bucket}
onCheckedChange={(checked) => updateS3Config('remove_bucket', checked as boolean)}
/>
<Label htmlFor="s3-remove-bucket">bucket名称</Label>
</div>
<div className="space-y-2">
<Label htmlFor="s3-custom-domain">访</Label>
<Input
id="s3-custom-domain"
value={s3Config.custom_domain}
onChange={(e) => updateS3Config('custom_domain', e.target.value)}
placeholder="https://cdn.example.com"
/>
<p className="text-xs text-muted-foreground">
使S3标准URL支持路径如: https://cdn.example.com/path
</p>
</div>
</CardContent>
</Card>
{/* 文件过滤配置 */}
<Card>
<CardHeader>
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<Label htmlFor="s3-folder-path"></Label>
<Input
id="s3-folder-path"
value={s3Config.folder_path}
onChange={(e) => updateS3Config('folder_path', e.target.value)}
placeholder="/images"
/>
<p className="text-xs text-muted-foreground">
: /images /uploads/photos
</p>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="s3-include-subfolders"
checked={s3Config.include_subfolders}
onCheckedChange={(checked) => updateS3Config('include_subfolders', checked as boolean)}
/>
<Label htmlFor="s3-include-subfolders"></Label>
</div>
<div className="space-y-2">
<Label></Label>
{extensionInputs.map((ext, index) => (
<div key={index} className="flex gap-2">
<Input
value={ext}
onChange={(e) => updateExtension(index, e.target.value)}
placeholder=".jpg 或 .png"
className="flex-1"
/>
{extensionInputs.length > 1 && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => removeExtension(index)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button
type="button"
size="sm"
variant="outline"
onClick={addExtension}
className="w-full"
>
<Plus className="h-4 w-4 mr-1" />
</Button>
<p className="text-xs text-muted-foreground">
支持正则匹配如: .jpg, .png, .gif
</p>
</div>
</CardContent>
</Card>
</div>
)
}
return null return null
} }

View File

@ -26,7 +26,7 @@ export default function DataSourceManagement({
const [editingDataSource, setEditingDataSource] = useState<DataSource | null>(null) const [editingDataSource, setEditingDataSource] = useState<DataSource | null>(null)
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
type: 'manual' as 'lankong' | 'manual' | 'api_get' | 'api_post' | 'endpoint', type: 'manual' as 'lankong' | 'manual' | 'api_get' | 'api_post' | 'endpoint' | 's3',
config: '', config: '',
is_active: true is_active: true
}) })
@ -185,6 +185,7 @@ export default function DataSourceManagement({
case 'api_get': return 'GET接口' case 'api_get': return 'GET接口'
case 'api_post': return 'POST接口' case 'api_post': return 'POST接口'
case 'endpoint': return '已有端点' case 'endpoint': return '已有端点'
case 's3': return 'S3对象存储'
default: return type default: return type
} }
} }
@ -245,7 +246,7 @@ export default function DataSourceManagement({
<select <select
id="ds-type" id="ds-type"
value={formData.type} value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as 'lankong' | 'manual' | 'api_get' | 'api_post' | 'endpoint' })} onChange={(e) => setFormData({ ...formData, type: e.target.value as 'lankong' | 'manual' | 'api_get' | 'api_post' | 'endpoint' | 's3' })}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
> >
<option value="manual"></option> <option value="manual"></option>
@ -253,6 +254,7 @@ export default function DataSourceManagement({
<option value="api_get">GET接口</option> <option value="api_get">GET接口</option>
<option value="api_post">POST接口</option> <option value="api_post">POST接口</option>
<option value="endpoint"></option> <option value="endpoint"></option>
<option value="s3">S3对象存储</option>
</select> </select>
</div> </div>
</div> </div>

View File

@ -18,6 +18,14 @@ const nextConfig: NextConfig = {
{ {
source: "/api/:path*", source: "/api/:path*",
destination: "http://localhost:5003/api/:path*", destination: "http://localhost:5003/api/:path*",
},
{
source: "/pic/:path*",
destination: "http://localhost:5003/pic/:path*",
},
{
source: "/video/:path*",
destination: "http://localhost:5003/video/:path*",
} }
]; ];
} }

View File

@ -21,7 +21,7 @@ export interface DataSource {
id: number id: number
endpoint_id: number endpoint_id: number
name: string name: string
type: 'lankong' | 'manual' | 'api_get' | 'api_post' | 'endpoint' type: 'lankong' | 'manual' | 'api_get' | 'api_post' | 'endpoint' | 's3'
config: string config: string
is_active: boolean is_active: boolean
last_sync?: string last_sync?: string