- Changed API base URLs in app.js to point to the production server for deployment. - Updated the my page UI by refining labels and removing unnecessary text for a cleaner look. - Adjusted CSS styles to enhance layout and reduce whitespace, improving overall user experience. This update aims to ensure proper API connectivity and enhance the visual presentation of the user interface.
482 lines
14 KiB
Go
482 lines
14 KiB
Go
package oss
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/url"
|
||
"strings"
|
||
"sync"
|
||
|
||
"soul-api/internal/config"
|
||
"soul-api/internal/database"
|
||
"soul-api/internal/model"
|
||
|
||
alioss "github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||
)
|
||
|
||
// Config 阿里云 OSS 配置,与管理端 ossConfig 字段对应
|
||
type Config struct {
|
||
Endpoint string `json:"endpoint"`
|
||
Bucket string `json:"bucket"`
|
||
Region string `json:"region"`
|
||
AccessKeyID string `json:"accessKeyId"`
|
||
AccessKeySecret string `json:"accessKeySecret"`
|
||
// PublicBaseURL 可选:CDN 或绑定在 Bucket 上的自定义域名(含 https://),上传接口返回的链接将用该前缀 + objectKey
|
||
PublicBaseURL string `json:"publicBaseUrl"`
|
||
}
|
||
|
||
func ossSecretIsPlaceholder(s string) bool {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" || s == "****" {
|
||
return true
|
||
}
|
||
return strings.Trim(s, "*") == ""
|
||
}
|
||
|
||
// inferEndpointFromRegion 管理端 Region 常为 cn-hangzhou 或 oss-cn-hangzhou,未填 Endpoint 时补全为公网接入域名
|
||
func inferEndpointFromRegion(region string) string {
|
||
r := strings.TrimSpace(strings.ToLower(region))
|
||
if r == "" {
|
||
return ""
|
||
}
|
||
r = strings.TrimPrefix(strings.TrimPrefix(r, "https://"), "http://")
|
||
// 已填成完整 endpoint
|
||
if strings.Contains(r, ".aliyuncs.com") {
|
||
return strings.TrimPrefix(strings.TrimPrefix(r, "https://"), "http://")
|
||
}
|
||
if !strings.HasPrefix(r, "oss-") {
|
||
r = "oss-" + r
|
||
}
|
||
return r + ".aliyuncs.com"
|
||
}
|
||
|
||
func pickStr(m map[string]interface{}, keys ...string) string {
|
||
for _, k := range keys {
|
||
v, ok := m[k]
|
||
if !ok || v == nil {
|
||
continue
|
||
}
|
||
switch t := v.(type) {
|
||
case string:
|
||
s := strings.TrimSpace(t)
|
||
if s != "" {
|
||
return s
|
||
}
|
||
case []byte:
|
||
s := strings.TrimSpace(string(t))
|
||
if s != "" {
|
||
return s
|
||
}
|
||
case json.Number:
|
||
s := strings.TrimSpace(t.String())
|
||
if s != "" {
|
||
return s
|
||
}
|
||
default:
|
||
s := strings.TrimSpace(fmt.Sprint(t))
|
||
if s != "" && s != "<nil>" {
|
||
return s
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func configFromMap(m map[string]interface{}) Config {
|
||
if m == nil {
|
||
return Config{}
|
||
}
|
||
return Config{
|
||
Endpoint: pickStr(m, "endpoint", "Endpoint"),
|
||
Bucket: pickStr(m, "bucket", "Bucket"),
|
||
Region: pickStr(m, "region", "Region"),
|
||
AccessKeyID: pickStr(m, "accessKeyId", "accessKeyID", "AccessKeyId", "access_key_id", "AccessKeyID"),
|
||
AccessKeySecret: pickStr(m, "accessKeySecret", "access_key_secret", "AccessKeySecret", "secret"),
|
||
PublicBaseURL: pickStr(m, "publicBaseUrl", "public_base_url", "PublicBaseURL", "cdn", "publicUrl"),
|
||
}
|
||
}
|
||
|
||
func overlayEmptyFromMap(cfg Config, m map[string]interface{}) Config {
|
||
if m == nil {
|
||
return cfg
|
||
}
|
||
f := configFromMap(m)
|
||
if strings.TrimSpace(cfg.Endpoint) == "" {
|
||
cfg.Endpoint = f.Endpoint
|
||
}
|
||
if strings.TrimSpace(cfg.Bucket) == "" {
|
||
cfg.Bucket = f.Bucket
|
||
}
|
||
if strings.TrimSpace(cfg.Region) == "" {
|
||
cfg.Region = f.Region
|
||
}
|
||
if strings.TrimSpace(cfg.AccessKeyID) == "" {
|
||
cfg.AccessKeyID = f.AccessKeyID
|
||
}
|
||
if strings.TrimSpace(cfg.AccessKeySecret) == "" {
|
||
cfg.AccessKeySecret = f.AccessKeySecret
|
||
}
|
||
if strings.TrimSpace(cfg.PublicBaseURL) == "" {
|
||
cfg.PublicBaseURL = f.PublicBaseURL
|
||
}
|
||
return cfg
|
||
}
|
||
|
||
// decodeOssConfigBytes 解析 oss_config:支持 UTF-8 BOM、JSON 再包一层字符串、map 内字段别名补全。
|
||
func decodeOssConfigBytes(raw []byte, depth int) (Config, error) {
|
||
if depth > 5 {
|
||
return Config{}, errors.New("nesting too deep")
|
||
}
|
||
raw = bytes.TrimSpace(raw)
|
||
raw = bytes.TrimPrefix(raw, []byte("\xef\xbb\xbf"))
|
||
if len(raw) == 0 {
|
||
return Config{}, errors.New("empty value")
|
||
}
|
||
var cfg Config
|
||
errDirect := json.Unmarshal(raw, &cfg)
|
||
var m map[string]interface{}
|
||
_ = json.Unmarshal(raw, &m)
|
||
if errDirect != nil {
|
||
var s string
|
||
if err2 := json.Unmarshal(raw, &s); err2 == nil && strings.TrimSpace(s) != "" {
|
||
return decodeOssConfigBytes([]byte(strings.TrimSpace(s)), depth+1)
|
||
}
|
||
if len(m) == 0 {
|
||
return Config{}, errDirect
|
||
}
|
||
return configFromMap(m), nil
|
||
}
|
||
return overlayEmptyFromMap(cfg, m), nil
|
||
}
|
||
|
||
// finalizeOssConfig 裁剪字段、补齐 endpoint、校验必填与 Secret 非占位。合法返回 true。
|
||
func finalizeOssConfig(cfg *Config) bool {
|
||
if cfg == nil {
|
||
return false
|
||
}
|
||
cfg.Endpoint = strings.TrimSpace(cfg.Endpoint)
|
||
cfg.Bucket = strings.TrimSpace(cfg.Bucket)
|
||
cfg.Region = strings.TrimSpace(cfg.Region)
|
||
cfg.AccessKeyID = strings.TrimSpace(cfg.AccessKeyID)
|
||
cfg.AccessKeySecret = strings.TrimSpace(cfg.AccessKeySecret)
|
||
if cfg.Bucket == "" || cfg.AccessKeyID == "" || cfg.AccessKeySecret == "" {
|
||
return false
|
||
}
|
||
if ossSecretIsPlaceholder(cfg.AccessKeySecret) {
|
||
return false
|
||
}
|
||
cfg.Endpoint = strings.TrimPrefix(strings.TrimPrefix(cfg.Endpoint, "https://"), "http://")
|
||
cfg.Endpoint = strings.Trim(cfg.Endpoint, "/")
|
||
if cfg.Endpoint == "" {
|
||
cfg.Endpoint = inferEndpointFromRegion(cfg.Region)
|
||
}
|
||
if cfg.Endpoint == "" {
|
||
return false
|
||
}
|
||
cfg.PublicBaseURL = strings.TrimSpace(cfg.PublicBaseURL)
|
||
return true
|
||
}
|
||
|
||
// 配置不完整或 Secret 为占位时走本地上传兜底;进程内只提示一次,避免并发上传刷屏。
|
||
var logLocalFallbackOnce sync.Once
|
||
var logOssParseOnce sync.Once
|
||
|
||
func noteLocalUploadFallback() {
|
||
logLocalFallbackOnce.Do(func() {
|
||
log.Print("oss: 未启用(配置不完整或 Secret 为占位),上传已使用本地兜底;可在管理端填写 OSS 或设置环境变量 ALIYUN_OSS_ACCESS_KEY_ID / ALIYUN_OSS_ACCESS_KEY_SECRET")
|
||
})
|
||
}
|
||
|
||
// applyAliyunOSEnvFallback 库中 Secret 为空/占位或 AccessKeyId 为空时,用环境变量 ALIYUN_OSS_ACCESS_KEY_* 补齐(不上传 .env 到公开仓库)。
|
||
func applyAliyunOSEnvFallback(cfg *Config) {
|
||
if cfg == nil {
|
||
return
|
||
}
|
||
g := config.Get()
|
||
if g == nil {
|
||
return
|
||
}
|
||
if sk := strings.TrimSpace(g.AliyunOSSAccessKeySecret); sk != "" {
|
||
if ossSecretIsPlaceholder(cfg.AccessKeySecret) || strings.TrimSpace(cfg.AccessKeySecret) == "" {
|
||
cfg.AccessKeySecret = sk
|
||
}
|
||
}
|
||
if id := strings.TrimSpace(g.AliyunOSSAccessKeyID); id != "" {
|
||
if strings.TrimSpace(cfg.AccessKeyID) == "" {
|
||
cfg.AccessKeyID = id
|
||
}
|
||
}
|
||
}
|
||
|
||
// LoadConfig 从 system_config 读取 oss_config;密钥可为环境变量兜底。不完整则返回 nil。
|
||
func LoadConfig() *Config {
|
||
var row model.SystemConfig
|
||
if err := database.DB().Where("config_key = ?", "oss_config").First(&row).Error; err != nil {
|
||
return nil
|
||
}
|
||
cfg, err := decodeOssConfigBytes(row.ConfigValue, 0)
|
||
if err != nil {
|
||
logOssParseOnce.Do(func() {
|
||
log.Printf("oss: oss_config parse error: %v", err)
|
||
})
|
||
return nil
|
||
}
|
||
applyAliyunOSEnvFallback(&cfg)
|
||
if !finalizeOssConfig(&cfg) {
|
||
noteLocalUploadFallback()
|
||
return nil
|
||
}
|
||
return &cfg
|
||
}
|
||
|
||
// clientEndpoint 传给阿里云 SDK:须带 scheme,外网建议 https
|
||
func clientEndpoint(cfg *Config) string {
|
||
if cfg == nil {
|
||
return ""
|
||
}
|
||
ep := strings.TrimSpace(cfg.Endpoint)
|
||
ep = strings.TrimPrefix(strings.TrimPrefix(ep, "https://"), "http://")
|
||
if ep == "" {
|
||
return ""
|
||
}
|
||
return "https://" + ep
|
||
}
|
||
|
||
// IsEnabled 是否已配置 OSS 且可用
|
||
func IsEnabled() bool {
|
||
return LoadConfig() != nil
|
||
}
|
||
|
||
// ConfigJSONIsReady 判断一段 oss_config 的 JSON(如 system_config.config_value)是否满足与 LoadConfig 相同的可用性规则(不访问数据库)。
|
||
func ConfigJSONIsReady(raw []byte) bool {
|
||
if len(bytes.TrimSpace(raw)) == 0 {
|
||
return false
|
||
}
|
||
cfg, err := decodeOssConfigBytes(raw, 0)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
return finalizeOssConfig(&cfg)
|
||
}
|
||
|
||
// ExtractPersistedSecretForMerge 从已保存的 oss_config JSON 中取出非占位 AccessKey Secret,供管理端保存时用 **** 与库内合并。
|
||
func ExtractPersistedSecretForMerge(raw []byte) (string, bool) {
|
||
cfg, err := decodeOssConfigBytes(raw, 0)
|
||
if err != nil {
|
||
return "", false
|
||
}
|
||
s := strings.TrimSpace(cfg.AccessKeySecret)
|
||
if s == "" || ossSecretIsPlaceholder(s) {
|
||
return "", false
|
||
}
|
||
return s, true
|
||
}
|
||
|
||
// Upload 上传文件到 OSS,objectKey 如 "uploads/avatars/xxx.jpg"
|
||
// 返回公网访问 URL,如 https://bucket.oss-cn-hangzhou.aliyuncs.com/uploads/avatars/xxx.jpg
|
||
func Upload(objectKey string, reader io.Reader, options ...alioss.Option) (string, error) {
|
||
cfg := LoadConfig()
|
||
if cfg == nil {
|
||
return "", nil // 未配置,调用方需回退本地
|
||
}
|
||
client, err := alioss.New(clientEndpoint(cfg), cfg.AccessKeyID, cfg.AccessKeySecret)
|
||
if err != nil {
|
||
log.Printf("oss: client init failed: %v", err)
|
||
return "", err
|
||
}
|
||
bucket, err := client.Bucket(cfg.Bucket)
|
||
if err != nil {
|
||
log.Printf("oss: bucket %s failed: %v", cfg.Bucket, err)
|
||
return "", err
|
||
}
|
||
opts := options
|
||
if len(opts) == 0 {
|
||
// 按后缀补常见 Content-Type,减少部分 Bucket/策略对匿名访问或 MIME 的依赖
|
||
lower := strings.ToLower(objectKey)
|
||
switch {
|
||
case strings.HasSuffix(lower, ".jpg"), strings.HasSuffix(lower, ".jpeg"):
|
||
opts = append(opts, alioss.ContentType("image/jpeg"))
|
||
case strings.HasSuffix(lower, ".png"):
|
||
opts = append(opts, alioss.ContentType("image/png"))
|
||
case strings.HasSuffix(lower, ".gif"):
|
||
opts = append(opts, alioss.ContentType("image/gif"))
|
||
case strings.HasSuffix(lower, ".webp"):
|
||
opts = append(opts, alioss.ContentType("image/webp"))
|
||
}
|
||
}
|
||
if err := bucket.PutObject(objectKey, reader, opts...); err != nil {
|
||
log.Printf("oss: PutObject %s failed: %v", objectKey, err)
|
||
return "", err
|
||
}
|
||
return ObjectPublicURL(cfg, objectKey), nil
|
||
}
|
||
|
||
// Delete 从 OSS 删除对象,objectKey 如 "uploads/avatars/xxx.jpg"
|
||
func Delete(objectKey string) error {
|
||
cfg := LoadConfig()
|
||
if cfg == nil {
|
||
return nil
|
||
}
|
||
client, err := alioss.New(clientEndpoint(cfg), cfg.AccessKeyID, cfg.AccessKeySecret)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
bucket, err := client.Bucket(cfg.Bucket)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return bucket.DeleteObject(objectKey)
|
||
}
|
||
|
||
// normObjectKey 统一 objectKey 分隔符与首尾斜杠
|
||
func normObjectKey(objectKey string) string {
|
||
k := strings.TrimSpace(objectKey)
|
||
k = strings.ReplaceAll(k, "\\", "/")
|
||
k = strings.Trim(k, "/")
|
||
return k
|
||
}
|
||
|
||
// normEndpointDomain endpoint 域名部分,无 scheme、无首尾斜杠
|
||
func normEndpointDomain(cfg *Config) string {
|
||
if cfg == nil {
|
||
return ""
|
||
}
|
||
return strings.Trim(normEndpointStr(cfg.Endpoint), "/")
|
||
}
|
||
|
||
func normEndpointStr(ep string) string {
|
||
ep = strings.TrimSpace(ep)
|
||
ep = strings.TrimPrefix(strings.TrimPrefix(ep, "https://"), "http://")
|
||
return strings.Trim(ep, "/")
|
||
}
|
||
|
||
func virtualHostedHost(cfg *Config) string {
|
||
if cfg == nil {
|
||
return ""
|
||
}
|
||
bucket := strings.TrimSpace(cfg.Bucket)
|
||
ep := normEndpointDomain(cfg)
|
||
if bucket == "" || ep == "" {
|
||
return ""
|
||
}
|
||
return strings.ToLower(bucket + "." + ep)
|
||
}
|
||
|
||
// publicURLHostFromBase 从 publicBaseUrl 配置解析 host(小写),无效返回空
|
||
func publicURLHostFromBase(base string) string {
|
||
base = strings.TrimSpace(base)
|
||
if base == "" {
|
||
return ""
|
||
}
|
||
if !strings.Contains(base, "://") {
|
||
base = "https://" + base
|
||
}
|
||
u, err := url.Parse(base)
|
||
if err != nil || u.Host == "" {
|
||
return ""
|
||
}
|
||
return strings.ToLower(u.Host)
|
||
}
|
||
|
||
// ObjectPublicURL 根据配置生成对象的公网 HTTPS 访问地址(自定义域名 / 虚拟主机 / path-style)
|
||
func ObjectPublicURL(cfg *Config, objectKey string) string {
|
||
if cfg == nil {
|
||
return ""
|
||
}
|
||
key := normObjectKey(objectKey)
|
||
if key == "" {
|
||
return ""
|
||
}
|
||
if pub := strings.TrimSpace(cfg.PublicBaseURL); pub != "" {
|
||
base := strings.TrimRight(pub, "/")
|
||
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
||
base = "https://" + base
|
||
}
|
||
return base + "/" + key
|
||
}
|
||
bucket := strings.TrimSpace(cfg.Bucket)
|
||
ep := normEndpointDomain(cfg)
|
||
if bucket == "" || ep == "" {
|
||
return ""
|
||
}
|
||
// Bucket 名含「.」时虚拟主机式 HTTPS 证书易失败,改用 path-style
|
||
if strings.Contains(bucket, ".") {
|
||
return "https://" + ep + "/" + bucket + "/" + key
|
||
}
|
||
return "https://" + bucket + "." + ep + "/" + key
|
||
}
|
||
|
||
// ParseObjectKeyFromURL 从 OSS 公网 URL 解析出 objectKey(用于 SDK Delete)
|
||
func ParseObjectKeyFromURL(rawURL string) string {
|
||
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
path := strings.Trim(strings.TrimPrefix(u.Path, "/"), "/")
|
||
if path == "" {
|
||
return ""
|
||
}
|
||
cfg := LoadConfig()
|
||
if cfg == nil {
|
||
return path
|
||
}
|
||
lowerHost := strings.ToLower(u.Host)
|
||
if ph := publicURLHostFromBase(cfg.PublicBaseURL); ph != "" && lowerHost == ph {
|
||
return path
|
||
}
|
||
if lowerHost == virtualHostedHost(cfg) {
|
||
return path
|
||
}
|
||
ep := normEndpointDomain(cfg)
|
||
bucket := strings.TrimSpace(cfg.Bucket)
|
||
if ep != "" && lowerHost == strings.ToLower(ep) && bucket != "" && strings.HasPrefix(path, bucket+"/") {
|
||
return strings.TrimPrefix(path, bucket+"/")
|
||
}
|
||
return path
|
||
}
|
||
|
||
// IsOSSURL 判断是否为当前配置下 OSS 对外 URL(用于删除时区分本地/OSS)
|
||
func IsOSSURL(rawURL string) bool {
|
||
cfg := LoadConfig()
|
||
if cfg == nil {
|
||
return false
|
||
}
|
||
s := strings.TrimSpace(rawURL)
|
||
u, err := url.Parse(s)
|
||
if err != nil || u.Host == "" {
|
||
return false
|
||
}
|
||
lowerHost := strings.ToLower(u.Host)
|
||
if ph := publicURLHostFromBase(cfg.PublicBaseURL); ph != "" && lowerHost == ph {
|
||
return true
|
||
}
|
||
if lowerHost == virtualHostedHost(cfg) {
|
||
return true
|
||
}
|
||
ep := normEndpointDomain(cfg)
|
||
bucket := strings.TrimSpace(cfg.Bucket)
|
||
path := strings.Trim(strings.TrimPrefix(u.Path, "/"), "/")
|
||
if ep != "" && lowerHost == strings.ToLower(ep) && bucket != "" && strings.HasPrefix(path, bucket+"/") {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// PublicURL 将路径转为 OSS 公网访问 URL,path 如 /uploads/avatars/xxx.jpg
|
||
// OSS 未配置时返回空字符串
|
||
func PublicURL(path string) string {
|
||
cfg := LoadConfig()
|
||
if cfg == nil {
|
||
return ""
|
||
}
|
||
path = strings.TrimSpace(path)
|
||
if path == "" {
|
||
return ""
|
||
}
|
||
return ObjectPublicURL(cfg, strings.TrimPrefix(path, "/"))
|
||
}
|