合并 internal → main: 退款/提现/激活/CI 全面优化 #4

Open
shanshanzhong147 wants to merge 76 commits from internal into main
5 changed files with 100 additions and 3 deletions
Showing only changes of commit f452f80100 - Show all commits
+6
View File
@@ -128,6 +128,10 @@ curl -X PUT 'https://bucket.s3.ap-east-1.amazonaws.com/...' \
说明:
- `Content-Type` 需和 `init` 返回的 `headers.Content-Type` 一致
- 允许的 `Content-Type` 由服务端 `S3.AllowedContentTypes` 配置控制,默认包含:
- 压缩包:`application/zip``application/x-zip-compressed``application/gzip``application/x-gzip`
- 通用文件:`application/octet-stream``text/plain``application/json`
- 图片:`image/jpeg``image/jpg``image/png``image/webp``image/gif``image/heic``image/heif``image/bmp`
- `upload_url` 有过期时间,通常 300 秒
- 成功时 S3 常见返回 `200``204`
@@ -164,11 +168,13 @@ curl -X POST 'https://tapi.hifast.biz/v1/public/file/upload/complete' \
- 如果请求带了 `X-App-Id`,就按现有逻辑验签
- 如果没有 `X-App-Id`,仍按旧逻辑放行
- 如果要给该接口加签,签名时必须对原始 multipart body 计算 `BODY_SHA256`
- 允许的 `Content-Type` 与预签名三段式一致;multipart 文件字段未显式携带 `Content-Type` 时,服务端会基于文件内容嗅探常见类型。
## 常见错误码
- `200`: 成功
- `400`: 参数错误
- `400 content_type is not allowed`: 文件 `Content-Type` 不在 `S3.AllowedContentTypes` 白名单内
- `40008`: 缺少签名头
- `40009`: 签名已过期
- `40010`: 签名无效
+1 -1
View File
@@ -74,7 +74,7 @@ S3:
UsePathStyle: false
PresignExpireSeconds: 300
MaxUploadSize: 104857600
AllowedContentTypes: "application/zip,application/x-zip-compressed,application/gzip,application/x-gzip,application/octet-stream,text/plain,application/json"
AllowedContentTypes: "application/zip,application/x-zip-compressed,application/gzip,application/x-gzip,application/octet-stream,text/plain,application/json,image/jpeg,image/jpg,image/png,image/webp,image/gif,image/heic,image/heif,image/bmp"
device:
enable: true # 开启设备加密通信
+1 -1
View File
@@ -58,7 +58,7 @@ type S3Config struct {
UsePathStyle bool `yaml:"UsePathStyle" default:"false"`
PresignExpireSeconds int64 `yaml:"PresignExpireSeconds" default:"300"`
MaxUploadSize int64 `yaml:"MaxUploadSize" default:"104857600"`
AllowedContentTypes string `yaml:"AllowedContentTypes" default:"application/zip,application/x-zip-compressed,application/gzip,application/x-gzip,application/octet-stream,text/plain,application/json"`
AllowedContentTypes string `yaml:"AllowedContentTypes" default:"application/zip,application/x-zip-compressed,application/gzip,application/x-gzip,application/octet-stream,text/plain,application/json,image/jpeg,image/jpg,image/png,image/webp,image/gif,image/heic,image/heif,image/bmp"`
}
type RedisConfig struct {
+1 -1
View File
@@ -80,7 +80,7 @@ func validateInitRequest(svcCtx *svc.ServiceContext, bizType, fileName, contentT
allowed := allowedContentTypeSet(svcCtx.Config.S3.AllowedContentTypes)
if len(allowed) > 0 {
if _, ok := allowed[strings.ToLower(strings.TrimSpace(contentType))]; !ok {
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "content_type is not allowed")
return errors.Wrapf(xerr.NewErrCodeMsg(xerr.InvalidParams, "content_type is not allowed"), "content_type is not allowed")
}
}
return nil
+91
View File
@@ -0,0 +1,91 @@
package file
import (
"mime/multipart"
"strings"
"testing"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/storage"
)
const testAllowedContentTypes = "application/zip,application/x-zip-compressed,application/gzip,application/x-gzip,application/octet-stream,text/plain,application/json,image/jpeg,image/jpg,image/png,image/webp,image/gif,image/heic,image/heif,image/bmp"
type testMultipartFile struct {
*strings.Reader
}
func (testMultipartFile) Close() error {
return nil
}
func TestValidateInitRequestAllowedContentTypes(t *testing.T) {
svcCtx := &svc.ServiceContext{
Config: config.Config{
S3: config.S3Config{
Enable: true,
MaxUploadSize: 1024,
AllowedContentTypes: testAllowedContentTypes,
},
},
S3Store: &storage.S3Store{},
}
tests := []struct {
name string
contentType string
wantErr bool
}{
{name: "allow jpeg", contentType: "image/jpeg"},
{name: "allow png", contentType: "image/png"},
{name: "allow webp", contentType: "image/webp"},
{name: "reject unknown", contentType: "application/x-sh", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateInitRequest(svcCtx, "app-package", "demo.bin", tt.contentType, 10)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "content_type is not allowed") {
t.Fatalf("expected content type error, got %v", err)
}
return
}
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
})
}
}
func TestSniffContentTypeDetectsCommonImages(t *testing.T) {
tests := []struct {
name string
data string
want string
}{
{name: "jpeg", data: "\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01", want: "image/jpeg"},
{name: "png", data: "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR", want: "image/png"},
{name: "webp", data: "RIFF\x1a\x00\x00\x00WEBPVP8 \x0e\x00\x00\x00", want: "image/webp"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
file := testMultipartFile{Reader: strings.NewReader(tt.data)}
got, err := sniffContentType(&multipart.FileHeader{}, file)
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if got != tt.want {
t.Fatalf("expected %q, got %q", tt.want, got)
}
if pos, err := file.Seek(0, 1); err != nil || pos != 0 {
t.Fatalf("expected reader reset to start, pos=%d err=%v", pos, err)
}
})
}
}