Files
乘风 c84c53515e feat: update API base URLs and improve UI elements
- 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.
2026-05-09 17:26:55 +08:00

482 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 上传文件到 OSSobjectKey 如 "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 公网访问 URLpath 如 /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, "/"))
}