Browse Source

feat: 添加文件上传接口用于存储用户头像

BaiLuoYan 4 days ago
parent
commit
c5fab9a9bc

+ 17 - 0
etc/perm-api-dev.yaml

@@ -33,3 +33,20 @@ Capjs:
   EndpointURL: "https://cap.znomo.com"
   Key: "c1c9be8c3f"
   Secret: "chC9e2jTTtPCxNv8gBFa18DjpkpUoQc706dZCLZ4nc1LZGQGpZLw"
+
+Minio:
+  Name: "minio-perms"
+  AccessKeyId: "S4ac2AQ6t4QQrJjDOS4X"
+  AccessKeySecret: "QJUMpcteib326jwXWv35WcLp0AhT7vOJpduIUjPq"
+  Endpoint: "minio-endpoint.znomo.com"
+  Domain: "https://minio-endpoint.znomo.com"
+  UseSSL: true
+  FileType:
+    avatar:
+      Bucket: "perms-system"
+      Dir: "avatar/{yyyy}/{mm}/{dd}"
+      AllowedContentTypes:
+        - "image/jpeg"
+        - "image/png"
+        - "image/gif"
+        - "image/webp"

+ 17 - 0
etc/perm-api-prod.yaml

@@ -33,3 +33,20 @@ Capjs:
   EndpointURL: "https://cap.znomo.com"
   Key: "c1c9be8c3f"
   Secret: "chC9e2jTTtPCxNv8gBFa18DjpkpUoQc706dZCLZ4nc1LZGQGpZLw"
+
+Minio:
+  Name: "minio-perms"
+  AccessKeyId: "S4ac2AQ6t4QQrJjDOS4X"
+  AccessKeySecret: "QJUMpcteib326jwXWv35WcLp0AhT7vOJpduIUjPq"
+  Endpoint: "minio-endpoint.znomo.com"
+  Domain: "https://minio-endpoint.znomo.com"
+  UseSSL: true
+  FileType:
+    avatar:
+      Bucket: "perms-system"
+      Dir: "avatar/{yyyy}/{mm}/{dd}"
+      AllowedContentTypes:
+        - "image/jpeg"
+        - "image/png"
+        - "image/gif"
+        - "image/webp"

+ 17 - 0
etc/perm-api-test.yaml

@@ -33,3 +33,20 @@ Capjs:
   EndpointURL: "https://cap.znomo.com"
   Key: "c1c9be8c3f"
   Secret: "chC9e2jTTtPCxNv8gBFa18DjpkpUoQc706dZCLZ4nc1LZGQGpZLw"
+
+Minio:
+  Name: "minio-perms"
+  AccessKeyId: "S4ac2AQ6t4QQrJjDOS4X"
+  AccessKeySecret: "QJUMpcteib326jwXWv35WcLp0AhT7vOJpduIUjPq"
+  Endpoint: "minio-endpoint.znomo.com"
+  Domain: "https://minio-endpoint.znomo.com"
+  UseSSL: true
+  FileType:
+    avatar:
+      Bucket: "perms-system"
+      Dir: "avatar/{yyyy}/{mm}/{dd}"
+      AllowedContentTypes:
+        - "image/jpeg"
+        - "image/png"
+        - "image/gif"
+        - "image/webp"

+ 12 - 1
go.mod

@@ -24,9 +24,11 @@ require (
 	github.com/coreos/go-systemd/v22 v22.5.0 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
+	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/emicklei/go-restful/v3 v3.12.2 // indirect
 	github.com/fatih/color v1.18.0 // indirect
 	github.com/fxamacker/cbor/v2 v2.9.0 // indirect
+	github.com/go-ini/ini v1.67.0 // indirect
 	github.com/go-logr/logr v1.4.3 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/go-openapi/jsonpointer v0.21.0 // indirect
@@ -43,16 +45,22 @@ require (
 	github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect
 	github.com/josharian/intern v1.0.0 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
-	github.com/klauspost/compress v1.18.0 // indirect
+	github.com/klauspost/compress v1.18.2 // indirect
+	github.com/klauspost/cpuid/v2 v2.2.11 // indirect
+	github.com/klauspost/crc32 v1.3.0 // indirect
 	github.com/mailru/easyjson v0.7.7 // indirect
 	github.com/mattn/go-colorable v0.1.13 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/minio/crc64nvme v1.1.1 // indirect
+	github.com/minio/md5-simd v1.1.2 // indirect
+	github.com/minio/minio-go/v7 v7.1.0 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
 	github.com/mojocn/base64Captcha v1.3.8 // indirect
 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
 	github.com/openzipkin/zipkin-go v0.4.3 // indirect
 	github.com/pelletier/go-toml/v2 v2.3.0 // indirect
+	github.com/philhofer/fwd v1.2.0 // indirect
 	github.com/pkg/errors v0.9.1 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/prometheus/client_golang v1.23.2 // indirect
@@ -60,10 +68,13 @@ require (
 	github.com/prometheus/common v0.66.1 // indirect
 	github.com/prometheus/procfs v0.16.1 // indirect
 	github.com/redis/go-redis/v9 v9.18.0 // indirect
+	github.com/rs/xid v1.6.0 // indirect
 	github.com/spaolacci/murmur3 v1.1.0 // indirect
+	github.com/tinylib/msgp v1.6.1 // indirect
 	github.com/titanous/json5 v1.0.0 // indirect
 	github.com/x448/float16 v0.8.4 // indirect
 	github.com/yuin/gopher-lua v1.1.1 // indirect
+	github.com/zeebo/xxh3 v1.1.0 // indirect
 	go.etcd.io/etcd/api/v3 v3.5.21 // indirect
 	go.etcd.io/etcd/client/pkg/v3 v3.5.21 // indirect
 	go.etcd.io/etcd/client/v3 v3.5.21 // indirect

+ 25 - 0
go.sum

@@ -40,6 +40,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
 github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/eapache/go-resiliency v1.6.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho=
 github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0=
 github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
@@ -54,6 +56,8 @@ github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/
 github.com/fullstorydev/grpcurl v1.9.3/go.mod h1:/b4Wxe8bG6ndAjlfSUjwseQReUDUvBJiFEB7UllOlUE=
 github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
 github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
+github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
+github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
 github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
@@ -133,8 +137,15 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
 github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
 github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
+github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
+github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
 github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
 github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
+github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
+github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
 github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -153,6 +164,12 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
+github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
+github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
+github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
+github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
+github.com/minio/minio-go/v7 v7.1.0 h1:QEt5IStDpxgGjEdtOgpiZ5QhmSl3ax7qy61vi2SwHO8=
+github.com/minio/minio-go/v7 v7.1.0/go.mod h1:Dm7WS1AgLmBa0NcQD6SeJnJf+K/EUW3GR7Ks6olB3OA=
 github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
 github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -177,6 +194,8 @@ github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf4
 github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
 github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
 github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
+github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
+github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
 github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -203,6 +222,8 @@ github.com/robertkrimen/otto v0.2.1/go.mod h1:UPwtJ1Xu7JrLcZjNWN8orJaM5n5YEtqL//
 github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
+github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
 github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
 github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
@@ -221,6 +242,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
+github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
 github.com/titanous/json5 v1.0.0 h1:hJf8Su1d9NuI/ffpxgxQfxh/UiBFZX7bMPid0rIL/7s=
 github.com/titanous/json5 v1.0.0/go.mod h1:7JH1M8/LHKc6cyP5o5g3CSaRj+mBrIimTxzpvmckH8c=
 github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -238,6 +261,8 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M
 github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
 github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
 github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
+github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
+github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
 github.com/zeromicro/go-zero v1.10.1 h1:1nM3ilvYx97GUqyaNH2IQPtfNyK7tp5JvN63c7m6QKU=
 github.com/zeromicro/go-zero v1.10.1/go.mod h1:z41DXmO6gx/Se7Ow5UIwPxcUmpVj3ebhoNCcZ1gfp5k=
 go.etcd.io/etcd/api/v3 v3.5.21 h1:A6O2/JDb3tvHhiIz3xf9nJ7REHvtEFJJ3veW3FbCnS8=

+ 18 - 0
internal/config/config.go

@@ -18,6 +18,22 @@ type CapjsConf struct {
 	Secret      string `json:",optional"` // cap.js site secret(用于服务端 siteverify)
 }
 
+type MinioFileTypeConf struct {
+	Bucket              string
+	Dir                 string
+	AllowedContentTypes []string `json:",optional"`
+}
+
+type MinioConf struct {
+	Name            string
+	AccessKeyId     string
+	AccessKeySecret string
+	Endpoint        string
+	Domain          string
+	UseSSL          bool                        `json:",default=false"`
+	FileType        map[string]MinioFileTypeConf `json:",optional"`
+}
+
 type Config struct {
 	rest.RestConf
 	RpcServerConf zrpc.RpcServerConf
@@ -38,6 +54,8 @@ type Config struct {
 
 	Capjs CapjsConf `json:",optional"`
 
+	Minio MinioConf `json:",optional"`
+
 	BehindProxy bool `json:",optional"`
 }
 

+ 39 - 0
internal/handler/auth/minioUploadHandler.go

@@ -0,0 +1,39 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package auth
+
+import (
+	"net/http"
+
+	"perms-system-server/internal/logic/auth"
+	"perms-system-server/internal/svc"
+
+	"github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func MinioUploadHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		if err := r.ParseMultipartForm(32 << 20); err != nil {
+			httpx.ErrorCtx(r.Context(), w, err)
+			return
+		}
+
+		file, fileHeader, err := r.FormFile("file")
+		if err != nil {
+			httpx.ErrorCtx(r.Context(), w, err)
+			return
+		}
+		defer file.Close()
+
+		fileType := r.FormValue("fileType")
+
+		l := auth.NewMinioUploadLogic(r.Context(), svcCtx)
+		result, err := l.MinioUpload(fileHeader, file, fileType)
+		if err != nil {
+			httpx.ErrorCtx(r.Context(), w, err)
+			return
+		}
+		httpx.OkJsonCtx(r.Context(), w, result)
+	}
+}

+ 5 - 0
internal/handler/routes.go

@@ -44,6 +44,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
 					Path:    "/auth/userInfo",
 					Handler: auth.UserInfoHandler(serverCtx),
 				},
+				{
+					Method:  http.MethodPost,
+					Path:    "/minio/upload",
+					Handler: auth.MinioUploadHandler(serverCtx),
+				},
 			}...,
 		),
 		rest.WithPrefix("/api"),

+ 143 - 0
internal/logic/auth/minioUploadLogic.go

@@ -0,0 +1,143 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package auth
+
+import (
+	"context"
+	"crypto/md5"
+	"encoding/hex"
+	"fmt"
+	"io"
+	"mime/multipart"
+	"path/filepath"
+	"slices"
+	"strings"
+	"time"
+
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+
+	"github.com/minio/minio-go/v7"
+	"github.com/zeromicro/go-zero/core/logx"
+)
+
+type MinioUploadResult struct {
+	Url  string `json:"url"`
+	Path string `json:"path"`
+	Md5  string `json:"md5"`
+	Size int64  `json:"size"`
+}
+
+type MinioUploadLogic struct {
+	logx.Logger
+	ctx    context.Context
+	svcCtx *svc.ServiceContext
+}
+
+func NewMinioUploadLogic(ctx context.Context, svcCtx *svc.ServiceContext) *MinioUploadLogic {
+	return &MinioUploadLogic{
+		Logger: logx.WithContext(ctx),
+		ctx:    ctx,
+		svcCtx: svcCtx,
+	}
+}
+
+func (l *MinioUploadLogic) MinioUpload(fileHeader *multipart.FileHeader, file multipart.File, fileType string) (*MinioUploadResult, error) {
+	if fileType == "" {
+		return nil, response.ErrBadRequest("fileType is required")
+	}
+
+	minioFileType, ok := l.svcCtx.Config.Minio.FileType[fileType]
+	if !ok {
+		return nil, response.ErrBadRequest("fileType not configured")
+	}
+
+	if minioFileType.Bucket == "" {
+		return nil, response.ErrBadRequest("bucket not configured")
+	}
+
+	contentType := fileHeader.Header.Get("Content-Type")
+
+	if len(minioFileType.AllowedContentTypes) > 0 && minioFileType.AllowedContentTypes[0] != "" && minioFileType.AllowedContentTypes[0] != "*" {
+		if !slices.Contains(minioFileType.AllowedContentTypes, contentType) {
+			return nil, response.ErrBadRequest("invalid contentType: " + contentType)
+		}
+	}
+
+	if err := l.ensureBucketExists(minioFileType.Bucket); err != nil {
+		return nil, err
+	}
+
+	src, err := fileHeader.Open()
+	if err != nil {
+		return nil, err
+	}
+	defer src.Close()
+
+	hash := md5.New()
+	if _, err := io.Copy(hash, src); err != nil {
+		return nil, response.ErrBadRequest("md5 计算失败: " + err.Error())
+	}
+	fileMd5 := hex.EncodeToString(hash.Sum(nil))
+	src.Seek(0, io.SeekStart)
+
+	fileExt := filepath.Ext(fileHeader.Filename)
+	dir := strings.TrimSpace(parseDir(minioFileType.Dir))
+	if dir != "" {
+		dir += "/"
+	}
+	objectPath := fmt.Sprintf("%s%s%s", dir, fileMd5, fileExt)
+
+	stat, statErr := l.svcCtx.MinioClient.StatObject(l.ctx, minioFileType.Bucket, objectPath, minio.StatObjectOptions{})
+	if statErr == nil {
+		objectFullPath := fmt.Sprintf("%s/%s", minioFileType.Bucket, objectPath)
+		return &MinioUploadResult{
+			Url:  fmt.Sprintf("%s/%s", l.svcCtx.Config.Minio.Domain, objectFullPath),
+			Path: objectFullPath,
+			Md5:  fileMd5,
+			Size: stat.Size,
+		}, nil
+	}
+
+	errCode := minio.ToErrorResponse(statErr).Code
+	if errCode == "AccessDenied" || errCode == "NoSuchKey" {
+		info, err := l.svcCtx.MinioClient.PutObject(l.ctx, minioFileType.Bucket, objectPath, src, fileHeader.Size, minio.PutObjectOptions{
+			ContentType: contentType,
+		})
+		if err != nil {
+			return nil, err
+		}
+
+		objectFullPath := fmt.Sprintf("%s/%s", minioFileType.Bucket, objectPath)
+		return &MinioUploadResult{
+			Url:  fmt.Sprintf("%s/%s", l.svcCtx.Config.Minio.Domain, objectFullPath),
+			Path: objectFullPath,
+			Md5:  fileMd5,
+			Size: info.Size,
+		}, nil
+	}
+
+	return nil, statErr
+}
+
+func (l *MinioUploadLogic) ensureBucketExists(bucket string) error {
+	exists, err := l.svcCtx.MinioClient.BucketExists(l.ctx, bucket)
+	if err != nil {
+		return err
+	}
+	if !exists {
+		return l.svcCtx.MinioClient.MakeBucket(l.ctx, bucket, minio.MakeBucketOptions{})
+	}
+	return nil
+}
+
+func parseDir(template string) string {
+	now := time.Now()
+	r := strings.NewReplacer(
+		"{yyyy}", now.Format("2006"),
+		"{mm}", now.Format("01"),
+		"{dd}", now.Format("02"),
+	)
+	return r.Replace(template)
+}

+ 17 - 0
internal/svc/servicecontext.go

@@ -6,7 +6,10 @@ import (
 	"perms-system-server/internal/middleware"
 	"perms-system-server/internal/model"
 
+	"github.com/minio/minio-go/v7"
+	"github.com/minio/minio-go/v7/pkg/credentials"
 	"github.com/zeromicro/go-zero/core/limit"
+	"github.com/zeromicro/go-zero/core/logx"
 	"github.com/zeromicro/go-zero/core/stores/redis"
 	"github.com/zeromicro/go-zero/core/stores/sqlx"
 	"github.com/zeromicro/go-zero/rest"
@@ -28,6 +31,7 @@ type ServiceContext struct {
 	TokenOpLimiter        *limit.PeriodLimit
 	UserDetailsLoader     *loaders.UserDetailsLoader
 	Redis                 *redis.Redis
+	MinioClient           *minio.Client
 	*model.Models
 }
 
@@ -55,6 +59,18 @@ func NewServiceContext(c config.Config) *ServiceContext {
 	usernameLimiter := limit.NewPeriodLimit(300, 10, rds, c.CacheRedis.KeyPrefix+":rl:user")
 	tokenOpLimiter := limit.NewPeriodLimit(60, 10, rds, c.CacheRedis.KeyPrefix+":rl:tokenop")
 
+	var minioClient *minio.Client
+	if c.Minio.Endpoint != "" {
+		var err error
+		minioClient, err = minio.New(c.Minio.Endpoint, &minio.Options{
+			Creds:  credentials.NewStaticV4(c.Minio.AccessKeyId, c.Minio.AccessKeySecret, ""),
+			Secure: c.Minio.UseSSL,
+		})
+		if err != nil {
+			logx.Must(err)
+		}
+	}
+
 	return &ServiceContext{
 		Config:                c,
 		JwtAuth:               middleware.NewJwtAuthMiddleware(c.Auth.AccessSecret, udLoader).Handle,
@@ -71,6 +87,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
 		TokenOpLimiter:        tokenOpLimiter,
 		UserDetailsLoader:     udLoader,
 		Redis:                 rds,
+		MinioClient:           minioClient,
 		Models:                models,
 	}
 }

+ 4 - 0
perm.api

@@ -445,6 +445,10 @@ service perm-api {
 	// UpdateSelfInfo 修改当前登录用户自身信息。仅允许修改昵称、头像、邮箱、手机,userId 从 JWT 获取
 	@handler UpdateSelfInfo
 	post /auth/updateInfo (UpdateSelfInfoReq)
+
+	// MinioUpload 文件上传。上传文件到 MinIO 对象存储,根据 fileType 参数路由到对应 bucket 和目录
+	@handler MinioUpload
+	post /minio/upload
 }
 
 // 产品管理(仅超管可操作)