package console // 下载落地页管理(2026-09-23 加):后台「版本与分发」页的「下载页」一行。 // // 分发桶(dl.ymaikj.com)上每个应用放一份静态下载页,外包装二维码指向它, // 如 https://dl.ymaikj.com/EAIMAR/download.html。以前只能改仓库里的 deploy/dl 再手传 OSS, // 现在由后台读回源码、编辑、发布: // // api_getdistpage 读线上那份 HTML(服务端 GetObject,不经 CDN/浏览器缓存) // api_savedistpage 发布:PutObject 覆盖,并把地址写进 app_release.download_page // // 读写都走服务端而不是浏览器直传:能强制 Content-Type 与 Cache-Control(见 saveDistPage), // 读到的也一定是桶里的最新版本,而不是某一层缓存。草稿只存在浏览器本地,不经过这里。 import ( "bytes" "fmt" "net/http" "net/url" "path" "regexp" "strings" "yunyan/comm" "yunyan/pb" "yunyan/sys/aliyun/oss" aoss "github.com/aliyun/aliyun-oss-go-sdk/oss" "github.com/gin-gonic/gin" ) // distPageDefaultName 下载页默认文件名:<分发域名>/<应用名>/download.html。 const distPageDefaultName = "download.html" // distPageMaxBytes 下载页源码上限。现网那份内联了 App 图标也才 70KB, // 给到 2MB 足够,同时挡住误把安装包之类的大文件当页面发布。 const distPageMaxBytes = 2 << 20 // distPageNameRe 文件名只允许一层、字母数字开头、.html 结尾。 // 分发桶是公共读的,这里放宽一点就等于让人能在 dl.ymaikj.com 上随意挂页面。 var distPageNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\.html$`) // distHost 从分发域名配置里取出 host(运营可能连 scheme、路径一起填)。 func distHost(domain string) string { d := strings.TrimSpace(domain) d = strings.TrimPrefix(strings.TrimPrefix(d, "https://"), "http://") d = strings.Trim(d, "/") if i := strings.Index(d, "/"); i >= 0 { d = d[:i] } return d } // defaultDistPageURL 某应用下载页的默认地址。 func defaultDistPageURL(domain, appSeg string) string { return (&url.URL{Scheme: "https", Host: distHost(domain), Path: "/" + appSeg + "/" + distPageDefaultName}).String() } // parseDistPageURL 校验后台输入框里的地址,返回 object key 与规范化后的地址。 // // 规则:域名必须是分发域名;路径必须恰好是 /<应用名>/<文件名>.html。 // 前缀由服务端按选中的应用定死,页面上只能改文件名——不然一个应用的运营 // 就能覆盖掉别的应用的下载页,或者覆盖 android/ 下的安装包。 func parseDistPageURL(raw, domain, appSeg string) (key, canonical string, err error) { host := distHost(domain) if host == "" { return "", "", fmt.Errorf("未配置分发域名") } raw = strings.TrimSpace(raw) if raw == "" { return "", "", fmt.Errorf("下载页地址不能为空") } u, perr := url.Parse(raw) if perr != nil || (u.Scheme != "http" && u.Scheme != "https") { return "", "", fmt.Errorf("下载页地址格式不对,应形如 https://%s/%s/%s", host, appSeg, distPageDefaultName) } if !strings.EqualFold(u.Host, host) { return "", "", fmt.Errorf("下载页只能放在分发域名 %s 下", host) } parts := strings.Split(strings.Trim(u.Path, "/"), "/") if len(parts) != 2 { return "", "", fmt.Errorf("下载页地址必须是 https://%s/%s/<文件名>.html,不能多也不能少一层目录", host, appSeg) } if parts[0] != appSeg { return "", "", fmt.Errorf("下载页只能放在本应用的目录 /%s/ 下", appSeg) } name := parts[1] if !distPageNameRe.MatchString(name) { return "", "", fmt.Errorf("文件名只能用字母、数字、. _ -,且以小写 .html 结尾") } key = path.Join(appSeg, name) canonical = (&url.URL{Scheme: "https", Host: host, Path: "/" + key}).String() return key, canonical, nil } // distPageScope 解析一次下载页请求的作用域:应用分段 + 分发桶句柄 + 分发域名。 func (this *serverComp) distPageScope(appName string) (appSeg string, bucket *oss.OSS, domain string, err error) { appName = strings.TrimSpace(appName) if appName == "" { return "", nil, "", fmt.Errorf("请先在页面顶部选择应用:下载页按应用分目录存放") } // 必须是注册过的应用,不能让任意字符串在公共桶里造目录。 app, aerr := this.module.model.getAppByNameOrAppName(appName) if aerr != nil || app == nil { return "", nil, "", fmt.Errorf("应用 %s 未在应用注册表中登记", appName) } appSeg = distAppSeg(appName) if appSeg == "" || comm.DistUploadDirs[appSeg] { return "", nil, "", fmt.Errorf("应用名 %s 不能用作下载页目录", appName) } conf, cerr := this.loadAliyunOSSConf(app.Name) if cerr != nil { return "", nil, "", cerr } conf = conf.ForDist() bucket, err = oss.NewPresigner(conf.Endpoint, conf.AccessKeyId, conf.AccessSecret, conf.Bucket) if err != nil { return "", nil, "", fmt.Errorf("初始化阿里云 OSS 失败: %v", err) } return appSeg, bucket, conf.Domain, nil } // getDistPage 读某应用的下载页源码。 // // 请求 { app_name, url? }:url 为空时取已保存的地址,再空取默认地址。 // 返回 { url, saved_url, default_url, content, exists };对象不存在时 exists=false、content 为空, // 不当成错误——新应用第一次用本功能时本来就没有页面。 func (this *serverComp) getDistPage(c *gin.Context) { var req struct { AppName string `json:"app_name"` URL string `json:"url"` } _ = c.ShouldBindJSON(&req) appSeg, bucket, domain, err := this.distPageScope(req.AppName) if err != nil { writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) return } row, err := this.loadAppRelease(req.AppName) if err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } defURL := defaultDistPageURL(domain, appSeg) target := strings.TrimSpace(req.URL) if target == "" { target = strings.TrimSpace(row.DownloadPage) } if target == "" { target = defURL } key, canonical, err := parseDistPageURL(target, domain, appSeg) if err != nil { writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) return } content, exists := "", true data, gerr := bucket.GetObject(key) if gerr != nil { if se, ok := gerr.(aoss.ServiceError); ok && se.StatusCode == http.StatusNotFound { exists = false } else { writeErr(c, pb.ErrorCode_SystemError, "读取下载页失败: "+gerr.Error()) return } } else { content = string(data) } writeOK(c, gin.H{ "url": canonical, "saved_url": row.DownloadPage, "default_url": defURL, "content": content, "exists": exists, }) } // saveDistPage 发布下载页:覆盖写到分发桶,并把地址记进 app_release.download_page。 // // 请求 { app_name, url, content }。 func (this *serverComp) saveDistPage(c *gin.Context) { var req struct { AppName string `json:"app_name"` URL string `json:"url"` Content string `json:"content"` } if err := c.ShouldBindJSON(&req); err != nil { writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) return } if strings.TrimSpace(req.Content) == "" { // 发一个空页面上去,扫码的人就是一片白——比保留旧页面糟得多,直接拒。 writeErr(c, pb.ErrorCode_ReqParameterError, "页面内容为空,未发布") return } if len(req.Content) > distPageMaxBytes { writeErr(c, pb.ErrorCode_ReqParameterError, fmt.Sprintf("页面超过 %dMB 上限", distPageMaxBytes>>20)) return } appSeg, bucket, domain, err := this.distPageScope(req.AppName) if err != nil { writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) return } key, canonical, err := parseDistPageURL(req.URL, domain, appSeg) if err != nil { writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) return } // ⚠️ 两个头都不能省: // Content-Type 不带 charset 时,部分安卓浏览器按 GBK 猜,中文整页乱码; // Cache-Control 不设时浏览器/微信会按启发式缓存,点了保存扫码的人还看到旧页面。 if err := bucket.UploadObject(key, bytes.NewReader([]byte(req.Content)), aoss.ContentType("text/html; charset=utf-8"), aoss.CacheControl("no-cache"), ); err != nil { writeErr(c, pb.ErrorCode_SystemError, "发布到 OSS 失败: "+err.Error()) return } // 页面已经发出去了,地址落库失败只影响下次打开时回填哪个地址,不回滚发布。 m, err := this.loadAppRelease(req.AppName) if err != nil { writeErr(c, pb.ErrorCode_DBError, "页面已发布,但保存地址失败: "+err.Error()) return } m.AppName = req.AppName m.DownloadPage = canonical this.saveAppRelease(c, m) }