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.
 
 
 
 
 
 

229 lines
8.5 KiB

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)
}