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 走该域名 // 「分发桶」:放对外公开下载的东西(App 安装包),与放用户录音/固件的主桶分开。 // 分开的理由不是洁癖:安装包域名容易被微信/浏览器判风险,混在主桶会连累 // 录音和后台直传;而且几十 MB × 几千次下载的流量账单要能单独看。 // 同一个 OSS 账号,所以 Endpoint/AK/SK 复用主桶那份,只有桶名和域名不同。 DistBucket string // 留空用 DefaultDistBucket DistDomain string // 留空用 DefaultDistDomain } // 分发桶的默认值。运营在【第三方服务配置 → 存储】填了 dist_bucket / dist_domain 就以配置为准。 // // ⚠️ DefaultDistDomain 必须与 deploy/dl 那个下载落地页所在的桶一致—— // 页面和安装包同源才没有跨域与防盗链问题。 const ( DefaultDistBucket = "ymai-dl" DefaultDistDomain = "https://dl.ymaikj.com" ) // DistUploadDirs 分发桶里允许上传到的顶层目录白名单。 // // ⚠️ 不做白名单的话,前端传什么 path 就在桶里造什么目录——这个桶是公共读、 // 直接挂在 dl.ymaikj.com 上的,等于把它变成任人摆放的公开网盘。 var DistUploadDirs = map[string]bool{ "android": true, // App 安装包(apk) } // ForDist 返回一份指向「分发桶」的配置副本。凭据与主桶共用。 func (oc *AliyunOSSConf) ForDist() *AliyunOSSConf { d := *oc d.Bucket = oc.DistBucket if d.Bucket == "" { d.Bucket = DefaultDistBucket } d.Domain = oc.DistDomain if d.Domain == "" { d.Domain = DefaultDistDomain } return &d } // 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 { // 兜底:后台传固件/产品图是**平台级**操作,请求未必带 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"), DistBucket: get("dist_bucket"), DistDomain: get("dist_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)), DistBucket: strings.TrimSpace(cfg.SysFieldPlain("dist_bucket", encKey)), DistDomain: strings.TrimSpace(cfg.SysFieldPlain("dist_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:运营常把域名连着路径一起填(oss.ymaikj.com/), // 那样直接当 Host 用会拼出 https://oss.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 }