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.
212 lines
8.0 KiB
212 lines
8.0 KiB
package comm
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"yunyan/lego/sys/postgres"
|
|
)
|
|
|
|
// AliyunOSSConf 一份可用的阿里云 OSS 凭证。
|
|
type AliyunOSSConf struct {
|
|
Endpoint string
|
|
AccessKeyId string
|
|
AccessSecret string
|
|
Bucket string
|
|
Domain string // 可选:自定义 CDN 域名,配了则访问 URL 走该域名
|
|
}
|
|
|
|
// AliyunOSSProvider svc_config / sys_service_config 中阿里云对象存储的 provider 值。
|
|
const AliyunOSSProvider = "aliyun_oss"
|
|
|
|
// OSSUploadRoot 所有上传对象强制置于该根前缀下(服务端兜底,调用方传什么路径都逃不出去)。
|
|
//
|
|
// ⚠️ 改这个值只影响【之后】上传的文件:已经存进库里的历史地址是死的绝对 URL,
|
|
// 不会跟着搬家,OSS 上的旧目录也还在。要改就得连同库里那些 URL 一起改。
|
|
const OSSUploadRoot = "EAIMAR"
|
|
|
|
// LoadAliyunOSSConf 读取阿里云 OSS 有效配置。
|
|
//
|
|
// 取值顺序与 console 后台直传一致,两边**必须**是同一份配置,否则后台上传的固件
|
|
// 和 App 上传的录音会落到不同的桶里:
|
|
// 1. 【系统配置 → 对象存储】(sys_service_config, category=storage, provider=aliyun_oss);
|
|
// 2. 该表无有效配置时回退【第三方服务配置 → 存储】(svc_config, provider=aliyun_oss),
|
|
// 应用行(app_name=<app>) 优先于全局行(app_name=”)。
|
|
//
|
|
// encKey 为 ${FIELD_ENCRYPT_KEY}——console 与各业务服务本就必须配同一把,
|
|
// 加密字段(access_key_id/access_key_secret)用它解密。
|
|
func LoadAliyunOSSConf(appName, encKey string) (*AliyunOSSConf, error) {
|
|
if oc := loadOSSFromSysConfig(encKey); oc != nil {
|
|
return oc, nil
|
|
}
|
|
find := func(app string) *ThirdSvcConfig {
|
|
rows := make([]*ThirdSvcConfig, 0)
|
|
if err := postgres.Find(TableSvcConfig, &rows,
|
|
"app_name=? AND provider=? AND enable=?", app, AliyunOSSProvider, true); err != nil || len(rows) == 0 {
|
|
return nil
|
|
}
|
|
return rows[0]
|
|
}
|
|
cfg := find(appName)
|
|
if cfg == nil && appName != "" {
|
|
cfg = find("") // 应用无专属配置则回退全局默认(作用域归一后已不再产生全局行)
|
|
}
|
|
if cfg == nil {
|
|
// 兜底:后台传固件/产品图是**平台级**操作,请求未必带 X-App-Id
|
|
// (产品管理是超管页面,本来就不按应用筛),于是 appName 为空;
|
|
// 而 2026-09-14 的作用域归一把 svc_config 全部分配到各应用之后,
|
|
// 全局行(app_name='')**不再存在**,上面那条回退就永远落空了 ——
|
|
// 表现是后台一上传就报「未配置阿里云 OSS」,而配置明明填得好好的。
|
|
//
|
|
// 只在**全库启用的 aliyun_oss 都指向同一个桶**时才兜底:各应用配了不同的桶时
|
|
// 随手挑一个会把文件传进别人的桶里,那比报错更糟,所以那种情况仍然报错,
|
|
// 并在文案里说清楚要带 X-App-Id。
|
|
var ambiguous bool
|
|
cfg, ambiguous = findUnambiguousOSS(encKey)
|
|
if ambiguous {
|
|
return nil, fmt.Errorf("有多个指向不同桶的阿里云 OSS 配置,无法确定用哪个:" +
|
|
"请在【系统配置 → 对象存储】启用平台级 OSS,或让该请求带上 X-App-Id")
|
|
}
|
|
}
|
|
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 := Decrypt(encKey, 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 err := oc.normalize(); err != nil {
|
|
return nil, err
|
|
}
|
|
return oc, nil
|
|
}
|
|
|
|
func loadOSSFromSysConfig(encKey string) *AliyunOSSConf {
|
|
rows := make([]*PlatformService, 0)
|
|
if err := postgres.Find(TableSysServiceConfig, &rows,
|
|
"category=? AND provider=? AND enable=?", SysCatStorage, AliyunOSSProvider, true); err != nil || len(rows) == 0 {
|
|
return nil
|
|
}
|
|
// 有 is_default 优先取默认,否则取第一条启用的。
|
|
cfg := rows[0]
|
|
for _, r := range rows {
|
|
if r.IsDefault {
|
|
cfg = r
|
|
break
|
|
}
|
|
}
|
|
oc := &AliyunOSSConf{
|
|
Endpoint: strings.TrimSpace(cfg.SysFieldPlain("endpoint", encKey)),
|
|
AccessKeyId: strings.TrimSpace(cfg.SysFieldPlain("access_key_id", encKey)),
|
|
AccessSecret: strings.TrimSpace(cfg.SysFieldPlain("access_key_secret", encKey)),
|
|
Bucket: strings.TrimSpace(cfg.SysFieldPlain("bucket", encKey)),
|
|
Domain: strings.TrimSpace(cfg.SysFieldPlain("domain", encKey)),
|
|
}
|
|
if oc.normalize() != nil {
|
|
return nil
|
|
}
|
|
return oc
|
|
}
|
|
|
|
// normalize 校验必填并规范化 endpoint 的 scheme。
|
|
//
|
|
// SDK 对无 scheme 的 endpoint 默认拼 http://,会让 https 页面直传时被浏览器按
|
|
// 「混合内容」拦掉;统一升到 https。
|
|
func (oc *AliyunOSSConf) normalize() error {
|
|
if oc.Endpoint == "" || oc.AccessKeyId == "" || oc.AccessSecret == "" || oc.Bucket == "" {
|
|
return fmt.Errorf("阿里云 OSS 配置不完整:endpoint / access_key_id / access_key_secret / bucket 均为必填")
|
|
}
|
|
if !strings.HasPrefix(oc.Endpoint, "http://") && !strings.HasPrefix(oc.Endpoint, "https://") {
|
|
oc.Endpoint = "https://" + oc.Endpoint
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PublicURL 把预签名 URL 换算成可直接访问的裸地址。
|
|
//
|
|
// 配了自定义域名 → https://<domain>/<key>(走 CDN);
|
|
// 否则把预签名的 query 去掉,留裸 OSS 地址。
|
|
func (oc *AliyunOSSConf) PublicURL(signedURL, key string) string {
|
|
if d := strings.TrimSpace(oc.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 会按段转义(`/` 保留)。
|
|
return (&url.URL{Scheme: "https", Host: d, Path: "/" + key}).String()
|
|
}
|
|
if u, e := url.Parse(signedURL); e == nil {
|
|
u.RawQuery = ""
|
|
return u.String()
|
|
}
|
|
return signedURL
|
|
}
|
|
|
|
// findUnambiguousOSS 在没有应用作用域时兜底取一份 OSS 配置。
|
|
//
|
|
// 只有当所有启用的 aliyun_oss 配置**指向同一个桶**(endpoint + bucket + access_key_id
|
|
// 三者都一致)时才返回它;存在分歧时返回 ambiguous=true,由调用方报错。
|
|
// 判据刻意用解密后的 access_key_id 而不是密文:同一份密钥在不同行里的密文不同
|
|
// (AES-GCM 每次加密的 nonce 不同),按密文比会把一致的判成分歧。
|
|
func findUnambiguousOSS(encKey string) (cfg *ThirdSvcConfig, ambiguous bool) {
|
|
rows := make([]*ThirdSvcConfig, 0)
|
|
if err := postgres.Find(TableSvcConfig, &rows,
|
|
"provider=? AND enable=?", AliyunOSSProvider, true); err != nil || len(rows) == 0 {
|
|
return nil, false
|
|
}
|
|
first := ossFingerprint(rows[0], encKey)
|
|
for _, r := range rows[1:] {
|
|
if ossFingerprint(r, encKey) != first {
|
|
return nil, true
|
|
}
|
|
}
|
|
return rows[0], false
|
|
}
|
|
|
|
// ossFingerprint 一份 OSS 配置的身份:endpoint + bucket + access_key_id。
|
|
//
|
|
// ⚠️ 用**解密后**的 access_key_id 比,不能比密文:AES-GCM 每次加密的 nonce 不同,
|
|
// 同一把密钥在不同行里的密文也不同,按密文比会把一致的配置判成分歧。
|
|
func ossFingerprint(c *ThirdSvcConfig, encKey string) string {
|
|
var ep, bk, ak string
|
|
for _, f := range c.Fields {
|
|
v := f.DefValue
|
|
if f.Encrypted && v != "" {
|
|
if plain, derr := Decrypt(encKey, v); derr == nil {
|
|
v = plain
|
|
}
|
|
}
|
|
switch f.Key {
|
|
case "endpoint":
|
|
ep = strings.TrimSpace(v)
|
|
case "bucket":
|
|
bk = strings.TrimSpace(v)
|
|
case "access_key_id":
|
|
ak = strings.TrimSpace(v)
|
|
}
|
|
}
|
|
return ep + "|" + bk + "|" + ak
|
|
}
|
|
|