refactor: commit all pending file_system changes

- Restructure handlers into file_commands/file_queries/file_handlers
- Add gRPC auth client, JWT middleware, rate limiting, request ID
- Add common utilities: logger, sanitizer, s3_errors
- Add unit tests for config, mediator, auth, request_id, sanitize
- Add proto definitions and generated code
- Remove old web UI pages
- Add .dockerignore and .env.example
This commit is contained in:
向宁 2026-05-17 22:20:02 +08:00
parent 5e20a6d7fc
commit b5df6445e5
52 changed files with 1929 additions and 1420 deletions

View File

@ -1 +0,0 @@
{"insecure-registries":["192.168.1.154:31010"]}

10
.dockerignore Normal file
View File

@ -0,0 +1,10 @@
.git
.gitignore
.idea
.vscode
docs
*.md
Jenkinsfile
.gitlab-ci.yml
.dockerignore
%USERPROFILE%.dockerdaemon.json

9
.env.example Normal file
View File

@ -0,0 +1,9 @@
RUSTFS_ENDPOINT_URL=http://your-rustfs-endpoint:9000
RUSTFS_ACCESS_KEY_ID=your-access-key
RUSTFS_SECRET_ACCESS_KEY=your-secret-key
RUSTFS_REGION=us-east-1
SERVER_PORT=8080
# 认证方式二选一:设置 GRPC_AUTH_ADDR 走 JWT/gRPC否则走 API Key
AUTH_API_KEY=your-api-key
GRPC_AUTH_ADDR=localhost:50051

356
api/proto/auth.pb.go Normal file
View File

@ -0,0 +1,356 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc (unknown)
// source: auth.proto
package proto
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type ValidateTokenRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ValidateTokenRequest) Reset() {
*x = ValidateTokenRequest{}
mi := &file_auth_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ValidateTokenRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ValidateTokenRequest) ProtoMessage() {}
func (x *ValidateTokenRequest) ProtoReflect() protoreflect.Message {
mi := &file_auth_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ValidateTokenRequest.ProtoReflect.Descriptor instead.
func (*ValidateTokenRequest) Descriptor() ([]byte, []int) {
return file_auth_proto_rawDescGZIP(), []int{0}
}
func (x *ValidateTokenRequest) GetToken() string {
if x != nil {
return x.Token
}
return ""
}
type ValidateTokenResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Valid bool `protobuf:"varint,1,opt,name=valid,proto3" json:"valid,omitempty"`
UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"`
Email string `protobuf:"bytes,4,opt,name=email,proto3" json:"email,omitempty"`
Roles []string `protobuf:"bytes,5,rep,name=roles,proto3" json:"roles,omitempty"`
Permissions []string `protobuf:"bytes,6,rep,name=permissions,proto3" json:"permissions,omitempty"`
ExpiresAt int64 `protobuf:"varint,7,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ValidateTokenResponse) Reset() {
*x = ValidateTokenResponse{}
mi := &file_auth_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ValidateTokenResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ValidateTokenResponse) ProtoMessage() {}
func (x *ValidateTokenResponse) ProtoReflect() protoreflect.Message {
mi := &file_auth_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ValidateTokenResponse.ProtoReflect.Descriptor instead.
func (*ValidateTokenResponse) Descriptor() ([]byte, []int) {
return file_auth_proto_rawDescGZIP(), []int{1}
}
func (x *ValidateTokenResponse) GetValid() bool {
if x != nil {
return x.Valid
}
return false
}
func (x *ValidateTokenResponse) GetUserId() string {
if x != nil {
return x.UserId
}
return ""
}
func (x *ValidateTokenResponse) GetUsername() string {
if x != nil {
return x.Username
}
return ""
}
func (x *ValidateTokenResponse) GetEmail() string {
if x != nil {
return x.Email
}
return ""
}
func (x *ValidateTokenResponse) GetRoles() []string {
if x != nil {
return x.Roles
}
return nil
}
func (x *ValidateTokenResponse) GetPermissions() []string {
if x != nil {
return x.Permissions
}
return nil
}
func (x *ValidateTokenResponse) GetExpiresAt() int64 {
if x != nil {
return x.ExpiresAt
}
return 0
}
type CheckPermissionRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"`
Permission string `protobuf:"bytes,2,opt,name=permission,proto3" json:"permission,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CheckPermissionRequest) Reset() {
*x = CheckPermissionRequest{}
mi := &file_auth_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CheckPermissionRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CheckPermissionRequest) ProtoMessage() {}
func (x *CheckPermissionRequest) ProtoReflect() protoreflect.Message {
mi := &file_auth_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CheckPermissionRequest.ProtoReflect.Descriptor instead.
func (*CheckPermissionRequest) Descriptor() ([]byte, []int) {
return file_auth_proto_rawDescGZIP(), []int{2}
}
func (x *CheckPermissionRequest) GetToken() string {
if x != nil {
return x.Token
}
return ""
}
func (x *CheckPermissionRequest) GetPermission() string {
if x != nil {
return x.Permission
}
return ""
}
type CheckPermissionResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Allowed bool `protobuf:"varint,1,opt,name=allowed,proto3" json:"allowed,omitempty"`
UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
Roles []string `protobuf:"bytes,3,rep,name=roles,proto3" json:"roles,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CheckPermissionResponse) Reset() {
*x = CheckPermissionResponse{}
mi := &file_auth_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CheckPermissionResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CheckPermissionResponse) ProtoMessage() {}
func (x *CheckPermissionResponse) ProtoReflect() protoreflect.Message {
mi := &file_auth_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CheckPermissionResponse.ProtoReflect.Descriptor instead.
func (*CheckPermissionResponse) Descriptor() ([]byte, []int) {
return file_auth_proto_rawDescGZIP(), []int{3}
}
func (x *CheckPermissionResponse) GetAllowed() bool {
if x != nil {
return x.Allowed
}
return false
}
func (x *CheckPermissionResponse) GetUserId() string {
if x != nil {
return x.UserId
}
return ""
}
func (x *CheckPermissionResponse) GetRoles() []string {
if x != nil {
return x.Roles
}
return nil
}
var File_auth_proto protoreflect.FileDescriptor
const file_auth_proto_rawDesc = "" +
"\n" +
"\n" +
"auth.proto\x12\x04auth\",\n" +
"\x14ValidateTokenRequest\x12\x14\n" +
"\x05token\x18\x01 \x01(\tR\x05token\"\xcf\x01\n" +
"\x15ValidateTokenResponse\x12\x14\n" +
"\x05valid\x18\x01 \x01(\bR\x05valid\x12\x17\n" +
"\auser_id\x18\x02 \x01(\tR\x06userId\x12\x1a\n" +
"\busername\x18\x03 \x01(\tR\busername\x12\x14\n" +
"\x05email\x18\x04 \x01(\tR\x05email\x12\x14\n" +
"\x05roles\x18\x05 \x03(\tR\x05roles\x12 \n" +
"\vpermissions\x18\x06 \x03(\tR\vpermissions\x12\x1d\n" +
"\n" +
"expires_at\x18\a \x01(\x03R\texpiresAt\"N\n" +
"\x16CheckPermissionRequest\x12\x14\n" +
"\x05token\x18\x01 \x01(\tR\x05token\x12\x1e\n" +
"\n" +
"permission\x18\x02 \x01(\tR\n" +
"permission\"b\n" +
"\x17CheckPermissionResponse\x12\x18\n" +
"\aallowed\x18\x01 \x01(\bR\aallowed\x12\x17\n" +
"\auser_id\x18\x02 \x01(\tR\x06userId\x12\x14\n" +
"\x05roles\x18\x03 \x03(\tR\x05roles2\xa7\x01\n" +
"\vAuthService\x12H\n" +
"\rValidateToken\x12\x1a.auth.ValidateTokenRequest\x1a\x1b.auth.ValidateTokenResponse\x12N\n" +
"\x0fCheckPermission\x12\x1c.auth.CheckPermissionRequest\x1a\x1d.auth.CheckPermissionResponseB`\n" +
"\bcom.authB\tAuthProtoP\x01Z\x19rag/file-system/api/proto\xa2\x02\x03AXX\xaa\x02\x04Auth\xca\x02\x04Auth\xe2\x02\x10Auth\\GPBMetadata\xea\x02\x04Authb\x06proto3"
var (
file_auth_proto_rawDescOnce sync.Once
file_auth_proto_rawDescData []byte
)
func file_auth_proto_rawDescGZIP() []byte {
file_auth_proto_rawDescOnce.Do(func() {
file_auth_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_auth_proto_rawDesc), len(file_auth_proto_rawDesc)))
})
return file_auth_proto_rawDescData
}
var file_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
var file_auth_proto_goTypes = []any{
(*ValidateTokenRequest)(nil), // 0: auth.ValidateTokenRequest
(*ValidateTokenResponse)(nil), // 1: auth.ValidateTokenResponse
(*CheckPermissionRequest)(nil), // 2: auth.CheckPermissionRequest
(*CheckPermissionResponse)(nil), // 3: auth.CheckPermissionResponse
}
var file_auth_proto_depIdxs = []int32{
0, // 0: auth.AuthService.ValidateToken:input_type -> auth.ValidateTokenRequest
2, // 1: auth.AuthService.CheckPermission:input_type -> auth.CheckPermissionRequest
1, // 2: auth.AuthService.ValidateToken:output_type -> auth.ValidateTokenResponse
3, // 3: auth.AuthService.CheckPermission:output_type -> auth.CheckPermissionResponse
2, // [2:4] is the sub-list for method output_type
0, // [0:2] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_auth_proto_init() }
func file_auth_proto_init() {
if File_auth_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_auth_proto_rawDesc), len(file_auth_proto_rawDesc)),
NumEnums: 0,
NumMessages: 4,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_auth_proto_goTypes,
DependencyIndexes: file_auth_proto_depIdxs,
MessageInfos: file_auth_proto_msgTypes,
}.Build()
File_auth_proto = out.File
file_auth_proto_goTypes = nil
file_auth_proto_depIdxs = nil
}

35
api/proto/auth.proto Normal file
View File

@ -0,0 +1,35 @@
syntax = "proto3";
package auth;
option go_package = "rag/file-system/api/proto";
service AuthService {
rpc ValidateToken (ValidateTokenRequest) returns (ValidateTokenResponse);
rpc CheckPermission (CheckPermissionRequest) returns (CheckPermissionResponse);
}
message ValidateTokenRequest {
string token = 1;
}
message ValidateTokenResponse {
bool valid = 1;
string user_id = 2;
string username = 3;
string email = 4;
repeated string roles = 5;
repeated string permissions = 6;
int64 expires_at = 7;
}
message CheckPermissionRequest {
string token = 1;
string permission = 2;
}
message CheckPermissionResponse {
bool allowed = 1;
string user_id = 2;
repeated string roles = 3;
}

159
api/proto/auth_grpc.pb.go Normal file
View File

@ -0,0 +1,159 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc (unknown)
// source: auth.proto
package proto
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
AuthService_ValidateToken_FullMethodName = "/auth.AuthService/ValidateToken"
AuthService_CheckPermission_FullMethodName = "/auth.AuthService/CheckPermission"
)
// AuthServiceClient is the client API for AuthService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type AuthServiceClient interface {
ValidateToken(ctx context.Context, in *ValidateTokenRequest, opts ...grpc.CallOption) (*ValidateTokenResponse, error)
CheckPermission(ctx context.Context, in *CheckPermissionRequest, opts ...grpc.CallOption) (*CheckPermissionResponse, error)
}
type authServiceClient struct {
cc grpc.ClientConnInterface
}
func NewAuthServiceClient(cc grpc.ClientConnInterface) AuthServiceClient {
return &authServiceClient{cc}
}
func (c *authServiceClient) ValidateToken(ctx context.Context, in *ValidateTokenRequest, opts ...grpc.CallOption) (*ValidateTokenResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ValidateTokenResponse)
err := c.cc.Invoke(ctx, AuthService_ValidateToken_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *authServiceClient) CheckPermission(ctx context.Context, in *CheckPermissionRequest, opts ...grpc.CallOption) (*CheckPermissionResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CheckPermissionResponse)
err := c.cc.Invoke(ctx, AuthService_CheckPermission_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AuthServiceServer is the server API for AuthService service.
// All implementations must embed UnimplementedAuthServiceServer
// for forward compatibility.
type AuthServiceServer interface {
ValidateToken(context.Context, *ValidateTokenRequest) (*ValidateTokenResponse, error)
CheckPermission(context.Context, *CheckPermissionRequest) (*CheckPermissionResponse, error)
mustEmbedUnimplementedAuthServiceServer()
}
// UnimplementedAuthServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedAuthServiceServer struct{}
func (UnimplementedAuthServiceServer) ValidateToken(context.Context, *ValidateTokenRequest) (*ValidateTokenResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ValidateToken not implemented")
}
func (UnimplementedAuthServiceServer) CheckPermission(context.Context, *CheckPermissionRequest) (*CheckPermissionResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CheckPermission not implemented")
}
func (UnimplementedAuthServiceServer) mustEmbedUnimplementedAuthServiceServer() {}
func (UnimplementedAuthServiceServer) testEmbeddedByValue() {}
// UnsafeAuthServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to AuthServiceServer will
// result in compilation errors.
type UnsafeAuthServiceServer interface {
mustEmbedUnimplementedAuthServiceServer()
}
func RegisterAuthServiceServer(s grpc.ServiceRegistrar, srv AuthServiceServer) {
// If the following call panics, it indicates UnimplementedAuthServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&AuthService_ServiceDesc, srv)
}
func _AuthService_ValidateToken_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ValidateTokenRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AuthServiceServer).ValidateToken(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AuthService_ValidateToken_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AuthServiceServer).ValidateToken(ctx, req.(*ValidateTokenRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AuthService_CheckPermission_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CheckPermissionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AuthServiceServer).CheckPermission(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AuthService_CheckPermission_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AuthServiceServer).CheckPermission(ctx, req.(*CheckPermissionRequest))
}
return interceptor(ctx, in, info, handler)
}
// AuthService_ServiceDesc is the grpc.ServiceDesc for AuthService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var AuthService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "auth.AuthService",
HandlerType: (*AuthServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ValidateToken",
Handler: _AuthService_ValidateToken_Handler,
},
{
MethodName: "CheckPermission",
Handler: _AuthService_CheckPermission_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "auth.proto",
}

13
buf.gen.yaml Normal file
View File

@ -0,0 +1,13 @@
version: v2
managed:
enabled: true
override:
- file_option: go_package_prefix
value: rag/file-system/api/proto
plugins:
- remote: buf.build/protocolbuffers/go
out: api/proto
opt: paths=source_relative
- remote: buf.build/grpc/go
out: api/proto
opt: paths=source_relative

3
buf.yaml Normal file
View File

@ -0,0 +1,3 @@
version: v2
modules:
- path: api/proto

View File

@ -0,0 +1,48 @@
package endpoints
import (
"rag/file-system/internal/api/handlers"
"rag/file-system/internal/infrastructure/mediator"
"net/http"
"github.com/gin-gonic/gin"
)
// AuthEndpoint handles authentication endpoints
type AuthEndpoint struct {
mediator *mediator.Mediator
}
// NewAuthEndpoint creates a new auth endpoint
func NewAuthEndpoint(m *mediator.Mediator) *AuthEndpoint {
return &AuthEndpoint{
mediator: m,
}
}
// Login authenticates a user with API key
// @Summary User login
// @Description Authenticate with API key
// @Tags Authentication
// @Accept json
// @Produce json
// @Param request body handlers.LoginQuery true "Login credentials"
// @Success 200 {object} handlers.LoginResult
// @Failure 400 {object} map[string]string "Invalid request"
// @Failure 500 {object} map[string]string "Internal error"
// @Router /auth/login [post]
func (e *AuthEndpoint) Login(c *gin.Context) {
var query handlers.LoginQuery
if err := c.ShouldBindJSON(&query); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request parameters"})
return
}
result, err := mediator.Send[handlers.LoginQuery, handlers.LoginResult](e.mediator, c.Request.Context(), query)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "authentication failed"})
return
}
c.JSON(http.StatusOK, result)
}

View File

@ -1,45 +0,0 @@
package endpoints
import (
"file-system/internal/api/handlers"
"file-system/internal/infrastructure/mediator"
"github.com/gin-gonic/gin"
)
// AuthEndpoint 认证端点
type AuthEndpoint struct {
mediator *mediator.Mediator
}
// NewAuthEndpoint 创建认证端点
func NewAuthEndpoint(m *mediator.Mediator) *AuthEndpoint {
return &AuthEndpoint{
mediator: m,
}
}
// Login 用户登录
// @Summary 用户登录
// @Description 使用 API 密钥登录
// @Tags 认证
// @Accept json
// @Produce json
// @Param request body handlers.LoginQuery true "登录信息"
// @Success 200 {object} handlers.LoginResult
// @Router /auth/login [post]
func (e *AuthEndpoint) Login(c *gin.Context) {
var query handlers.LoginQuery
if err := c.ShouldBindJSON(&query); err != nil {
c.JSON(400, gin.H{"error": "请求参数错误"})
return
}
result, err := mediator.Send[handlers.LoginQuery, handlers.LoginResult](e.mediator, c.Request.Context(), query)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, result)
}

View File

@ -1,11 +1,10 @@
package endpoints
import (
"file-system/internal/api/handlers"
"file-system/internal/api/requests"
"file-system/internal/api/validators"
"file-system/internal/common"
"file-system/internal/infrastructure/mediator"
"rag/file-system/internal/api/handlers"
"rag/file-system/internal/api/requests"
"rag/file-system/internal/api/validators"
"rag/file-system/internal/infrastructure/mediator"
"net/http"
"github.com/gin-gonic/gin"
@ -24,15 +23,16 @@ func NewBucketEndpoint(m *mediator.Mediator, cbv *validators.CreateBucketValidat
}
// CreateBucket godoc
// @Summary 创建存储桶
// @Description 创建一个新的 S3 存储桶
// @Tags 存储桶管理
// @Summary Create bucket
// @Description Create a new S3 bucket
// @Tags Bucket Management
// @Accept json
// @Produce json
// @Param request body requests.CreateBucketRequest true "创建存储桶请求参数"
// @Success 200 {object} map[string]string "创建成功消息"
// @Failure 400 {object} map[string]string "参数错误"
// @Failure 500 {object} map[string]string "服务器内部错误"
// @Security ApiKeyAuth
// @Param request body requests.CreateBucketRequest true "Bucket creation parameters"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /buckets [post]
func (e *BucketEndpoint) CreateBucket(c *gin.Context) {
var req requests.CreateBucketRequest
@ -50,11 +50,7 @@ func (e *BucketEndpoint) CreateBucket(c *gin.Context) {
result, err := mediator.Send[handlers.CreateBucketCommand, string](e.Mediator, c.Request.Context(), cmd)
if err != nil {
if be, ok := err.(*common.BusinessException); ok {
c.JSON(be.Code, gin.H{"error": be.Message})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
handleError(c, err)
return
}
@ -62,34 +58,36 @@ func (e *BucketEndpoint) CreateBucket(c *gin.Context) {
}
// ListBuckets godoc
// @Summary 获取存储桶列表
// @Description 列出所有可用的 S3 存储桶
// @Tags 存储桶管理
// @Summary List buckets
// @Description List all available S3 buckets
// @Tags Bucket Management
// @Accept json
// @Produce json
// @Success 200 {object} map[string][]string "存储桶列表"
// @Failure 500 {object} map[string]string "服务器内部错误"
// @Security ApiKeyAuth
// @Success 200 {object} map[string][]string
// @Failure 500 {object} map[string]string
// @Router /buckets [get]
func (e *BucketEndpoint) ListBuckets(c *gin.Context) {
query := handlers.ListBucketsQuery{}
result, err := mediator.Send[handlers.ListBucketsQuery, []string](e.Mediator, c.Request.Context(), query)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"buckets": result})
}
// DeleteBucket godoc
// @Summary 删除存储桶
// @Description 删除指定的 S3 存储桶(桶必须为空)
// @Tags 存储桶管理
// @Summary Delete bucket
// @Description Delete an S3 bucket (must be empty)
// @Tags Bucket Management
// @Accept json
// @Produce json
// @Param request body requests.DeleteBucketRequest true "删除存储桶请求参数"
// @Success 200 {object} map[string]string "删除成功消息"
// @Failure 400 {object} map[string]string "参数错误"
// @Failure 500 {object} map[string]string "服务器内部错误"
// @Security ApiKeyAuth
// @Param request body requests.DeleteBucketRequest true "Bucket deletion parameters"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /buckets [delete]
func (e *BucketEndpoint) DeleteBucket(c *gin.Context) {
var req requests.DeleteBucketRequest
@ -107,11 +105,7 @@ func (e *BucketEndpoint) DeleteBucket(c *gin.Context) {
result, err := mediator.Send[handlers.DeleteBucketCommand, string](e.Mediator, c.Request.Context(), cmd)
if err != nil {
if be, ok := err.(*common.BusinessException); ok {
c.JSON(be.Code, gin.H{"error": be.Message})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
handleError(c, err)
return
}

View File

@ -0,0 +1,21 @@
package endpoints
import (
"rag/file-system/internal/common"
"net/http"
"github.com/gin-gonic/gin"
)
func handleError(c *gin.Context, err error) {
if be, ok := err.(*common.BusinessException); ok {
c.JSON(be.Code, gin.H{"error": be.Message})
} else {
common.Logger.Error("unhandled error",
"error", err,
"path", c.Request.URL.Path,
"request_id", c.GetString("request_id"),
)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
}
}

View File

@ -1,12 +1,13 @@
package endpoints
import (
"file-system/internal/api/handlers"
"file-system/internal/api/requests"
"file-system/internal/api/validators"
"file-system/internal/common"
"file-system/internal/domain/repository"
"file-system/internal/infrastructure/mediator"
"rag/file-system/internal/api/handlers"
"rag/file-system/internal/api/requests"
"rag/file-system/internal/api/validators"
"rag/file-system/internal/common"
"rag/file-system/internal/domain/repository"
"rag/file-system/internal/infrastructure/mediator"
"fmt"
"io"
"net/http"
"time"
@ -15,50 +16,43 @@ import (
)
type FileEndpoint struct {
Mediator *mediator.Mediator
UploadValidator *validators.UploadFileValidator
DownloadValidator *validators.DownloadFileValidator
NewFeaturesValidator *validators.NewFeaturesValidator
Mediator *mediator.Mediator
FileValidator *validators.FileValidator
}
func NewFileEndpoint(m *mediator.Mediator, uv *validators.UploadFileValidator, dv *validators.DownloadFileValidator, nfv *validators.NewFeaturesValidator) *FileEndpoint {
func NewFileEndpoint(m *mediator.Mediator, fv *validators.FileValidator) *FileEndpoint {
return &FileEndpoint{
Mediator: m,
UploadValidator: uv,
DownloadValidator: dv,
NewFeaturesValidator: nfv,
Mediator: m,
FileValidator: fv,
}
}
// UploadFile godoc
// @Summary 上传文件 (简单上传)
// @Description 上传小文件到指定的存储桶,支持 multipart/form-data 格式
// @Tags 文件操作
// @Summary Upload file
// @Description Upload a small file to the specified bucket
// @Tags Files
// @Accept multipart/form-data
// @Produce json
// @Security ApiKeyAuth
// @Param bucket_name formData string true "存储桶名称"
// @Param file formData file true "要上传的文件"
// @Success 200 {object} map[string]string "上传成功消息"
// @Failure 400 {object} map[string]string "参数错误"
// @Failure 401 {object} map[string]string "未授权API 密钥无效或缺失"
// @Failure 500 {object} map[string]string "服务器内部错误"
// @Param bucket_name formData string true "Bucket name"
// @Param file formData file true "File to upload"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /files/upload [post]
func (e *FileEndpoint) UploadFile(c *gin.Context) {
var req requests.UploadFileRequest
// 绑定参数
if err := c.ShouldBind(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 验证参数
if err := e.UploadValidator.Validate(&req); err != nil {
if err := e.FileValidator.ValidateUpload(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 打开文件流
file, err := req.File.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open file"})
@ -66,14 +60,12 @@ func (e *FileEndpoint) UploadFile(c *gin.Context) {
}
defer file.Close()
// 构建 Command
cmd := handlers.UploadFileCommand{
BucketName: req.BucketName,
FileName: req.File.Filename,
Data: file,
}
// 调用 Mediator
result, err := mediator.Send[handlers.UploadFileCommand, string](e.Mediator, c.Request.Context(), cmd)
if err != nil {
handleError(c, err)
@ -84,18 +76,18 @@ func (e *FileEndpoint) UploadFile(c *gin.Context) {
}
// DownloadFile godoc
// @Summary 下载文件
// @Description 从指定的存储桶下载文件,返回文件流
// @Tags 文件操作
// @Summary Download file
// @Description Download a file from the specified bucket
// @Tags Files
// @Accept json
// @Produce octet-stream
// @Security ApiKeyAuth
// @Param bucket_name query string true "存储桶名称"
// @Param object_key query string true "对象键(文件名)"
// @Success 200 {file} file "文件流"
// @Failure 400 {object} map[string]string "参数错误"
// @Failure 401 {object} map[string]string "未授权API 密钥无效或缺失"
// @Failure 500 {object} map[string]string "服务器内部错误"
// @Param bucket_name query string true "Bucket name"
// @Param object_key query string true "Object key"
// @Success 200 {file} file
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /files/download [get]
func (e *FileEndpoint) DownloadFile(c *gin.Context) {
var req requests.DownloadFileRequest
@ -104,7 +96,7 @@ func (e *FileEndpoint) DownloadFile(c *gin.Context) {
return
}
if err := e.DownloadValidator.Validate(&req); err != nil {
if err := e.FileValidator.ValidateDownload(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@ -121,26 +113,26 @@ func (e *FileEndpoint) DownloadFile(c *gin.Context) {
}
defer result.Close()
c.Header("Content-Disposition", "attachment; filename="+req.ObjectKey)
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, common.SanitizeFilename(req.ObjectKey)))
c.Header("Content-Type", "application/octet-stream")
io.Copy(c.Writer, result)
}
// ListFiles godoc
// @Summary 文件列表 (分页)
// @Description 分页查询存储桶中的文件,支持前缀筛选和分页
// @Tags 文件操作
// @Summary List files (paginated)
// @Description List files in a bucket with pagination and prefix filtering
// @Tags Files
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param bucket_name query string true "存储桶名称"
// @Param prefix query string false "文件名前缀筛选"
// @Param max_keys query int false "每页数量默认20"
// @Param token query string false "分页Token下一页的凭证"
// @Param bucket_name query string true "Bucket name"
// @Param prefix query string false "File name prefix filter"
// @Param max_keys query int false "Items per page (default 20)"
// @Param token query string false "Pagination token"
// @Success 200 {object} repository.ListFilesResult
// @Failure 400 {object} map[string]string "参数错误"
// @Failure 401 {object} map[string]string "未授权API 密钥无效或缺失"
// @Failure 500 {object} map[string]string "服务器内部错误"
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /files/list [get]
func (e *FileEndpoint) ListFiles(c *gin.Context) {
var req requests.ListFilesRequest
@ -149,7 +141,7 @@ func (e *FileEndpoint) ListFiles(c *gin.Context) {
return
}
if err := e.NewFeaturesValidator.ValidateListFiles(&req); err != nil {
if err := e.FileValidator.ValidateListFiles(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@ -175,18 +167,18 @@ func (e *FileEndpoint) ListFiles(c *gin.Context) {
}
// GetPreviewURL godoc
// @Summary 获取预览链接
// @Description 生成文件的临时预览链接24小时有效支持图片/视频/文档等
// @Tags 文件操作
// @Summary Get preview URL
// @Description Generate a temporary presigned URL for file preview (24h expiry)
// @Tags Files
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param bucket_name query string true "存储桶名称"
// @Param object_key query string true "对象键(文件名)"
// @Success 200 {object} map[string]string "返回预览 URL"
// @Failure 400 {object} map[string]string "参数错误"
// @Failure 401 {object} map[string]string "未授权API 密钥无效或缺失"
// @Failure 500 {object} map[string]string "服务器内部错误"
// @Param bucket_name query string true "Bucket name"
// @Param object_key query string true "Object key"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /files/preview [get]
func (e *FileEndpoint) GetPreviewURL(c *gin.Context) {
var req requests.GetFilePreviewRequest
@ -195,7 +187,7 @@ func (e *FileEndpoint) GetPreviewURL(c *gin.Context) {
return
}
if err := e.NewFeaturesValidator.ValidatePreview(&req); err != nil {
if err := e.FileValidator.ValidatePreview(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@ -215,27 +207,27 @@ func (e *FileEndpoint) GetPreviewURL(c *gin.Context) {
}
// GetFileContent godoc
// @Summary 获取文件文本内容
// @Description 读取文件的文本内容,用于 Markdown 等文本文件的在线预览
// @Tags 文件操作
// @Summary Get file text content
// @Description Retrieve text content of a file for preview (e.g., Markdown)
// @Tags Files
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param bucket_name query string true "存储桶名称"
// @Param object_key query string true "对象键(文件名)"
// @Success 200 {object} map[string]string "返回文件文本内容"
// @Failure 400 {object} map[string]string "参数错误"
// @Failure 401 {object} map[string]string "未授权API 密钥无效或缺失"
// @Failure 500 {object} map[string]string "服务器内部错误"
// @Param bucket_name query string true "Bucket name"
// @Param object_key query string true "Object key"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /files/content [get]
func (e *FileEndpoint) GetFileContent(c *gin.Context) {
var req requests.GetFilePreviewRequest
var req requests.GetFileContentRequest
if err := c.ShouldBind(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := e.NewFeaturesValidator.ValidatePreview(&req); err != nil {
if err := e.FileValidator.ValidateGetContent(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@ -254,17 +246,17 @@ func (e *FileEndpoint) GetFileContent(c *gin.Context) {
}
// InitMultipart godoc
// @Summary 初始化分片上传
// @Description 开始一个新的大文件分片上传任务,返回 upload_id 用于后续分片上传
// @Tags 大文件上传
// @Summary Initialize multipart upload
// @Description Start a new multipart upload session and return upload_id
// @Tags Multipart Upload
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param request body requests.InitMultipartRequest true "请求参数"
// @Success 200 {object} map[string]string "返回 upload_id"
// @Failure 400 {object} map[string]string "参数错误"
// @Failure 401 {object} map[string]string "未授权API 密钥无效或缺失"
// @Failure 500 {object} map[string]string "服务器内部错误"
// @Param request body requests.InitMultipartRequest true "Request parameters"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /files/multipart/init [post]
func (e *FileEndpoint) InitMultipart(c *gin.Context) {
var req requests.InitMultipartRequest
@ -272,7 +264,7 @@ func (e *FileEndpoint) InitMultipart(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := e.NewFeaturesValidator.ValidateInitMultipart(&req); err != nil {
if err := e.FileValidator.ValidateInitMultipart(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@ -287,21 +279,21 @@ func (e *FileEndpoint) InitMultipart(c *gin.Context) {
}
// UploadPart godoc
// @Summary 上传分片
// @Description 上传单个文件分片,建议每个分片 5MB支持失败重试
// @Tags 大文件上传
// @Summary Upload a part
// @Description Upload a single part of a multipart upload (5MB recommended per part)
// @Tags Multipart Upload
// @Accept multipart/form-data
// @Produce json
// @Security ApiKeyAuth
// @Param bucket_name formData string true "存储桶名称"
// @Param object_key formData string true "对象键"
// @Param upload_id formData string true "上传ID由初始化接口返回"
// @Param part_number formData int true "分片序号从1开始"
// @Param file formData file true "分片文件数据"
// @Success 200 {object} map[string]string "返回 ETag"
// @Failure 400 {object} map[string]string "参数错误"
// @Failure 401 {object} map[string]string "未授权API 密钥无效或缺失"
// @Failure 500 {object} map[string]string "服务器内部错误"
// @Param bucket_name formData string true "Bucket name"
// @Param object_key formData string true "Object key"
// @Param upload_id formData string true "Upload ID"
// @Param part_number formData int true "Part number (starting from 1)"
// @Param file formData file true "Part data"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /files/multipart/part [put]
func (e *FileEndpoint) UploadPart(c *gin.Context) {
var req requests.UploadPartRequest
@ -309,7 +301,7 @@ func (e *FileEndpoint) UploadPart(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := e.NewFeaturesValidator.ValidateUploadPart(&req); err != nil {
if err := e.FileValidator.ValidateUploadPart(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@ -337,17 +329,17 @@ func (e *FileEndpoint) UploadPart(c *gin.Context) {
}
// CompleteMultipart godoc
// @Summary 完成分片上传
// @Description 合并所有分片完成上传,需传入所有分片的 PartNumber 和 ETag
// @Tags 大文件上传
// @Summary Complete multipart upload
// @Description Assemble all parts to complete the upload
// @Tags Multipart Upload
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param request body requests.CompleteMultipartRequest true "请求参数"
// @Success 200 {object} map[string]string "返回文件位置"
// @Failure 400 {object} map[string]string "参数错误"
// @Failure 401 {object} map[string]string "未授权API 密钥无效或缺失"
// @Failure 500 {object} map[string]string "服务器内部错误"
// @Param request body requests.CompleteMultipartRequest true "Request parameters"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /files/multipart/complete [post]
func (e *FileEndpoint) CompleteMultipart(c *gin.Context) {
var req requests.CompleteMultipartRequest
@ -355,7 +347,7 @@ func (e *FileEndpoint) CompleteMultipart(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := e.NewFeaturesValidator.ValidateCompleteMultipart(&req); err != nil {
if err := e.FileValidator.ValidateCompleteMultipart(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@ -374,18 +366,55 @@ func (e *FileEndpoint) CompleteMultipart(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"location": result})
}
// DeleteFile godoc
// @Summary 删除文件
// @Description 从指定的存储桶删除文件,此操作不可恢复
// @Tags 文件操作
// AbortMultipart godoc
// @Summary Abort multipart upload
// @Description Cancel an in-progress multipart upload
// @Tags Multipart Upload
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param request body requests.DeleteFileRequest true "请求参数"
// @Success 200 {object} map[string]string "删除成功消息"
// @Failure 400 {object} map[string]string "参数错误"
// @Failure 401 {object} map[string]string "未授权API 密钥无效或缺失"
// @Failure 500 {object} map[string]string "服务器内部错误"
// @Param request body requests.AbortMultipartRequest true "Request parameters"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /files/multipart/abort [post]
func (e *FileEndpoint) AbortMultipart(c *gin.Context) {
var req requests.AbortMultipartRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := e.FileValidator.ValidateAbortMultipart(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
cmd := handlers.AbortMultipartCommand{
BucketName: req.BucketName,
ObjectKey: req.ObjectKey,
UploadId: req.UploadId,
}
result, err := mediator.Send[handlers.AbortMultipartCommand, string](e.Mediator, c.Request.Context(), cmd)
if err != nil {
handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"message": result})
}
// DeleteFile godoc
// @Summary Delete file
// @Description Delete a file from the specified bucket (irreversible)
// @Tags Files
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param request body requests.DeleteFileRequest true "Request parameters"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /files/delete [delete]
func (e *FileEndpoint) DeleteFile(c *gin.Context) {
var req requests.DeleteFileRequest
@ -394,7 +423,7 @@ func (e *FileEndpoint) DeleteFile(c *gin.Context) {
return
}
if err := e.NewFeaturesValidator.ValidateDeleteFile(&req); err != nil {
if err := e.FileValidator.ValidateDeleteFile(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@ -411,11 +440,3 @@ func (e *FileEndpoint) DeleteFile(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"message": result})
}
func handleError(c *gin.Context, err error) {
if be, ok := err.(*common.BusinessException); ok {
c.JSON(be.Code, gin.H{"error": be.Message})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
}

View File

@ -2,7 +2,7 @@ package handlers
import (
"context"
"file-system/internal/domain/repository"
"rag/file-system/internal/domain/repository"
)
type CreateBucketHandler struct {

View File

@ -1,19 +0,0 @@
package handlers
import (
"context"
"file-system/internal/domain/repository"
"io"
)
type DownloadFileHandler struct {
Repo repository.FileRepository
}
func NewDownloadFileHandler(repo repository.FileRepository) *DownloadFileHandler {
return &DownloadFileHandler{Repo: repo}
}
func (h *DownloadFileHandler) Handle(ctx context.Context, query DownloadFileQuery) (io.ReadCloser, error) {
return h.Repo.DownloadFile(ctx, query.BucketName, query.ObjectKey)
}

View File

@ -1,6 +0,0 @@
package handlers
type DownloadFileQuery struct {
BucketName string
ObjectKey string
}

View File

@ -0,0 +1,44 @@
package handlers
import (
"io"
"rag/file-system/internal/common"
)
type UploadFileCommand struct {
BucketName string
FileName string
Data io.Reader
}
type DeleteFileCommand struct {
BucketName string
ObjectKey string
}
type InitMultipartCommand struct {
BucketName string
ObjectKey string
}
type UploadPartCommand struct {
BucketName string
ObjectKey string
UploadId string
PartNumber int32
Data io.Reader
}
type CompleteMultipartCommand struct {
BucketName string
ObjectKey string
UploadId string
Parts []common.Part
}
type AbortMultipartCommand struct {
BucketName string
ObjectKey string
UploadId string
}

View File

@ -0,0 +1,140 @@
package handlers
import (
"context"
"io"
"rag/file-system/internal/domain/repository"
)
type UploadFileHandler struct {
Repo repository.FileRepository
}
func NewUploadFileHandler(repo repository.FileRepository) *UploadFileHandler {
return &UploadFileHandler{Repo: repo}
}
func (h *UploadFileHandler) Handle(ctx context.Context, cmd UploadFileCommand) (string, error) {
err := h.Repo.UploadFile(ctx, cmd.BucketName, cmd.FileName, cmd.Data)
if err != nil {
return "", err
}
return "File uploaded successfully", nil
}
type DownloadFileHandler struct {
Repo repository.FileRepository
}
func NewDownloadFileHandler(repo repository.FileRepository) *DownloadFileHandler {
return &DownloadFileHandler{Repo: repo}
}
func (h *DownloadFileHandler) Handle(ctx context.Context, query DownloadFileQuery) (io.ReadCloser, error) {
return h.Repo.DownloadFile(ctx, query.BucketName, query.ObjectKey)
}
type ListFilesHandler struct {
Repo repository.FileRepository
}
func NewListFilesHandler(repo repository.FileRepository) *ListFilesHandler {
return &ListFilesHandler{Repo: repo}
}
func (h *ListFilesHandler) Handle(ctx context.Context, q ListFilesQuery) (*repository.ListFilesResult, error) {
return h.Repo.ListObjectsV2(ctx, q.BucketName, q.Prefix, q.MaxKeys, q.Token)
}
type GetFilePreviewHandler struct {
Repo repository.FileRepository
}
func NewGetFilePreviewHandler(repo repository.FileRepository) *GetFilePreviewHandler {
return &GetFilePreviewHandler{Repo: repo}
}
func (h *GetFilePreviewHandler) Handle(ctx context.Context, q GetFilePreviewQuery) (string, error) {
return h.Repo.GeneratePresignedURL(ctx, q.BucketName, q.ObjectKey, q.Expiry)
}
type GetFileContentHandler struct {
Repo repository.FileRepository
}
func NewGetFileContentHandler(repo repository.FileRepository) *GetFileContentHandler {
return &GetFileContentHandler{Repo: repo}
}
func (h *GetFileContentHandler) Handle(ctx context.Context, q GetFileContentQuery) (string, error) {
return h.Repo.GetFileContent(ctx, q.BucketName, q.ObjectKey)
}
type DeleteFileHandler struct {
Repo repository.FileRepository
}
func NewDeleteFileHandler(repo repository.FileRepository) *DeleteFileHandler {
return &DeleteFileHandler{Repo: repo}
}
func (h *DeleteFileHandler) Handle(ctx context.Context, cmd DeleteFileCommand) (string, error) {
err := h.Repo.DeleteFile(ctx, cmd.BucketName, cmd.ObjectKey)
if err != nil {
return "", err
}
return "File deleted successfully", nil
}
type InitMultipartHandler struct {
Repo repository.FileRepository
}
func NewInitMultipartHandler(repo repository.FileRepository) *InitMultipartHandler {
return &InitMultipartHandler{Repo: repo}
}
func (h *InitMultipartHandler) Handle(ctx context.Context, cmd InitMultipartCommand) (string, error) {
return h.Repo.CreateMultipartUpload(ctx, cmd.BucketName, cmd.ObjectKey)
}
type UploadPartHandler struct {
Repo repository.FileRepository
}
func NewUploadPartHandler(repo repository.FileRepository) *UploadPartHandler {
return &UploadPartHandler{Repo: repo}
}
func (h *UploadPartHandler) Handle(ctx context.Context, cmd UploadPartCommand) (string, error) {
return h.Repo.UploadPart(ctx, cmd.BucketName, cmd.ObjectKey, cmd.UploadId, cmd.PartNumber, cmd.Data)
}
type CompleteMultipartHandler struct {
Repo repository.FileRepository
}
func NewCompleteMultipartHandler(repo repository.FileRepository) *CompleteMultipartHandler {
return &CompleteMultipartHandler{Repo: repo}
}
func (h *CompleteMultipartHandler) Handle(ctx context.Context, cmd CompleteMultipartCommand) (string, error) {
return h.Repo.CompleteMultipartUpload(ctx, cmd.BucketName, cmd.ObjectKey, cmd.UploadId, cmd.Parts)
}
type AbortMultipartHandler struct {
Repo repository.FileRepository
}
func NewAbortMultipartHandler(repo repository.FileRepository) *AbortMultipartHandler {
return &AbortMultipartHandler{Repo: repo}
}
func (h *AbortMultipartHandler) Handle(ctx context.Context, cmd AbortMultipartCommand) (string, error) {
err := h.Repo.AbortMultipartUpload(ctx, cmd.BucketName, cmd.ObjectKey, cmd.UploadId)
if err != nil {
return "", err
}
return "Multipart upload aborted successfully", nil
}

View File

@ -0,0 +1,26 @@
package handlers
import "time"
type DownloadFileQuery struct {
BucketName string
ObjectKey string
}
type ListFilesQuery struct {
BucketName string
Prefix string
MaxKeys int32
Token *string
}
type GetFilePreviewQuery struct {
BucketName string
ObjectKey string
Expiry time.Duration
}
type GetFileContentQuery struct {
BucketName string
ObjectKey string
}

View File

@ -1,45 +0,0 @@
package handlers
import (
"context"
"file-system/internal/domain/repository"
)
// InitMultipartHandler
type InitMultipartHandler struct {
Repo repository.FileRepository
}
func NewInitMultipartHandler(repo repository.FileRepository) *InitMultipartHandler {
return &InitMultipartHandler{Repo: repo}
}
func (h *InitMultipartHandler) Handle(ctx context.Context, cmd InitMultipartCommand) (string, error) {
return h.Repo.CreateMultipartUpload(ctx, cmd.BucketName, cmd.ObjectKey)
}
// UploadPartHandler
type UploadPartHandler struct {
Repo repository.FileRepository
}
func NewUploadPartHandler(repo repository.FileRepository) *UploadPartHandler {
return &UploadPartHandler{Repo: repo}
}
func (h *UploadPartHandler) Handle(ctx context.Context, cmd UploadPartCommand) (string, error) {
return h.Repo.UploadPart(ctx, cmd.BucketName, cmd.ObjectKey, cmd.UploadId, cmd.PartNumber, cmd.Data)
}
// CompleteMultipartHandler
type CompleteMultipartHandler struct {
Repo repository.FileRepository
}
func NewCompleteMultipartHandler(repo repository.FileRepository) *CompleteMultipartHandler {
return &CompleteMultipartHandler{Repo: repo}
}
func (h *CompleteMultipartHandler) Handle(ctx context.Context, cmd CompleteMultipartCommand) (string, error) {
return h.Repo.CompleteMultipartUpload(ctx, cmd.BucketName, cmd.ObjectKey, cmd.UploadId, cmd.Parts)
}

View File

@ -1,110 +0,0 @@
package handlers
import (
"context"
"file-system/internal/common"
"file-system/internal/domain/repository"
"io"
"time"
)
// Queries & Commands
type ListFilesQuery struct {
BucketName string
Prefix string
MaxKeys int32
Token *string
}
type GetFilePreviewQuery struct {
BucketName string
ObjectKey string
Expiry time.Duration
}
// GetFileContentQuery 获取文件文本内容查询
type GetFileContentQuery struct {
BucketName string
ObjectKey string
}
type InitMultipartCommand struct {
BucketName string
ObjectKey string
}
type UploadPartCommand struct {
BucketName string
ObjectKey string
UploadId string
PartNumber int32
Data io.Reader
}
type CompleteMultipartCommand struct {
BucketName string
ObjectKey string
UploadId string
Parts []common.Part
}
// Handlers
type ListFilesHandler struct {
Repo repository.FileRepository
}
func NewListFilesHandler(repo repository.FileRepository) *ListFilesHandler {
return &ListFilesHandler{Repo: repo}
}
func (h *ListFilesHandler) Handle(ctx context.Context, q ListFilesQuery) (*repository.ListFilesResult, error) {
return h.Repo.ListObjectsV2(ctx, q.BucketName, q.Prefix, q.MaxKeys, q.Token)
}
type GetFilePreviewHandler struct {
Repo repository.FileRepository
}
func NewGetFilePreviewHandler(repo repository.FileRepository) *GetFilePreviewHandler {
return &GetFilePreviewHandler{Repo: repo}
}
func (h *GetFilePreviewHandler) Handle(ctx context.Context, q GetFilePreviewQuery) (string, error) {
return h.Repo.GeneratePresignedURL(ctx, q.BucketName, q.ObjectKey, q.Expiry)
}
// GetFileContentHandler 获取文件文本内容处理器
type GetFileContentHandler struct {
Repo repository.FileRepository
}
func NewGetFileContentHandler(repo repository.FileRepository) *GetFileContentHandler {
return &GetFileContentHandler{Repo: repo}
}
func (h *GetFileContentHandler) Handle(ctx context.Context, q GetFileContentQuery) (string, error) {
return h.Repo.GetFileContent(ctx, q.BucketName, q.ObjectKey)
}
// DeleteFileCommand 删除文件命令
type DeleteFileCommand struct {
BucketName string
ObjectKey string
}
type DeleteFileHandler struct {
Repo repository.FileRepository
}
func NewDeleteFileHandler(repo repository.FileRepository) *DeleteFileHandler {
return &DeleteFileHandler{Repo: repo}
}
func (h *DeleteFileHandler) Handle(ctx context.Context, cmd DeleteFileCommand) (string, error) {
err := h.Repo.DeleteFile(ctx, cmd.BucketName, cmd.ObjectKey)
if err != nil {
return "", err
}
return "File deleted successfully", nil
}

View File

@ -1,9 +0,0 @@
package handlers
import "io"
type UploadFileCommand struct {
BucketName string
FileName string
Data io.Reader
}

View File

@ -1,25 +0,0 @@
package handlers
import (
"context"
"file-system/internal/domain/repository"
)
type UploadFileHandler struct {
Repo repository.FileRepository
}
func NewUploadFileHandler(repo repository.FileRepository) *UploadFileHandler {
return &UploadFileHandler{Repo: repo}
}
func (h *UploadFileHandler) Handle(ctx context.Context, cmd UploadFileCommand) (string, error) {
// 业务逻辑:上传文件
// 调用 Repository
err := h.Repo.UploadFile(ctx, cmd.BucketName, cmd.FileName, cmd.Data)
if err != nil {
// 简单的错误处理,实际可能需要包装为 BusinessException
return "", err
}
return "File uploaded successfully", nil
}

View File

@ -1,6 +0,0 @@
package requests
type DownloadFileRequest struct {
BucketName string `form:"bucket_name"`
ObjectKey string `form:"object_key"`
}

View File

@ -1,8 +1,9 @@
package requests
import (
"file-system/internal/common"
"mime/multipart"
"rag/file-system/internal/common"
)
type ListFilesRequest struct {
@ -17,6 +18,11 @@ type GetFilePreviewRequest struct {
ObjectKey string `form:"object_key"`
}
type GetFileContentRequest struct {
BucketName string `form:"bucket_name"`
ObjectKey string `form:"object_key"`
}
type InitMultipartRequest struct {
BucketName string `json:"bucket_name"`
ObjectKey string `json:"object_key"`
@ -41,3 +47,19 @@ type DeleteFileRequest struct {
BucketName string `json:"bucket_name"`
ObjectKey string `json:"object_key"`
}
type AbortMultipartRequest struct {
BucketName string `json:"bucket_name"`
ObjectKey string `json:"object_key"`
UploadId string `json:"upload_id"`
}
type DownloadFileRequest struct {
BucketName string `form:"bucket_name"`
ObjectKey string `form:"object_key"`
}
type UploadFileRequest struct {
BucketName string `form:"bucket_name"`
File *multipart.FileHeader `form:"file"`
}

View File

@ -1,8 +0,0 @@
package requests
import "mime/multipart"
type UploadFileRequest struct {
BucketName string `form:"bucket_name"`
File *multipart.FileHeader `form:"file"`
}

View File

@ -1,8 +1,8 @@
package validators
import (
"file-system/internal/api/requests"
"file-system/internal/common"
"rag/file-system/internal/api/requests"
"rag/file-system/internal/common"
)
type CreateBucketValidator struct{}
@ -15,6 +15,9 @@ func (v *CreateBucketValidator) Validate(req *requests.CreateBucketRequest) erro
if req.BucketName == "" {
return common.NewBusinessException("Bucket name cannot be empty")
}
if err := common.SanitizeBucketName(req.BucketName); err != nil {
return err
}
return nil
}
@ -22,5 +25,8 @@ func (v *CreateBucketValidator) ValidateDelete(req *requests.DeleteBucketRequest
if req.BucketName == "" {
return common.NewBusinessException("Bucket name cannot be empty")
}
if err := common.SanitizeBucketName(req.BucketName); err != nil {
return err
}
return nil
}

View File

@ -1,22 +0,0 @@
package validators
import (
"file-system/internal/api/requests"
"file-system/internal/common"
)
type DownloadFileValidator struct{}
func NewDownloadFileValidator() *DownloadFileValidator {
return &DownloadFileValidator{}
}
func (v *DownloadFileValidator) Validate(req *requests.DownloadFileRequest) error {
if req.BucketName == "" {
return common.NewBusinessException("Bucket name cannot be empty")
}
if req.ObjectKey == "" {
return common.NewBusinessException("Object key cannot be empty")
}
return nil
}

View File

@ -0,0 +1,97 @@
package validators
import (
"rag/file-system/internal/api/requests"
"rag/file-system/internal/common"
)
type FileValidator struct{}
func NewFileValidator() *FileValidator {
return &FileValidator{}
}
func (v *FileValidator) ValidateListFiles(req *requests.ListFilesRequest) error {
if req.BucketName == "" {
return common.NewBusinessException("Bucket name is required")
}
if err := common.SanitizeBucketName(req.BucketName); err != nil {
return err
}
if req.MaxKeys <= 0 {
req.MaxKeys = 20
}
if req.MaxKeys > 1000 {
req.MaxKeys = 1000
}
return nil
}
func (v *FileValidator) ValidatePreview(req *requests.GetFilePreviewRequest) error {
if req.BucketName == "" || req.ObjectKey == "" {
return common.NewBusinessException("Bucket name and Object key are required")
}
return common.SanitizeObjectKey(req.ObjectKey)
}
func (v *FileValidator) ValidateGetContent(req *requests.GetFileContentRequest) error {
if req.BucketName == "" || req.ObjectKey == "" {
return common.NewBusinessException("Bucket name and Object key are required")
}
return common.SanitizeObjectKey(req.ObjectKey)
}
func (v *FileValidator) ValidateInitMultipart(req *requests.InitMultipartRequest) error {
if req.BucketName == "" || req.ObjectKey == "" {
return common.NewBusinessException("Bucket name and Object key are required")
}
return common.SanitizeObjectKey(req.ObjectKey)
}
func (v *FileValidator) ValidateUploadPart(req *requests.UploadPartRequest) error {
if req.BucketName == "" || req.ObjectKey == "" || req.UploadId == "" || req.PartNumber <= 0 || req.File == nil {
return common.NewBusinessException("Missing required fields for upload part")
}
return common.SanitizeObjectKey(req.ObjectKey)
}
func (v *FileValidator) ValidateCompleteMultipart(req *requests.CompleteMultipartRequest) error {
if req.BucketName == "" || req.ObjectKey == "" || req.UploadId == "" || len(req.Parts) == 0 {
return common.NewBusinessException("Missing required fields for completion")
}
return common.SanitizeObjectKey(req.ObjectKey)
}
func (v *FileValidator) ValidateDeleteFile(req *requests.DeleteFileRequest) error {
if req.BucketName == "" || req.ObjectKey == "" {
return common.NewBusinessException("Bucket name and Object key are required")
}
return common.SanitizeObjectKey(req.ObjectKey)
}
func (v *FileValidator) ValidateAbortMultipart(req *requests.AbortMultipartRequest) error {
if req.BucketName == "" || req.ObjectKey == "" || req.UploadId == "" {
return common.NewBusinessException("Bucket name, Object key, and Upload ID are required")
}
return common.SanitizeObjectKey(req.ObjectKey)
}
func (v *FileValidator) ValidateDownload(req *requests.DownloadFileRequest) error {
if req.BucketName == "" {
return common.NewBusinessException("Bucket name cannot be empty")
}
if req.ObjectKey == "" {
return common.NewBusinessException("Object key cannot be empty")
}
return common.SanitizeObjectKey(req.ObjectKey)
}
func (v *FileValidator) ValidateUpload(req *requests.UploadFileRequest) error {
if req.BucketName == "" {
return common.NewBusinessException("Bucket name cannot be empty")
}
if req.File == nil {
return common.NewBusinessException("File is required")
}
return common.SanitizeObjectKey(req.File.Filename)
}

View File

@ -1,57 +0,0 @@
package validators
import (
"file-system/internal/api/requests"
"file-system/internal/common"
)
type NewFeaturesValidator struct{}
func NewNewFeaturesValidator() *NewFeaturesValidator {
return &NewFeaturesValidator{}
}
func (v *NewFeaturesValidator) ValidateListFiles(req *requests.ListFilesRequest) error {
if req.BucketName == "" {
return common.NewBusinessException("Bucket name is required")
}
if req.MaxKeys <= 0 {
req.MaxKeys = 10 // default
}
return nil
}
func (v *NewFeaturesValidator) ValidatePreview(req *requests.GetFilePreviewRequest) error {
if req.BucketName == "" || req.ObjectKey == "" {
return common.NewBusinessException("Bucket name and Object key are required")
}
return nil
}
func (v *NewFeaturesValidator) ValidateInitMultipart(req *requests.InitMultipartRequest) error {
if req.BucketName == "" || req.ObjectKey == "" {
return common.NewBusinessException("Bucket name and Object key are required")
}
return nil
}
func (v *NewFeaturesValidator) ValidateUploadPart(req *requests.UploadPartRequest) error {
if req.BucketName == "" || req.ObjectKey == "" || req.UploadId == "" || req.PartNumber <= 0 || req.File == nil {
return common.NewBusinessException("Missing required fields for upload part")
}
return nil
}
func (v *NewFeaturesValidator) ValidateCompleteMultipart(req *requests.CompleteMultipartRequest) error {
if req.BucketName == "" || req.ObjectKey == "" || req.UploadId == "" || len(req.Parts) == 0 {
return common.NewBusinessException("Missing required fields for completion")
}
return nil
}
func (v *NewFeaturesValidator) ValidateDeleteFile(req *requests.DeleteFileRequest) error {
if req.BucketName == "" || req.ObjectKey == "" {
return common.NewBusinessException("Bucket name and Object key are required")
}
return nil
}

View File

@ -1,22 +0,0 @@
package validators
import (
"file-system/internal/api/requests"
"file-system/internal/common"
)
type UploadFileValidator struct{}
func NewUploadFileValidator() *UploadFileValidator {
return &UploadFileValidator{}
}
func (v *UploadFileValidator) Validate(req *requests.UploadFileRequest) error {
if req.BucketName == "" {
return common.NewBusinessException("Bucket name cannot be empty")
}
if req.File == nil {
return common.NewBusinessException("File is required")
}
return nil
}

View File

@ -0,0 +1,37 @@
package common
import "testing"
func TestConfig_Validate_MissingAuthAPIKey(t *testing.T) {
cfg := &Config{
RustFSAccessKeyID: "key",
RustFSSecretAccessKey: "secret",
RustFSEndpoint: "http://localhost:9000",
}
if err := cfg.Validate(); err == nil {
t.Error("expected error when AuthAPIKey is empty, got nil")
}
}
func TestConfig_Validate_MissingRustFSAccessKeyID(t *testing.T) {
cfg := &Config{
AuthAPIKey: "api-key",
RustFSSecretAccessKey: "secret",
RustFSEndpoint: "http://localhost:9000",
}
if err := cfg.Validate(); err == nil {
t.Error("expected error when RustFSAccessKeyID is empty, got nil")
}
}
func TestConfig_Validate_AllFieldsPresent(t *testing.T) {
cfg := &Config{
AuthAPIKey: "api-key",
RustFSAccessKeyID: "access-key",
RustFSSecretAccessKey: "secret-key",
RustFSEndpoint: "http://localhost:9000",
}
if err := cfg.Validate(); err != nil {
t.Errorf("expected nil when all fields present, got error: %v", err)
}
}

View File

@ -15,3 +15,24 @@ func NewBusinessException(message string) *BusinessException {
Code: 400,
}
}
func NewNotFoundError(message string) *BusinessException {
return &BusinessException{
Message: message,
Code: 404,
}
}
func NewConflictError(message string) *BusinessException {
return &BusinessException{
Message: message,
Code: 409,
}
}
func NewBusinessExceptionWithCode(code int, message string) *BusinessException {
return &BusinessException{
Message: message,
Code: code,
}
}

14
internal/common/logger.go Normal file
View File

@ -0,0 +1,14 @@
package common
import (
"log/slog"
"os"
)
var Logger *slog.Logger
func InitLogger() {
Logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
}

View File

@ -0,0 +1,26 @@
package common
import (
"errors"
"fmt"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
)
func WrapS3Error(err error) error {
if err == nil {
return nil
}
var (
noSuchBucket *types.NoSuchBucket
noSuchKey *types.NoSuchKey
notFound *types.NotFound
)
if errors.As(err, &noSuchBucket) || errors.As(err, &noSuchKey) || errors.As(err, &notFound) {
return NewNotFoundError("resource not found")
}
return fmt.Errorf("storage operation failed")
}

View File

@ -0,0 +1,32 @@
package common
import (
"regexp"
"strings"
)
var bucketNameRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$`)
func SanitizeObjectKey(key string) error {
if strings.Contains(key, "..") || strings.Contains(key, "//") || strings.HasPrefix(key, "/") {
return NewBusinessException("invalid object key: path traversal detected")
}
return nil
}
func SanitizeBucketName(name string) error {
if !bucketNameRegex.MatchString(name) {
return NewBusinessException("invalid bucket name: must be 3-63 lowercase letters, digits, hyphens, or dots")
}
if len(name) < 3 || len(name) > 63 {
return NewBusinessException("invalid bucket name: must be between 3 and 63 characters")
}
return nil
}
func SanitizeFilename(name string) string {
safe := strings.ReplaceAll(name, `"`, `\"`)
safe = strings.ReplaceAll(safe, "\r", "")
safe = strings.ReplaceAll(safe, "\n", "")
return safe
}

View File

@ -0,0 +1,108 @@
package common
import (
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// SanitizeObjectKey
// ---------------------------------------------------------------------------
func TestSanitizeObjectKey_ValidInput(t *testing.T) {
keys := []string{"folder/file.txt", "file.csv", "a/b/c/d.json"}
for _, key := range keys {
if err := SanitizeObjectKey(key); err != nil {
t.Errorf("expected nil for key %q, got error: %v", key, err)
}
}
}
func TestSanitizeObjectKey_PathTraversal(t *testing.T) {
cases := []struct {
name string
key string
}{
{"double dot", "../etc/passwd"},
{"double dot middle", "a/../b"},
{"double slash", "folder//file.txt"},
{"leading slash", "/absolute/path"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := SanitizeObjectKey(tc.key)
if err == nil {
t.Errorf("expected error for key %q, got nil", tc.key)
}
})
}
}
// ---------------------------------------------------------------------------
// SanitizeBucketName
// ---------------------------------------------------------------------------
func TestSanitizeBucketName_Valid(t *testing.T) {
names := []string{"my-bucket", "bucket123", "a1b", "my.bucket.name"}
for _, name := range names {
if err := SanitizeBucketName(name); err != nil {
t.Errorf("expected nil for bucket %q, got error: %v", name, err)
}
}
}
func TestSanitizeBucketName_Invalid(t *testing.T) {
cases := []struct {
name string
input string
}{
{"uppercase", "MyBucket"},
{"too short", "ab"},
{"starts with hyphen", "-bucket"},
{"starts with dot", ".bucket"},
{"contains underscore", "my_bucket"},
{"empty", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := SanitizeBucketName(tc.input)
if err == nil {
t.Errorf("expected error for bucket %q, got nil", tc.input)
}
})
}
}
func TestSanitizeBucketName_TooLong(t *testing.T) {
longName := strings.Repeat("a", 64)
if err := SanitizeBucketName(longName); err == nil {
t.Error("expected error for bucket name > 63 chars, got nil")
}
}
// ---------------------------------------------------------------------------
// SanitizeFilename
// ---------------------------------------------------------------------------
func TestSanitizeFilename_RemovesCRLF(t *testing.T) {
input := "file\r\nname.txt"
got := SanitizeFilename(input)
if strings.Contains(got, "\r") || strings.Contains(got, "\n") {
t.Errorf("expected \\r and \\n removed, got %q", got)
}
}
func TestSanitizeFilename_EscapesQuotes(t *testing.T) {
input := `some"file.txt`
got := SanitizeFilename(input)
if strings.Contains(got, `"`) && !strings.Contains(got, `\"`) {
t.Errorf("expected quotes escaped, got %q", got)
}
}
func TestSanitizeFilename_CleanInput(t *testing.T) {
input := "clean-file.txt"
if got := SanitizeFilename(input); got != input {
t.Errorf("expected %q, got %q", input, got)
}
}

View File

@ -2,7 +2,7 @@ package repository
import (
"context"
"file-system/internal/common"
"rag/file-system/internal/common"
"io"
"time"
)
@ -25,19 +25,18 @@ type FileRepository interface {
ListBuckets(ctx context.Context) ([]string, error)
CreateBucket(ctx context.Context, bucketName string) error
DeleteBucket(ctx context.Context, bucketName string) error
ListObjects(ctx context.Context, bucketName string) ([]string, error)
// 新增功能
// File listing with pagination
ListObjectsV2(ctx context.Context, bucketName string, prefix string, maxKeys int32, continuationToken *string) (*ListFilesResult, error)
GeneratePresignedURL(ctx context.Context, bucketName string, objectKey string, expiry time.Duration) (string, error)
// 获取文件文本内容(用于文本文件预览)
// File content retrieval for text preview
GetFileContent(ctx context.Context, bucketName string, objectKey string) (string, error)
// 删除文件
// File deletion
DeleteFile(ctx context.Context, bucketName string, objectKey string) error
// 分片上传
// Multipart upload
CreateMultipartUpload(ctx context.Context, bucketName string, objectKey string) (string, error)
UploadPart(ctx context.Context, bucketName string, objectKey string, uploadId string, partNumber int32, data io.Reader) (string, error)
CompleteMultipartUpload(ctx context.Context, bucketName string, objectKey string, uploadId string, parts []common.Part) (string, error)

View File

@ -0,0 +1,101 @@
package grpc
import (
"context"
"fmt"
"rag/file-system/api/proto"
"rag/file-system/internal/common"
"time"
"github.com/patrickmn/go-cache"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
type TokenInfo struct {
Valid bool
UserId string
Username string
Email string
Roles []string
Permissions []string
ExpiresAt int64
}
type AuthClient struct {
conn *grpc.ClientConn
client proto.AuthServiceClient
cache *cache.Cache
}
func NewAuthClient(addr string) (*AuthClient, error) {
conn, err := grpc.NewClient(addr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
return nil, fmt.Errorf("gRPC connect failed: %w", err)
}
common.Logger.Info("gRPC auth client connected", "addr", addr)
return &AuthClient{
conn: conn,
client: proto.NewAuthServiceClient(conn),
cache: cache.New(2*time.Minute, 5*time.Minute),
}, nil
}
func (a *AuthClient) ValidateToken(ctx context.Context, token string) (*TokenInfo, error) {
if cached, ok := a.cache.Get(token); ok {
return cached.(*TokenInfo), nil
}
resp, err := a.client.ValidateToken(ctx, &proto.ValidateTokenRequest{Token: token})
if err != nil {
return nil, fmt.Errorf("gRPC ValidateToken failed: %w", err)
}
info := &TokenInfo{
Valid: resp.Valid,
UserId: resp.UserId,
Username: resp.Username,
Email: resp.Email,
Roles: resp.Roles,
Permissions: resp.Permissions,
ExpiresAt: resp.ExpiresAt,
}
if info.Valid {
ttl := a.cacheTTL(resp.ExpiresAt)
a.cache.Set(token, info, ttl)
}
return info, nil
}
func (a *AuthClient) HasPermission(token, permission string) (bool, error) {
resp, err := a.client.CheckPermission(context.Background(), &proto.CheckPermissionRequest{
Token: token,
Permission: permission,
})
if err != nil {
return false, fmt.Errorf("gRPC CheckPermission failed: %w", err)
}
return resp.Allowed, nil
}
func (a *AuthClient) Close() error {
return a.conn.Close()
}
func (a *AuthClient) cacheTTL(expiresAt int64) time.Duration {
expires := time.Unix(expiresAt, 0)
ttl := time.Until(expires) - 30*time.Second
if ttl < time.Minute {
ttl = time.Minute
}
if ttl > 2*time.Minute {
ttl = 2 * time.Minute
}
return ttl
}

View File

@ -0,0 +1,70 @@
package mediator
import (
"context"
"errors"
"testing"
)
// --- test request / response types ---
type testRequest struct {
Message string
}
type testResponse struct {
Result string
}
// --- stub handler ---
type stubHandler struct {
response testResponse
err error
}
func (h *stubHandler) Handle(_ context.Context, _ testRequest) (testResponse, error) {
return h.response, h.err
}
// --- tests ---
func TestRegisterAndSend_Success(t *testing.T) {
m := NewMediator()
Register[testRequest, testResponse](m, &stubHandler{
response: testResponse{Result: "ok"},
})
resp, err := Send[testRequest, testResponse](m, context.Background(), testRequest{Message: "hello"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Result != "ok" {
t.Errorf("expected result %q, got %q", "ok", resp.Result)
}
}
func TestSend_UnregisteredType(t *testing.T) {
m := NewMediator()
_, err := Send[testRequest, testResponse](m, context.Background(), testRequest{Message: "hello"})
if err == nil {
t.Fatal("expected error for unregistered type, got nil")
}
}
func TestSend_HandlerError(t *testing.T) {
m := NewMediator()
expectedErr := errors.New("something went wrong")
Register[testRequest, testResponse](m, &stubHandler{
err: expectedErr,
})
_, err := Send[testRequest, testResponse](m, context.Background(), testRequest{Message: "hello"})
if err == nil {
t.Fatal("expected error from handler, got nil")
}
if err.Error() != expectedErr.Error() {
t.Errorf("expected error %q, got %q", expectedErr.Error(), err.Error())
}
}

View File

@ -2,7 +2,7 @@ package s3
import (
"context"
"file-system/internal/common"
"rag/file-system/internal/common"
"log"
"github.com/aws/aws-sdk-go-v2/aws"
@ -12,12 +12,11 @@ import (
)
type RustFSClient struct {
Client *s3.Client
PresignClient *s3.PresignClient
client *s3.Client
presignClient *s3.PresignClient
}
func NewRustFSClient(cfg *common.Config) *RustFSClient {
// Custom Endpoint Resolver
customResolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
return aws.Endpoint{
URL: cfg.RustFSEndpoint,
@ -43,7 +42,15 @@ func NewRustFSClient(cfg *common.Config) *RustFSClient {
})
return &RustFSClient{
Client: client,
PresignClient: s3.NewPresignClient(client),
client: client,
presignClient: s3.NewPresignClient(client),
}
}
func (c *RustFSClient) S3Client() *s3.Client {
return c.client
}
func (c *RustFSClient) PresignClient() *s3.PresignClient {
return c.presignClient
}

View File

@ -2,8 +2,8 @@ package s3
import (
"context"
"file-system/internal/common"
"file-system/internal/domain/repository"
"rag/file-system/internal/common"
"rag/file-system/internal/domain/repository"
"io"
"sort"
"time"
@ -13,6 +13,8 @@ import (
"github.com/aws/aws-sdk-go-v2/service/s3/types"
)
const maxContentPreviewSize = 10 * 1024 * 1024 // 10MB
type S3FileRepository struct {
client *RustFSClient
}
@ -22,29 +24,29 @@ func NewS3FileRepository(client *RustFSClient) repository.FileRepository {
}
func (r *S3FileRepository) UploadFile(ctx context.Context, bucketName string, objectKey string, data io.Reader) error {
_, err := r.client.Client.PutObject(ctx, &s3.PutObjectInput{
_, err := r.client.S3Client().PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
Body: data,
})
return err
return common.WrapS3Error(err)
}
func (r *S3FileRepository) DownloadFile(ctx context.Context, bucketName string, objectKey string) (io.ReadCloser, error) {
resp, err := r.client.Client.GetObject(ctx, &s3.GetObjectInput{
resp, err := r.client.S3Client().GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
})
if err != nil {
return nil, err
return nil, common.WrapS3Error(err)
}
return resp.Body, nil
}
func (r *S3FileRepository) ListBuckets(ctx context.Context) ([]string, error) {
resp, err := r.client.Client.ListBuckets(ctx, &s3.ListBucketsInput{})
resp, err := r.client.S3Client().ListBuckets(ctx, &s3.ListBucketsInput{})
if err != nil {
return nil, err
return nil, common.WrapS3Error(err)
}
var buckets []string
for _, b := range resp.Buckets {
@ -56,62 +58,50 @@ func (r *S3FileRepository) ListBuckets(ctx context.Context) ([]string, error) {
}
func (r *S3FileRepository) CreateBucket(ctx context.Context, bucketName string) error {
_, err := r.client.Client.CreateBucket(ctx, &s3.CreateBucketInput{
_, err := r.client.S3Client().CreateBucket(ctx, &s3.CreateBucketInput{
Bucket: aws.String(bucketName),
})
return err
return common.WrapS3Error(err)
}
func (r *S3FileRepository) DeleteBucket(ctx context.Context, bucketName string) error {
_, err := r.client.Client.DeleteBucket(ctx, &s3.DeleteBucketInput{
_, err := r.client.S3Client().DeleteBucket(ctx, &s3.DeleteBucketInput{
Bucket: aws.String(bucketName),
})
return err
return common.WrapS3Error(err)
}
func (r *S3FileRepository) ListObjects(ctx context.Context, bucketName string) ([]string, error) {
resp, err := r.client.Client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
Bucket: aws.String(bucketName),
})
if err != nil {
return nil, err
}
var objects []string
for _, obj := range resp.Contents {
if obj.Key != nil {
objects = append(objects, *obj.Key)
}
}
return objects, nil
}
// GetFileContent 获取文件文本内容(用于 Markdown 等文本文件预览)
// GetFileContent retrieves text file content for preview (e.g., Markdown files)
func (r *S3FileRepository) GetFileContent(ctx context.Context, bucketName string, objectKey string) (string, error) {
resp, err := r.client.Client.GetObject(ctx, &s3.GetObjectInput{
resp, err := r.client.S3Client().GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
})
if err != nil {
return "", err
return "", common.WrapS3Error(err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
data, err := io.ReadAll(io.LimitReader(resp.Body, maxContentPreviewSize))
if err != nil {
return "", err
}
if int64(len(data)) >= maxContentPreviewSize {
return "", common.NewBusinessException("file too large for content preview (max 10MB)")
}
return string(data), nil
}
// DeleteFile 删除文件
// DeleteFile removes a file from the bucket
func (r *S3FileRepository) DeleteFile(ctx context.Context, bucketName string, objectKey string) error {
_, err := r.client.Client.DeleteObject(ctx, &s3.DeleteObjectInput{
_, err := r.client.S3Client().DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
})
return err
return common.WrapS3Error(err)
}
// ListObjectsV2 分页列出文件
// ListObjectsV2 lists files with pagination support
func (r *S3FileRepository) ListObjectsV2(ctx context.Context, bucketName string, prefix string, maxKeys int32, continuationToken *string) (*repository.ListFilesResult, error) {
input := &s3.ListObjectsV2Input{
Bucket: aws.String(bucketName),
@ -122,13 +112,16 @@ func (r *S3FileRepository) ListObjectsV2(ctx context.Context, bucketName string,
input.ContinuationToken = continuationToken
}
resp, err := r.client.Client.ListObjectsV2(ctx, input)
resp, err := r.client.S3Client().ListObjectsV2(ctx, input)
if err != nil {
return nil, err
return nil, common.WrapS3Error(err)
}
files := make([]repository.FileInfo, 0, len(resp.Contents))
for _, obj := range resp.Contents {
if obj.Key == nil || obj.Size == nil || obj.LastModified == nil || obj.ETag == nil {
continue
}
files = append(files, repository.FileInfo{
Key: *obj.Key,
Size: *obj.Size,
@ -143,35 +136,38 @@ func (r *S3FileRepository) ListObjectsV2(ctx context.Context, bucketName string,
}, nil
}
// GeneratePresignedURL 生成预签名链接
// GeneratePresignedURL generates a presigned URL for temporary file access
func (r *S3FileRepository) GeneratePresignedURL(ctx context.Context, bucketName string, objectKey string, expiry time.Duration) (string, error) {
presignResult, err := r.client.PresignClient.PresignGetObject(ctx, &s3.GetObjectInput{
presignResult, err := r.client.PresignClient().PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
}, func(opts *s3.PresignOptions) {
opts.Expires = expiry
})
if err != nil {
return "", err
return "", common.WrapS3Error(err)
}
return presignResult.URL, nil
}
// CreateMultipartUpload 初始化分片上传
// CreateMultipartUpload initializes a multipart upload session
func (r *S3FileRepository) CreateMultipartUpload(ctx context.Context, bucketName string, objectKey string) (string, error) {
resp, err := r.client.Client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{
resp, err := r.client.S3Client().CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
})
if err != nil {
return "", err
return "", common.WrapS3Error(err)
}
if resp.UploadId == nil {
return "", common.NewBusinessException("failed to initialize multipart upload")
}
return *resp.UploadId, nil
}
// UploadPart 上传分片
// UploadPart uploads a single part of a multipart upload
func (r *S3FileRepository) UploadPart(ctx context.Context, bucketName string, objectKey string, uploadId string, partNumber int32, data io.Reader) (string, error) {
resp, err := r.client.Client.UploadPart(ctx, &s3.UploadPartInput{
resp, err := r.client.S3Client().UploadPart(ctx, &s3.UploadPartInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
UploadId: aws.String(uploadId),
@ -179,14 +175,16 @@ func (r *S3FileRepository) UploadPart(ctx context.Context, bucketName string, ob
Body: data,
})
if err != nil {
return "", err
return "", common.WrapS3Error(err)
}
if resp.ETag == nil {
return "", common.NewBusinessException("failed to upload part")
}
return *resp.ETag, nil
}
// CompleteMultipartUpload 完成分片上传
// CompleteMultipartUpload assembles all parts to complete the upload
func (r *S3FileRepository) CompleteMultipartUpload(ctx context.Context, bucketName string, objectKey string, uploadId string, parts []common.Part) (string, error) {
// 需要按 PartNumber 排序
sort.Slice(parts, func(i, j int) bool {
return parts[i].PartNumber < parts[j].PartNumber
})
@ -199,7 +197,7 @@ func (r *S3FileRepository) CompleteMultipartUpload(ctx context.Context, bucketNa
}
}
resp, err := r.client.Client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{
resp, err := r.client.S3Client().CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
UploadId: aws.String(uploadId),
@ -208,17 +206,20 @@ func (r *S3FileRepository) CompleteMultipartUpload(ctx context.Context, bucketNa
},
})
if err != nil {
return "", err
return "", common.WrapS3Error(err)
}
if resp.Location == nil {
return "", common.NewBusinessException("failed to complete multipart upload")
}
return *resp.Location, nil
}
// AbortMultipartUpload 取消分片上传
// AbortMultipartUpload cancels an in-progress multipart upload
func (r *S3FileRepository) AbortMultipartUpload(ctx context.Context, bucketName string, objectKey string, uploadId string) error {
_, err := r.client.Client.AbortMultipartUpload(ctx, &s3.AbortMultipartUploadInput{
_, err := r.client.S3Client().AbortMultipartUpload(ctx, &s3.AbortMultipartUploadInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
UploadId: aws.String(uploadId),
})
return err
return common.WrapS3Error(err)
}

View File

@ -0,0 +1,63 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func init() {
gin.SetMode(gin.TestMode)
}
func TestAuthMiddleware_MissingKey(t *testing.T) {
r := gin.New()
r.Use(AuthMiddleware("secret-key"))
r.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/test", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestAuthMiddleware_WrongKey(t *testing.T) {
r := gin.New()
r.Use(AuthMiddleware("secret-key"))
r.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("X-API-Key", "wrong-key")
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestAuthMiddleware_CorrectKey(t *testing.T) {
r := gin.New()
r.Use(AuthMiddleware("secret-key"))
r.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("X-API-Key", "secret-key")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}

View File

@ -0,0 +1,80 @@
package middleware
import (
"net/http"
"rag/file-system/internal/infrastructure/grpc"
"strings"
"github.com/gin-gonic/gin"
)
const (
HeaderAuthorization = "Authorization"
BearerPrefix = "Bearer "
ContextKeyUserID = "user_id"
ContextKeyUsername = "username"
ContextKeyEmail = "email"
ContextKeyRoles = "roles"
ContextKeyPermissions = "permissions"
)
func JWTAuthMiddleware(authClient *grpc.AuthClient) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader(HeaderAuthorization)
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"code": http.StatusUnauthorized,
"message": "未授权:请提供 Bearer Token",
})
c.Abort()
return
}
if !strings.HasPrefix(authHeader, BearerPrefix) {
c.JSON(http.StatusUnauthorized, gin.H{
"code": http.StatusUnauthorized,
"message": "未授权Token 格式错误,需要 Bearer <token>",
})
c.Abort()
return
}
token := strings.TrimPrefix(authHeader, BearerPrefix)
if token == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"code": http.StatusUnauthorized,
"message": "未授权Token 不能为空",
})
c.Abort()
return
}
info, err := authClient.ValidateToken(c.Request.Context(), token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"code": http.StatusUnauthorized,
"message": "Token 验证失败",
})
c.Abort()
return
}
if !info.Valid {
c.JSON(http.StatusUnauthorized, gin.H{
"code": http.StatusUnauthorized,
"message": "Token 无效或已过期",
})
c.Abort()
return
}
c.Set(ContextKeyUserID, info.UserId)
c.Set(ContextKeyUsername, info.Username)
c.Set(ContextKeyEmail, info.Email)
c.Set(ContextKeyRoles, info.Roles)
c.Set(ContextKeyPermissions, info.Permissions)
c.Next()
}
}

View File

@ -0,0 +1,25 @@
package middleware
import (
"rag/file-system/internal/common"
"time"
"github.com/gin-gonic/gin"
)
func LoggingMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
duration := time.Since(start)
common.Logger.Info("request",
"method", c.Request.Method,
"path", c.Request.URL.Path,
"status", c.Writer.Status(),
"duration_ms", duration.Milliseconds(),
"request_id", c.GetString("request_id"),
"client_ip", c.ClientIP(),
)
}
}

View File

@ -0,0 +1,24 @@
package middleware
import (
"net/http"
"sync"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)
func RateLimitMiddleware(rps float64, burst int) gin.HandlerFunc {
var limiters sync.Map
return func(c *gin.Context) {
key := c.ClientIP()
l, _ := limiters.LoadOrStore(key, rate.NewLimiter(rate.Limit(rps), burst))
if !l.(*rate.Limiter).Allow() {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many requests"})
c.Abort()
return
}
c.Next()
}
}

View File

@ -0,0 +1,22 @@
package middleware
import (
"crypto/rand"
"encoding/hex"
"github.com/gin-gonic/gin"
)
func RequestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
b := make([]byte, 8)
rand.Read(b)
requestID = hex.EncodeToString(b)
}
c.Set("request_id", requestID)
c.Header("X-Request-ID", requestID)
c.Next()
}
}

View File

@ -0,0 +1,51 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func init() {
gin.SetMode(gin.TestMode)
}
func TestRequestIDMiddleware_GeneratesID(t *testing.T) {
r := gin.New()
r.Use(RequestIDMiddleware())
r.GET("/test", func(c *gin.Context) {
c.Status(http.StatusOK)
})
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/test", nil)
r.ServeHTTP(w, req)
gotID := w.Header().Get("X-Request-ID")
if gotID == "" {
t.Error("expected X-Request-ID to be generated, got empty string")
}
if len(gotID) != 16 {
t.Errorf("expected 16-char hex ID, got %d chars: %q", len(gotID), gotID)
}
}
func TestRequestIDMiddleware_PreservesExisting(t *testing.T) {
r := gin.New()
r.Use(RequestIDMiddleware())
r.GET("/test", func(c *gin.Context) {
c.Status(http.StatusOK)
})
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("X-Request-ID", "my-custom-id")
r.ServeHTTP(w, req)
gotID := w.Header().Get("X-Request-ID")
if gotID != "my-custom-id" {
t.Errorf("expected X-Request-ID to be preserved as %q, got %q", "my-custom-id", gotID)
}
}

View File

@ -0,0 +1,17 @@
package middleware
import (
"context"
"time"
"github.com/gin-gonic/gin"
)
func TimeoutMiddleware(timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}

BIN
server Executable file

Binary file not shown.

View File

@ -1,606 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RustFS 高级文件管理系统</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Vue 3 -->
<script src="https://cdn.jsdelivr.net/npm/vue@3.3.4/dist/vue.global.prod.js"></script>
<!-- Axios -->
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<!-- Font Awesome -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<!-- Marked.js Markdown 渲染 -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
body { background-color: #f8f9fa; font-family: "Microsoft YaHei", sans-serif; }
.card { border-radius: 10px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); border: none; margin-bottom: 20px; }
.card-header { background-color: #fff; border-bottom: 1px solid #eee; font-weight: bold; padding: 15px 20px; }
.btn-primary { background-color: #0d6efd; border: none; }
.progress { height: 20px; border-radius: 10px; }
.file-icon { font-size: 1.2rem; margin-right: 10px; color: #6c757d; }
.action-btn { cursor: pointer; margin-right: 10px; }
.action-btn:hover { color: #0d6efd; }
[v-cloak] { display: none; }
/* Markdown 预览样式 */
.markdown-body { text-align: left; padding: 20px; max-height: 80vh; overflow-y: auto; }
.markdown-body h1 { font-size: 1.8rem; border-bottom: 1px solid #eee; padding-bottom: 0.3rem; }
.markdown-body h2 { font-size: 1.5rem; border-bottom: 1px solid #eee; padding-bottom: 0.3rem; }
.markdown-body h3 { font-size: 1.3rem; }
.markdown-body pre { background: #f6f8fa; padding: 16px; border-radius: 6px; overflow-x: auto; }
.markdown-body code { background: #f6f8fa; padding: 2px 6px; border-radius: 3px; font-size: 90%; }
.markdown-body pre code { background: none; padding: 0; }
.markdown-body blockquote { border-left: 4px solid #ddd; padding: 0 15px; color: #666; }
.markdown-body table { border-collapse: collapse; width: 100%; margin: 16px 0; }
.markdown-body th, .markdown-body td { border: 1px solid #ddd; padding: 8px 12px; }
.markdown-body th { background: #f6f8fa; }
.markdown-body img { max-width: 100%; }
</style>
</head>
<body>
<div id="app" class="container py-4" v-cloak>
<!-- 未登录状态 -->
<div v-if="!isAuthenticated" class="text-center py-5">
<div class="alert alert-warning d-inline-block">
<i class="fas fa-lock fa-3x mb-3 d-block"></i>
<h4>需要登录</h4>
<p class="mb-3">请先登录以访问文件管理系统</p>
<a href="/web/login.html" class="btn btn-primary btn-lg">
<i class="fas fa-sign-in-alt me-2"></i>前往登录
</a>
</div>
</div>
<!-- 已登录状态 -->
<div v-else>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="fas fa-cloud-upload-alt text-primary me-2"></i>RustFS 文件管理系统</h1>
<div>
<a href="/web/guide.html" target="_blank" class="btn btn-outline-info me-2">
<i class="fas fa-plug me-1"></i>对接指南
</a>
<a href="/swagger/index.html" target="_blank" class="btn btn-outline-secondary me-2">
<i class="fas fa-book me-1"></i>API 文档
</a>
<button class="btn btn-primary me-2" @click="showCreateBucketModal = true">
<i class="fas fa-plus me-1"></i>新建存储桶
</button>
<button class="btn btn-outline-danger" @click="logout">
<i class="fas fa-sign-out-alt me-1"></i>退出
</button>
</div>
</div>
<div class="row">
<!-- Sidebar: Buckets -->
<div class="col-md-3">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
存储桶列表
<button class="btn btn-sm btn-link" @click="loadBuckets"><i class="fas fa-sync-alt"></i></button>
</div>
<div class="list-group list-group-flush">
<div v-for="bucket in buckets" :key="bucket"
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
:class="{ active: currentBucket === bucket }">
<a href="#" class="text-decoration-none flex-grow-1" :class="{ 'text-white': currentBucket === bucket }" @click.prevent="selectBucket(bucket)">
<i class="fas fa-box me-2"></i>{{ bucket }}
</a>
<span class="action-btn" :class="currentBucket === bucket ? 'text-white' : 'text-danger'" @click.stop="deleteBucket(bucket)" title="删除存储桶">
<i class="fas fa-trash-alt"></i>
</span>
</div>
<div v-if="buckets.length === 0" class="list-group-item text-muted text-center py-4">
暂无存储桶
</div>
</div>
</div>
</div>
<!-- Main Content: File List & Upload -->
<div class="col-md-9">
<!-- Toolbar -->
<div class="card mb-3" v-if="currentBucket">
<div class="card-body py-3">
<div class="row g-3 align-items-center">
<div class="col-auto">
<label class="col-form-label fw-bold">{{ currentBucket }}</label>
</div>
<div class="col">
<div class="input-group">
<span class="input-group-text bg-white"><i class="fas fa-search"></i></span>
<input type="text" class="form-control" placeholder="搜索文件名..." v-model="filters.prefix" @keyup.enter="refreshFiles">
</div>
</div>
<div class="col-auto">
<button class="btn btn-success" @click="triggerFileInput">
<i class="fas fa-upload me-1"></i>上传文件
</button>
<input type="file" ref="fileInput" class="d-none" @change="handleFileSelect">
</div>
</div>
</div>
</div>
<!-- File List Table -->
<div class="card" v-if="currentBucket">
<div class="card-body p-0">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th class="ps-4">文件名</th>
<th>大小</th>
<th>修改时间</th>
<th class="text-end pe-4">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="file in files" :key="file.Key">
<td class="ps-4">
<i :class="getFileIcon(file.Key)" class="file-icon"></i>
{{ file.Key }}
</td>
<td>{{ formatSize(file.Size) }}</td>
<td class="text-muted small">{{ formatDate(file.LastModified) }}</td>
<td class="text-end pe-4">
<span class="action-btn text-primary" @click="previewFile(file.Key)" title="预览">
<i class="fas fa-eye"></i>
</span>
<span class="action-btn text-success" @click="downloadFile(file.Key)" title="下载">
<i class="fas fa-download"></i>
</span>
<span class="action-btn text-danger" @click="deleteFile(file.Key)" title="删除">
<i class="fas fa-trash-alt"></i>
</span>
</td>
</tr>
<tr v-if="files.length === 0 && !loadingFiles">
<td colspan="4" class="text-center py-5 text-muted">
<i class="fas fa-folder-open fa-3x mb-3 d-block opacity-50"></i>
暂无文件
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="card-footer d-flex justify-content-between align-items-center" v-if="nextToken || pageHistory.length > 0">
<button class="btn btn-sm btn-outline-secondary" :disabled="pageHistory.length === 0" @click="prevPage">
<i class="fas fa-chevron-left me-1"></i>上一页
</button>
<span class="text-muted small">当前页数: {{ pageHistory.length + 1 }}</span>
<button class="btn btn-sm btn-outline-secondary" :disabled="!nextToken" @click="nextPage">
下一页<i class="fas fa-chevron-right ms-1"></i>
</button>
</div>
</div>
<div v-else class="text-center py-5">
<i class="fas fa-arrow-left fa-3x text-muted mb-3 d-block opacity-25"></i>
<h4 class="text-muted">请选择一个存储桶</h4>
</div>
<!-- Upload Progress -->
<div class="card mt-3" v-if="uploads.length > 0">
<div class="card-header">上传任务队列</div>
<ul class="list-group list-group-flush">
<li class="list-group-item" v-for="upload in uploads" :key="upload.id">
<div class="d-flex justify-content-between mb-1">
<div>
<span class="fw-bold">{{ upload.file.name }}</span>
<span class="badge bg-secondary ms-2">{{ upload.status }}</span>
</div>
<span class="small text-muted">{{ upload.progress }}%</span>
</div>
<div class="progress">
<div class="progress-bar progress-bar-striped progress-bar-animated"
:class="getProgressBarClass(upload.status)"
role="progressbar"
:style="{ width: upload.progress + '%' }"></div>
</div>
<div class="mt-1 small text-danger" v-if="upload.error">{{ upload.error }}</div>
</li>
</ul>
</div>
</div>
</div>
<!-- Create Bucket Modal -->
<div class="modal fade show" style="display: block; background: rgba(0,0,0,0.5)" v-if="showCreateBucketModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">新建存储桶</h5>
<button type="button" class="btn-close" @click="showCreateBucketModal = false"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">存储桶名称</label>
<input type="text" class="form-control" v-model="newBucketName" placeholder="输入名称...">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="showCreateBucketModal = false">取消</button>
<button type="button" class="btn btn-primary" @click="createBucket">创建</button>
</div>
</div>
</div>
</div>
<!-- Preview Modal -->
<div class="modal fade show" style="display: block; background: rgba(0,0,0,0.5)" v-if="previewUrl">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">文件预览</h5>
<button type="button" class="btn-close" @click="previewUrl = null"></button>
</div>
<div class="modal-body bg-light p-4">
<div v-if="isPreviewMarkdown" class="markdown-body" v-html="markdownHtml"></div>
<div v-else class="text-center">
<img v-if="isPreviewImage" :src="previewUrl" class="img-fluid" style="max-height: 80vh">
<video v-else-if="isPreviewVideo" :src="previewUrl" controls class="w-100" style="max-height: 80vh"></video>
<iframe v-else :src="previewUrl" class="w-100" style="height: 60vh; border:none"></iframe>
</div>
</div>
<div class="modal-footer justify-content-center">
<a :href="previewUrl" target="_blank" class="btn btn-outline-primary">
<i class="fas fa-external-link-alt me-1"></i>在新窗口打开
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const { createApp, ref, computed, onMounted } = Vue;
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
createApp({
setup() {
const isAuthenticated = ref(false);
const buckets = ref([]);
const currentBucket = ref(null);
const files = ref([]);
const loadingFiles = ref(false);
const nextToken = ref(null);
const pageHistory = ref([]);
const filters = ref({ prefix: '', maxKeys: 20 });
const showCreateBucketModal = ref(false);
const newBucketName = ref('');
const uploads = ref([]);
const previewUrl = ref(null);
const previewType = ref('');
const markdownHtml = ref('');
// 检查登录状态
const checkAuth = () => {
const token = localStorage.getItem('rustfs_token');
if (token) {
isAuthenticated.value = true;
initApiClient(token);
} else {
isAuthenticated.value = false;
}
};
// 初始化 API 客户端
let api = null;
const initApiClient = (token) => {
api = axios.create({
baseURL: window.location.origin,
headers: {
'X-API-Key': token
}
});
};
// 退出登录
const logout = () => {
if (confirm('确定要退出登录吗?')) {
localStorage.removeItem('rustfs_token');
window.location.href = '/web/login.html';
}
};
// Load Buckets
const loadBuckets = async () => {
try {
const res = await api.get('/buckets');
buckets.value = res.data.buckets || [];
} catch (err) {
if (err.response?.status === 401) {
alert('登录已过期,请重新登录');
localStorage.removeItem('rustfs_token');
window.location.href = '/web/login.html';
} else {
alert('加载存储桶失败: ' + (err.response?.data?.error || err.message));
}
}
};
// Create Bucket
const createBucket = async () => {
if (!newBucketName.value) return alert('请输入名称');
try {
await api.post('/buckets', { bucket_name: newBucketName.value });
await loadBuckets();
showCreateBucketModal.value = false;
newBucketName.value = '';
} catch (err) {
alert('创建失败: ' + (err.response?.data?.error || err.message));
}
};
// Select Bucket
const selectBucket = (name) => {
currentBucket.value = name;
nextToken.value = null;
pageHistory.value = [];
loadFiles();
};
// Load Files
const currentToken = ref(null);
const loadFilesWrapped = async (token) => {
loadingFiles.value = true;
try {
const res = await api.get('/files/list', {
params: {
bucket_name: currentBucket.value,
prefix: filters.value.prefix,
max_keys: filters.value.maxKeys,
token: token
}
});
files.value = res.data.Files || [];
currentToken.value = token;
nextToken.value = res.data.NextContinuationToken;
} catch (err) {
console.error(err);
} finally {
loadingFiles.value = false;
}
}
const loadFiles = () => loadFilesWrapped(null);
const refreshFiles = () => {
nextToken.value = null;
pageHistory.value = [];
loadFiles();
};
const nextP = () => {
if(!nextToken.value) return;
pageHistory.value.push(currentToken.value);
loadFilesWrapped(nextToken.value);
}
const prevPage = () => {
if(pageHistory.value.length === 0) return;
const prevToken = pageHistory.value.pop();
loadFilesWrapped(prevToken);
}
// File Upload
const triggerFileInput = () => document.querySelector('input[type=file]').click();
const handleFileSelect = async (e) => {
const file = e.target.files[0];
if (!file) return;
e.target.value = '';
const uploadTask = {
id: Date.now(),
file: file,
progress: 0,
status: 'pending',
error: null,
uploadId: null,
parts: []
};
uploads.value.unshift(uploadTask);
processUpload(uploadTask);
};
const processUpload = async (task) => {
task.status = 'uploading';
const file = task.file;
const bucket = currentBucket.value;
const key = file.name;
try {
const initRes = await api.post('/files/multipart/init', {
bucket_name: bucket,
object_key: key
});
task.uploadId = initRes.data.upload_id;
const totalParts = Math.ceil(file.size / CHUNK_SIZE);
const parts = [];
for (let i = 0; i < totalParts; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const partNumber = i + 1;
const formData = new FormData();
formData.append('bucket_name', bucket);
formData.append('object_key', key);
formData.append('upload_id', task.uploadId);
formData.append('part_number', partNumber);
formData.append('file', chunk);
let retries = 3;
let etag = null;
while(retries > 0) {
try {
const partRes = await api.put('/files/multipart/part', formData);
etag = partRes.data.etag;
break;
} catch(e) {
retries--;
if(retries === 0) throw e;
await new Promise(r => setTimeout(r, 1000));
}
}
parts.push({ PartNumber: partNumber, ETag: etag });
task.progress = Math.round(((i + 1) / totalParts) * 100);
}
await api.post('/files/multipart/complete', {
bucket_name: bucket,
object_key: key,
upload_id: task.uploadId,
parts: parts
});
task.status = 'completed';
task.progress = 100;
setTimeout(() => refreshFiles(), 1000);
} catch (err) {
console.error(err);
task.status = 'failed';
task.error = err.response?.data?.error || err.message;
}
};
// Preview
const previewFile = async (key) => {
try {
const ext = key.split('.').pop().toLowerCase();
if (['md', 'markdown'].includes(ext)) {
// Markdown 文件:通过后端接口获取文本内容,前端渲染
previewUrl.value = 'loading';
previewType.value = 'markdown';
const res = await api.get('/files/content', {
params: { bucket_name: currentBucket.value, object_key: key }
});
markdownHtml.value = marked.parse(res.data.content);
} else {
// 其他文件:获取 presigned URL 预览
const res = await api.get('/files/preview', {
params: { bucket_name: currentBucket.value, object_key: key }
});
previewUrl.value = res.data.url;
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) {
previewType.value = 'image';
} else if (['mp4', 'webm', 'ogg'].includes(ext)) {
previewType.value = 'video';
} else {
previewType.value = 'other';
}
}
} catch (err) {
previewUrl.value = null;
alert('无法获取预览内容');
}
};
const isPreviewImage = computed(() => previewType.value === 'image');
const isPreviewVideo = computed(() => previewType.value === 'video');
const isPreviewMarkdown = computed(() => previewType.value === 'markdown');
// Download
const downloadFile = (key) => {
const url = `${window.location.origin}/files/download?bucket_name=${encodeURIComponent(currentBucket.value)}&object_key=${encodeURIComponent(key)}`;
window.open(url, '_blank');
};
// Delete File
const deleteFile = async (key) => {
if (!confirm(`确定要删除文件 "${key}" 吗?此操作不可恢复!`)) return;
try {
await api.delete('/files/delete', {
data: { bucket_name: currentBucket.value, object_key: key }
});
loadFilesWrapped(currentToken.value);
} catch (err) {
alert('删除失败: ' + (err.response?.data?.error || err.message));
}
};
// Delete Bucket
const deleteBucket = async (bucketName) => {
if (!confirm(`确定要删除存储桶 "${bucketName}" 吗?\n注意存储桶必须为空才能删除`)) return;
try {
await api.delete('/buckets', {
data: { bucket_name: bucketName }
});
if (currentBucket.value === bucketName) {
currentBucket.value = null;
files.value = [];
}
await loadBuckets();
} catch (err) {
alert('删除存储桶失败: ' + (err.response?.data?.error || err.message));
}
};
// Utils
const formatSize = (bytes) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatDate = (dateStr) => {
return new Date(dateStr).toLocaleString('zh-CN');
};
const getFileIcon = (filename) => {
const ext = filename.split('.').pop().toLowerCase();
if (['jpg', 'png', 'gif'].includes(ext)) return 'fas fa-file-image text-primary';
if (['pdf', 'doc', 'docx'].includes(ext)) return 'fas fa-file-pdf text-danger';
if (['mp4', 'avi'].includes(ext)) return 'fas fa-file-video text-success';
if (['zip', 'rar'].includes(ext)) return 'fas fa-file-archive text-warning';
if (['md', 'markdown'].includes(ext)) return 'fas fa-file-code text-info';
return 'fas fa-file text-secondary';
};
const getProgressBarClass = (status) => {
if(status === 'completed') return 'bg-success';
if(status === 'failed') return 'bg-danger';
return 'bg-primary';
}
onMounted(() => {
checkAuth();
if (isAuthenticated.value) {
loadBuckets();
}
});
return {
isAuthenticated,
buckets, currentBucket, files, loadingFiles, nextToken, pageHistory, filters,
showCreateBucketModal, newBucketName, uploads, previewUrl, isPreviewImage, isPreviewVideo, isPreviewMarkdown, markdownHtml,
logout,
loadBuckets, createBucket, selectBucket, deleteBucket, refreshFiles,
nextPage: nextP, prevPage,
triggerFileInput, handleFileSelect,
previewFile, downloadFile, deleteFile,
formatSize, formatDate, getFileIcon, getProgressBarClass
};
}
}).mount('#app');
</script>
</body>
</html>

View File

@ -1,212 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RustFS - 用户登录</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: "Microsoft YaHei", sans-serif;
}
.login-card {
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
padding: 40px;
width: 100%;
max-width: 450px;
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-header i {
font-size: 3rem;
color: #667eea;
margin-bottom: 15px;
}
.login-header h2 {
color: #333;
font-weight: bold;
margin-bottom: 10px;
}
.login-header p {
color: #666;
font-size: 0.95rem;
}
.form-control {
border-radius: 10px;
padding: 12px 15px;
border: 2px solid #e0e0e0;
transition: all 0.3s;
}
.form-control:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
}
.input-group-text {
background: white;
border: 2px solid #e0e0e0;
border-right: none;
border-radius: 10px 0 0 10px;
}
.input-group .form-control {
border-left: none;
border-radius: 0 10px 10px 0;
}
.input-group .form-control:focus {
border-color: #667eea;
}
.btn-login {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 10px;
padding: 12px;
font-weight: bold;
color: white;
width: 100%;
transition: transform 0.2s;
}
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
color: white;
}
.btn-login:disabled {
background: #ccc;
transform: none;
}
.alert {
border-radius: 10px;
display: none;
}
.footer {
text-align: center;
margin-top: 25px;
color: #999;
font-size: 0.85rem;
}
[v-cloak] { display: none; }
</style>
</head>
<body>
<div id="app" class="container" v-cloak>
<div class="login-card">
<div class="login-header">
<i class="fas fa-cloud"></i>
<h2>RustFS 文件管理</h2>
<p>请输入 API 密钥以访问系统</p>
</div>
<div class="alert alert-danger" role="alert" :style="{ display: showError ? 'block' : 'none' }">
<i class="fas fa-exclamation-circle me-2"></i>{{ errorMessage }}
</div>
<form @submit.prevent="login">
<div class="mb-4">
<label class="form-label fw-bold">API 密钥</label>
<div class="input-group">
<span class="input-group-text">
<i class="fas fa-key"></i>
</span>
<input
type="password"
class="form-control"
placeholder="请输入 API 密钥"
v-model="apiKey"
:disabled="loading"
required
autofocus
>
</div>
<div class="form-text text-muted mt-2">
<i class="fas fa-info-circle me-1"></i>
请输入管理员分配的 API 密钥
</div>
</div>
<button type="submit" class="btn btn-login" :disabled="loading">
<span v-if="loading">
<span class="spinner-border spinner-border-sm me-2"></span>
登录中...
</span>
<span v-else>
<i class="fas fa-sign-in-alt me-2"></i>登 录
</span>
</button>
</form>
<div class="footer">
<p class="mb-1">Powered by RustFS & Gin</p>
<a href="/swagger/index.html" target="_blank" class="text-decoration-none">
<i class="fas fa-book me-1"></i>API 文档
</a>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@3.3.4/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
const { createApp, ref } = Vue;
createApp({
setup() {
const apiKey = ref('');
const loading = ref(false);
const showError = ref(false);
const errorMessage = ref('');
const login = async () => {
if (!apiKey.value.trim()) {
showError.value = true;
errorMessage.value = '请输入 API 密钥';
return;
}
loading.value = true;
showError.value = false;
try {
const res = await axios.post('/auth/login', {
api_key: apiKey.value
});
if (res.data.success) {
// 保存 token 到 localStorage
localStorage.setItem('rustfs_token', res.data.token);
// 跳转到主页面
window.location.href = '/web/';
} else {
showError.value = true;
errorMessage.value = res.data.message || '登录失败';
}
} catch (err) {
showError.value = true;
errorMessage.value = err.response?.data?.error || err.message || '登录请求失败';
} finally {
loading.value = false;
}
};
return {
apiKey,
loading,
showError,
errorMessage,
login
};
}
}).mount('#app');
</script>
</body>
</html>