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_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 { 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:///(走 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 }