You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
182 lines
7.4 KiB
182 lines
7.4 KiB
package console
|
|
|
|
// 文件直传(固件升级包 / 产品图片等)—— 后端签发「阿里云 OSS 预签名 PUT URL」,浏览器直传。
|
|
//
|
|
// 设计要点:
|
|
// 1. OSS 凭证来自【第三方服务配置(svc_config) → 存储 → 阿里云 OSS】(provider=aliyun_oss),
|
|
// 即 serviceconfig.vue 里落库、并按应用分离(app_name='' 为全局默认)的那份配置。
|
|
// ——不再走腾讯云 COS / 也不读那份纯演示未落库的「系统配置」页。
|
|
// 2. object key 由服务端强制置于 uploadRoot("EAIMAR") 根前缀下,形如
|
|
// EAIMAR/product/<产品id>/<文件名>,并做防路径穿越清洗。
|
|
// 3. 预签名 PUT 会把 Content-Type 计入签名,故前端 PUT 必须带同样的 Content-Type(uploadFile 已对齐)。
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"path"
|
|
"strings"
|
|
|
|
"yunyan/comm"
|
|
"yunyan/lego/sys/postgres"
|
|
"yunyan/pb"
|
|
"yunyan/sys/aliyun/oss"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// ossStorageProvider svc_config 中对象存储服务的 provider 值(与 api_svctemplate.go 的
|
|
// storage_aliyun_oss 模板 provider 一致)。
|
|
const ossStorageProvider = "aliyun_oss"
|
|
|
|
// uploadRoot 所有上传对象强制置于该根前缀下(服务端兜底,前端传什么 path 都逃不出去)。
|
|
//
|
|
// ⚠️ 改这个值只影响【之后】上传的文件:已经存进 productimage / updatepackageaddress 的
|
|
// 历史地址是死的绝对 URL,不会跟着搬家,OSS 上的旧 yunyan/ 目录也还在。要么保留旧目录,
|
|
// 要么在 OSS 控制台把对象拷过来、再把库里那些 URL 一起改,两件事缺一不可。
|
|
const uploadRoot = "EAIMAR"
|
|
|
|
// aliyunOSSConf 从 svc_config 解出的一份可用的阿里云 OSS 凭证。
|
|
type aliyunOSSConf struct {
|
|
Endpoint string
|
|
AccessKeyId string
|
|
AccessSecret string
|
|
Bucket string
|
|
Domain string // 可选:自定义 CDN 域名,配了则文件下载 URL 走该域名
|
|
}
|
|
|
|
// loadAliyunOSSConf 读取阿里云 OSS 有效配置。
|
|
// 优先读【系统配置 → 对象存储】(平台自用表 sys_service_config,category=storage, provider=aliyun_oss);
|
|
// 该表无有效配置时,回退旧的【第三方服务配置 → 存储】(svc_config) 以兼容存量部署。
|
|
// 加密字段(access_key_id/access_key_secret) 用 console 的 EncryptKey 解密。
|
|
func (this *serverComp) loadAliyunOSSConf(appName string) (*aliyunOSSConf, error) {
|
|
if oc := this.loadSysStorageAliyun(); oc != nil {
|
|
return oc, nil
|
|
}
|
|
find := func(app string) *ThirdSvcConfig {
|
|
rows := make([]*ThirdSvcConfig, 0)
|
|
if err := postgres.Find(comm.TableSvcConfig, &rows,
|
|
"app_name=? AND provider=? AND enable=?", app, ossStorageProvider, true); err != nil || len(rows) == 0 {
|
|
return nil
|
|
}
|
|
return rows[0]
|
|
}
|
|
cfg := find(appName)
|
|
if cfg == nil && appName != "" {
|
|
cfg = find("") // 应用无专属配置则回退全局默认
|
|
}
|
|
if cfg == nil {
|
|
return nil, fmt.Errorf("未配置阿里云 OSS:请在【第三方服务配置 → 存储】新增并启用「阿里云 OSS」服务")
|
|
}
|
|
get := func(key string) string {
|
|
for _, f := range cfg.Fields {
|
|
if f.Key != key {
|
|
continue
|
|
}
|
|
v := f.DefValue
|
|
if f.Encrypted && v != "" {
|
|
if plain, err := svcDecrypt(this.options.EncryptKey, v); err == nil {
|
|
v = plain
|
|
}
|
|
}
|
|
return strings.TrimSpace(v)
|
|
}
|
|
return ""
|
|
}
|
|
oc := &aliyunOSSConf{
|
|
Endpoint: get("endpoint"),
|
|
AccessKeyId: get("access_key_id"),
|
|
AccessSecret: get("access_key_secret"),
|
|
Bucket: get("bucket"),
|
|
Domain: get("domain"),
|
|
}
|
|
if oc.Endpoint == "" || oc.AccessKeyId == "" || oc.AccessSecret == "" || oc.Bucket == "" {
|
|
return nil, fmt.Errorf("阿里云 OSS 配置不完整:endpoint / access_key_id / access_key_secret / bucket 均为必填")
|
|
}
|
|
// 规范化 endpoint 的 scheme:SDK 对无 scheme 的 endpoint 默认拼 http://,
|
|
// 会导致 https 后台页直传时被浏览器按「混合内容」拦截;这里统一升到 https。
|
|
if !strings.HasPrefix(oc.Endpoint, "http://") && !strings.HasPrefix(oc.Endpoint, "https://") {
|
|
oc.Endpoint = "https://" + oc.Endpoint
|
|
}
|
|
return oc, nil
|
|
}
|
|
|
|
// buildObjectKey 生成对象存储 key,强制置于 uploadRoot 根下并做防路径穿越清洗。
|
|
//
|
|
// dir 调用方给的逻辑目录(如 "product/12/"),可空。
|
|
// name 文件名(取 base 去掉任何目录部分)。
|
|
//
|
|
// 例:buildObjectKey("product/12/", "fw_v1.bin") => "EAIMAR/product/12/fw_v1.bin"
|
|
func buildObjectKey(dir, name string) string {
|
|
name = path.Base(strings.TrimSpace(name)) // 去掉任何目录部分,防止 name 里夹带 ../
|
|
if name == "" || name == "." || name == "/" {
|
|
name = "file"
|
|
}
|
|
key := path.Clean(path.Join(uploadRoot, dir, name)) // Join+Clean 合并 //、解析 ..
|
|
// 兜底:若 Clean 后逃逸出根前缀(dir 里塞了过量 ../),强制退回根下只保留文件名。
|
|
if key != uploadRoot && !strings.HasPrefix(key, uploadRoot+"/") {
|
|
key = uploadRoot + "/" + name
|
|
}
|
|
return key
|
|
}
|
|
|
|
// getUploadURL 前端固件/图片直传:读取阿里云 OSS 配置,为目标对象签发一个预签名 PUT URL。
|
|
//
|
|
// 路由沿用 api_getcostoken(前端 useApi.uploadFile 用该名,避免前端改路由)。
|
|
// 请求体 { path, filename, content_type };返回 { upload_url(预签名PUT), url(去签名的公网URL), key }。
|
|
func (this *serverComp) getUploadURL(c *gin.Context) {
|
|
var req struct {
|
|
Path string `json:"path"`
|
|
Filename string `json:"filename"`
|
|
ContentType string `json:"content_type"`
|
|
}
|
|
_ = c.ShouldBindJSON(&req)
|
|
if strings.TrimSpace(req.Filename) == "" {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "filename 必填")
|
|
return
|
|
}
|
|
// 作用域:X-App-Id → 应用名(缺失/解析失败即全局默认)。
|
|
appName := ""
|
|
if appId := parseAppId(c.GetHeader("X-App-Id")); appId > 0 {
|
|
if app, err := this.module.model.getApp(appId); err == nil && app != nil {
|
|
appName = app.Name
|
|
}
|
|
}
|
|
conf, err := this.loadAliyunOSSConf(appName)
|
|
if err != nil {
|
|
writeErr(c, pb.ErrorCode_SystemError, err.Error())
|
|
return
|
|
}
|
|
presigner, err := oss.NewPresigner(conf.Endpoint, conf.AccessKeyId, conf.AccessSecret, conf.Bucket)
|
|
if err != nil {
|
|
writeErr(c, pb.ErrorCode_SystemError, "初始化阿里云 OSS 失败: "+err.Error())
|
|
return
|
|
}
|
|
key := buildObjectKey(req.Path, req.Filename)
|
|
ct := strings.TrimSpace(req.ContentType)
|
|
if ct == "" {
|
|
ct = "application/octet-stream"
|
|
}
|
|
uploadURL, err := presigner.SignPutURL(key, ct, 900) // 有效期 15 分钟
|
|
if err != nil {
|
|
writeErr(c, pb.ErrorCode_SystemError, "生成上传地址失败: "+err.Error())
|
|
return
|
|
}
|
|
// 公网访问 URL(存进 updatepackageaddress 的裸地址):
|
|
// 配了自定义域名 → https://<domain>/<key>(走 CDN);否则去掉预签名 query 的裸 OSS 地址。
|
|
publicURL := uploadURL
|
|
if d := strings.TrimSpace(conf.Domain); d != "" {
|
|
d = strings.TrimPrefix(strings.TrimPrefix(d, "https://"), "http://")
|
|
d = strings.Trim(d, "/")
|
|
// 只取第一段做 host:运营常把域名连着路径一起填(cdn.ymaikj.com/),
|
|
// 那样直接当 Host 用会拼出 https://cdn.ymaikj.com//EAIMAR/... 这种取不到的地址。
|
|
if i := strings.Index(d, "/"); i >= 0 {
|
|
d = d[:i]
|
|
}
|
|
// 用 url.URL 拼而不是字符串相加:文件名可能带中文或空格,Path 会按段转义(`/` 保留)。
|
|
publicURL = (&url.URL{Scheme: "https", Host: d, Path: "/" + key}).String()
|
|
} else if u, e := url.Parse(uploadURL); e == nil {
|
|
u.RawQuery = ""
|
|
publicURL = u.String()
|
|
}
|
|
writeOK(c, gin.H{"upload_url": uploadURL, "url": publicURL, "key": key})
|
|
}
|
|
|