Browse Source
音乐:sys/music、sys/musicobj、modules/music、mcp/tool_music.go 全部删除,home 的 模块列表与 musicobj 初始化、两份 yaml 模板里的 musicobj 段一并去掉。起因是它的 ApiBaseUrl 指向上一代品牌域名;客户端的音乐模块本来就早已删除。 ⚠️ 咪咕音乐(tool_migu_music + migu 服务)没有动,别一起当成音乐链路删了。 腾讯云 COS:sys/tencentyun 整个删除,home/api 的 cos.OnInit 与配置段、 comm.svcRuntimeSections 的 cos 托管一并去掉。头像上传改走阿里云 OSS(与 user_getuploadurl、console 后台直传同一个桶)。modules/api/api_getcostoken.go 是 孤儿(后台调的其实是 console 那个同名路由,客户端从不调),删除。后台两处的 腾讯云 COS 卡片/模板与巡检探针也删——服务端读存储配置的唯一入口只认 provider=aliyun_oss,留着是「配得上却不生效」的陷阱。存量 tencent_cos 行不动。 ⚠️ 老客户端仍读业务库 config 表的 COS_* 直传腾讯云,那 5 个键别删;历史录音的 audiourl 是 COS 绝对地址,不会自动搬家。 分发桶:comm.AliyunOSSConf 加 DistBucket/DistDomain(留空用 ymai-dl / dl.ymaikj.com),凭据与主桶共用。后台「上传 APK」走 api_getcostoken 带 target:"dist":key 不加 EAIMAR 根前缀(分发桶直接挂在 dl.ymaikj.com 上)、目录走 白名单、按应用分子目录(一个桶放多个应用,不分目录会静默互相覆盖)。 ⚠️ Content-Type 由服务端按扩展名强制并回带给前端:浏览器对 .apk 给出的 File.type 往往是空串,前端照它发 PUT 会与预签名不符、OSS 回 403,而报错里完全看不出是类型 对不上。⚠️ 分发桶必须单独配 CORS(来源 console.ymaikj.com、方法 PUT), 浏览器直传打的是桶的 OSS endpoint 而不是自定义域名。 另含下载落地页 deploy/dl(含 OSS 部署教程)与各 deploy 脚本/README 的同步更新。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>main
69 changed files with 1229 additions and 2169 deletions
@ -1,31 +0,0 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"yunyan/comm" |
|||
"yunyan/pb" |
|||
"yunyan/sys/tencentyun/cos" |
|||
) |
|||
|
|||
// 获取COS临时上传凭证(前端直传用)
|
|||
func (this *apiComp) GetCosToken(session comm.IUserSession, req *pb.ApiGetCosTokenReq) (resp *pb.ApiGetCosTokenResp, errdata *pb.ErrorData) { |
|||
result, err := cos.Sts() |
|||
if err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_SystemError, |
|||
Message: "获取上传凭证失败: " + err.Error(), |
|||
} |
|||
return |
|||
} |
|||
config := cos.GetConfig() |
|||
resp = &pb.ApiGetCosTokenResp{ |
|||
Bucket: config.BucketName, |
|||
Region: config.Region, |
|||
BucketUrl: config.BucketURL, |
|||
TmpSecretId: result.Credentials.TmpSecretID, |
|||
TmpSecretKey: result.Credentials.TmpSecretKey, |
|||
SessionToken: result.Credentials.SessionToken, |
|||
Expiration: result.Expiration, |
|||
Domain: config.DomainName, |
|||
} |
|||
return |
|||
} |
|||
@ -0,0 +1,54 @@ |
|||
package console |
|||
|
|||
import "testing" |
|||
|
|||
// 分发桶是公共读、直接挂在 dl.ymaikj.com 上的,key 拼错就等于把它变成公开网盘,
|
|||
// 所以每条边界都钉在测试里。
|
|||
func TestBuildDistObjectKey(t *testing.T) { |
|||
ok := map[string][3]string{ |
|||
"正常": {"EAIMAR", "android", "eaimar-release-1.0.2-20260923.apk"}, |
|||
"目录带斜杠": {"EAIMAR", "/android/", "a.apk"}, |
|||
"文件名夹带目录": {"EAIMAR", "android", "../../etc/passwd"}, |
|||
"另一个应用": {"deepGlass", "android", "a.apk"}, |
|||
"应用名带斜杠": {"a/../b", "android", "a.apk"}, |
|||
} |
|||
want := map[string]string{ |
|||
"正常": "android/EAIMAR/eaimar-release-1.0.2-20260923.apk", |
|||
"目录带斜杠": "android/EAIMAR/a.apk", |
|||
"文件名夹带目录": "android/EAIMAR/passwd", |
|||
"另一个应用": "android/deepGlass/a.apk", |
|||
"应用名带斜杠": "android/a____b/a.apk", |
|||
} |
|||
for name, in := range ok { |
|||
got, err := buildDistObjectKey(in[0], in[1], in[2]) |
|||
if err != nil { |
|||
t.Errorf("%s: 本该通过,却报错 %v", name, err) |
|||
continue |
|||
} |
|||
if got != want[name] { |
|||
t.Errorf("%s: 期望 %q,得到 %q", name, want[name], got) |
|||
} |
|||
} |
|||
|
|||
bad := map[string][3]string{ |
|||
"没选应用": {"", "android", "a.apk"}, |
|||
"应用名全是非法字符": {"//", "android", "a.apk"}, |
|||
"目录为空": {"EAIMAR", "", "a.apk"}, |
|||
"目录不在白名单": {"EAIMAR", "anything", "a.apk"}, |
|||
"用 .. 跳出去": {"EAIMAR", "android/../secret", "a.apk"}, |
|||
"绝对路径跳根": {"EAIMAR", "/../../", "a.apk"}, |
|||
"文件名为空": {"EAIMAR", "android", " "}, |
|||
} |
|||
for name, in := range bad { |
|||
if got, err := buildDistObjectKey(in[0], in[1], in[2]); err == nil { |
|||
t.Errorf("%s: 本该被拒,却得到 %q", name, got) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// apk 的 Content-Type 必须由服务端强制:它计入预签名,签错了要等真机装包才发现。
|
|||
func TestDistContentTypeForced(t *testing.T) { |
|||
if distContentTypes[".apk"] != "application/vnd.android.package-archive" { |
|||
t.Fatalf("apk 的 Content-Type 不对: %q", distContentTypes[".apk"]) |
|||
} |
|||
} |
|||
@ -1,115 +0,0 @@ |
|||
package mcp |
|||
|
|||
import ( |
|||
"context" |
|||
"yunyan/lego/core" |
|||
"yunyan/lego/core/cbase" |
|||
"yunyan/lego/sys/log" |
|||
"yunyan/sys/musicobj" |
|||
"yunyan/utils" |
|||
"fmt" |
|||
|
|||
"github.com/mark3labs/mcp-go/mcp" |
|||
) |
|||
|
|||
// 音乐工具(搜索+播放)
|
|||
type tool_music struct { |
|||
cbase.ModuleCompBase |
|||
module *Mcp |
|||
} |
|||
|
|||
// 组件初始化接口
|
|||
func (this *tool_music) Init(service core.IService, module core.IModule, comp core.IModuleComp, opt core.IModuleOptions) (err error) { |
|||
this.ModuleCompBase.Init(service, module, comp, opt) |
|||
this.module = module.(*Mcp) |
|||
return |
|||
} |
|||
|
|||
func (this *tool_music) Start() (err error) { |
|||
err = this.ModuleCompBase.Start() |
|||
if this.module.AddTool(ToolGroup_CHINA, this.Tool(), this.Handl) { |
|||
if err := musicobj.OnInit(this.module.service.GetSettings().Sys["musicobj"]); err != nil { |
|||
panic(fmt.Sprintf("init sys.musicobj err: %s", err.Error())) |
|||
} else { |
|||
log.Infof("init sys.musicobj success!") |
|||
} |
|||
} |
|||
return |
|||
} |
|||
|
|||
func (this *tool_music) Tool() mcp.Tool { |
|||
return mcp.NewTool("music_search_play", |
|||
mcp.WithDescription("搜索音乐并返回带播放链接的音乐列表"), |
|||
mcp.WithString("query", |
|||
mcp.Description("搜索的音乐关键字 例如:小苹果, 周杰伦, 张学友, 流行音乐等"), |
|||
mcp.Required(), |
|||
mcp.DefaultString("热门"), |
|||
), |
|||
mcp.WithNumber("limit", |
|||
mcp.Description("返回结果数量 (1-20, default 10)"), |
|||
mcp.DefaultNumber(30), |
|||
), |
|||
mcp.WithNumber("offset", |
|||
mcp.Description("搜索的音乐偏移量 范围在0到9之间 (默认0)"), |
|||
mcp.DefaultNumber(0), |
|||
), |
|||
) |
|||
} |
|||
|
|||
type MusicItem struct { |
|||
Id int64 `json:"id"` |
|||
Name string `json:"name"` |
|||
Singer string `json:"singer"` |
|||
Image string `json:"image"` |
|||
Url string `json:"url"` |
|||
} |
|||
|
|||
func (this *tool_music) Handl(ctx context.Context, request mcp.CallToolRequest) (result *mcp.CallToolResult, err error) { |
|||
var ( |
|||
query string |
|||
limit, offset int |
|||
songslist *musicobj.SearchResultResponse |
|||
) |
|||
if query, err = request.RequireString("query"); err != nil { |
|||
err = fmt.Errorf("query is required") |
|||
return |
|||
} |
|||
limit = request.GetInt("limit", 30) |
|||
offset = request.GetInt("offset", 0) |
|||
if songslist, err = musicobj.SearchMusic(query, limit, offset); err != nil { |
|||
this.module.Errorln(err) |
|||
return |
|||
} |
|||
if len(songslist.Data.Musics) == 0 { |
|||
err = fmt.Errorf("no music found") |
|||
return |
|||
} |
|||
|
|||
musics := make([]*MusicItem, 0, len(songslist.Data.Musics)) |
|||
for _, v := range songslist.Data.Musics { |
|||
item := &MusicItem{ |
|||
Id: v.Id, |
|||
Name: v.Name, |
|||
Singer: v.Singer, |
|||
Image: v.Image, |
|||
} |
|||
if urlResp, urlErr := musicobj.MusicUrl(v.Id); urlErr == nil { |
|||
item.Url = urlResp.Data.URL |
|||
} |
|||
musics = append(musics, item) |
|||
} |
|||
|
|||
text := utils.ToString(map[string]interface{}{ |
|||
"data": musics, |
|||
"result": "success", |
|||
}) |
|||
result = &mcp.CallToolResult{ |
|||
Content: []mcp.Content{ |
|||
mcp.TextContent{ |
|||
Type: "text", |
|||
Text: text, |
|||
}, |
|||
}, |
|||
} |
|||
return |
|||
} |
|||
@ -1,27 +0,0 @@ |
|||
package music |
|||
|
|||
import ( |
|||
"yunyan/lego/base" |
|||
"yunyan/lego/core" |
|||
"yunyan/modules" |
|||
) |
|||
|
|||
type apiComp struct { |
|||
modules.MCompHttpGate |
|||
service base.IRPCXService |
|||
module *Music |
|||
options *Options |
|||
} |
|||
|
|||
func (this *apiComp) Init(service core.IService, module core.IModule, comp core.IModuleComp, options core.IModuleOptions) (err error) { |
|||
this.MCompHttpGate.Init(service, module, comp, options) |
|||
this.service = service.(base.IRPCXService) |
|||
this.module = module.(*Music) |
|||
this.options = options.(*Options) |
|||
return |
|||
} |
|||
|
|||
func (this *apiComp) Start() (err error) { |
|||
err = this.MCompHttpGate.Start() |
|||
return |
|||
} |
|||
@ -1,55 +0,0 @@ |
|||
package music |
|||
|
|||
import ( |
|||
"yunyan/comm" |
|||
"yunyan/pb" |
|||
"yunyan/sys/musicobj" |
|||
"fmt" |
|||
) |
|||
|
|||
// @Summary 获取音乐播放地址
|
|||
// @Description 获取音乐播放地址
|
|||
// @Tags Music
|
|||
// @Accept json
|
|||
// @Produce json
|
|||
// @Security BearerAuth
|
|||
// @Param user body pb.MusicSearchReq true "请求数据"
|
|||
// @Success 200 {object} comm.HttpResult{data=pb.MusicSearchResp} "响应数据"
|
|||
// @Router /api/home/music_search [post]
|
|||
func (this *apiComp) Search(session comm.IUserSession, req *pb.MusicSearchReq) (resp *pb.MusicSearchResp, errdata *pb.ErrorData) { |
|||
var ( |
|||
musics []*pb.DBMusic |
|||
songslist *musicobj.SearchResultResponse |
|||
err error |
|||
) |
|||
|
|||
if songslist, err = musicobj.SearchMusic(req.Keywords, int(req.Limit), int(req.Offset)); err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_MusicSearchError, |
|||
Message: err.Error(), |
|||
} |
|||
return |
|||
} else { |
|||
if len(songslist.Data.Musics) == 0 { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_MusicNotFound, |
|||
Message: fmt.Sprintf("未找到关键字:%s 相关歌单", req.Keywords), |
|||
} |
|||
return |
|||
} |
|||
} |
|||
|
|||
musics = make([]*pb.DBMusic, 0) |
|||
for _, v := range songslist.Data.Musics { |
|||
musiccard := &pb.DBMusic{Id: v.Id} |
|||
musiccard.Name = v.Name |
|||
musiccard.Singer = v.Singer |
|||
musiccard.Image = v.Image |
|||
musics = append(musics, musiccard) |
|||
} |
|||
|
|||
resp = &pb.MusicSearchResp{ |
|||
Musics: musics, |
|||
} |
|||
return |
|||
} |
|||
@ -1,38 +0,0 @@ |
|||
package music |
|||
|
|||
import ( |
|||
"yunyan/comm" |
|||
"yunyan/lego/sys/log" |
|||
"yunyan/pb" |
|||
"yunyan/sys/musicobj" |
|||
) |
|||
|
|||
// @Summary 获取音乐播放地址
|
|||
// @Description 获取音乐播放地址
|
|||
// @Tags Music
|
|||
// @Accept json
|
|||
// @Produce json
|
|||
// @Security BearerAuth
|
|||
// @Param user body pb.MusicUrlReq true "请求数据"
|
|||
// @Success 200 {object} comm.HttpResult{data=pb.MusicUrlResp} "响应数据"
|
|||
// @Router /api/home/music_url [post]
|
|||
func (this *apiComp) Url(session comm.IUserSession, req *pb.MusicUrlReq) (resp *pb.MusicUrlResp, errdata *pb.ErrorData) { |
|||
var ( |
|||
result *musicobj.UrlResultResponse |
|||
err error |
|||
) |
|||
if result, err = musicobj.MusicUrl(req.Id); err != nil { |
|||
errdata = &pb.ErrorData{ |
|||
Code: pb.ErrorCode_MusicUrlError, |
|||
Message: err.Error(), |
|||
} |
|||
this.module.Error("GetMusicUrl Fail!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "req", Value: req.String()}, log.Field{Key: "err", Value: err.Error()}) |
|||
return |
|||
} |
|||
resp = &pb.MusicUrlResp{ |
|||
Id: req.Id, |
|||
Url: result.Data.URL, |
|||
Ts: result.Data.Ts, |
|||
} |
|||
return |
|||
} |
|||
@ -1,26 +0,0 @@ |
|||
package music |
|||
|
|||
import ( |
|||
"net/http" |
|||
|
|||
"github.com/gin-gonic/gin" |
|||
) |
|||
|
|||
// 设置跨域
|
|||
func cors() gin.HandlerFunc { |
|||
return func(c *gin.Context) { |
|||
method := c.Request.Method |
|||
origin := c.Request.Header.Get("Origin") |
|||
if origin != "" { |
|||
c.Header("Access-Control-Allow-Origin", "*") // 可将将 * 替换为指定的域名
|
|||
c.Header("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE, UPDATE") |
|||
c.Header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization") |
|||
c.Header("Access-Control-Expose-Headers", "Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Cache-Control, Content-Language, Content-Type") |
|||
c.Header("Access-Control-Allow-Credentials", "true") |
|||
} |
|||
if method == "OPTIONS" { |
|||
c.AbortWithStatus(http.StatusNoContent) |
|||
} |
|||
c.Next() |
|||
} |
|||
} |
|||
@ -1,20 +0,0 @@ |
|||
package music |
|||
|
|||
import ( |
|||
"yunyan/lego/core" |
|||
"yunyan/lego/core/cbase" |
|||
) |
|||
|
|||
// 代理模型
|
|||
type modelComp struct { |
|||
cbase.ModuleCompBase |
|||
module *Music |
|||
} |
|||
|
|||
// 组件初始化接口
|
|||
func (this *modelComp) Init(service core.IService, module core.IModule, comp core.IModuleComp, opt core.IModuleOptions) (err error) { |
|||
this.ModuleCompBase.Init(service, module, comp, opt) |
|||
this.module = module.(*Music) |
|||
|
|||
return |
|||
} |
|||
@ -1 +0,0 @@ |
|||
package music_test |
|||
@ -1,49 +0,0 @@ |
|||
package music |
|||
|
|||
import ( |
|||
"yunyan/comm" |
|||
"yunyan/modules" |
|||
|
|||
"yunyan/lego/core" |
|||
) |
|||
|
|||
func NewModule() core.IModule { |
|||
m := new(Music) |
|||
return m |
|||
} |
|||
|
|||
type Music struct { |
|||
modules.ModuleBase |
|||
api *apiComp |
|||
model *modelComp |
|||
options *Options |
|||
} |
|||
|
|||
func (this *Music) GetType() core.M_Modules { |
|||
return comm.ModuleMusic |
|||
} |
|||
|
|||
func (this *Music) NewOptions() (options core.IModuleOptions) { |
|||
return new(Options) |
|||
} |
|||
|
|||
func (this *Music) Init(service core.IService, module core.IModule, options core.IModuleOptions) (err error) { |
|||
this.options = options.(*Options) |
|||
if err = this.ModuleBase.Init(service, module, options); err != nil { |
|||
return |
|||
} |
|||
return |
|||
} |
|||
|
|||
func (this *Music) Start() (err error) { |
|||
if err = this.ModuleBase.Start(); err != nil { |
|||
return |
|||
} |
|||
return |
|||
} |
|||
|
|||
func (this *Music) OnInstallComp() { |
|||
this.ModuleBase.OnInstallComp() |
|||
this.api = this.RegisterComp(new(apiComp)).(*apiComp) |
|||
this.model = this.RegisterComp(new(modelComp)).(*modelComp) |
|||
} |
|||
@ -1,27 +0,0 @@ |
|||
package music |
|||
|
|||
import ( |
|||
"yunyan/modules" |
|||
|
|||
"yunyan/lego/utils/mapstructure" |
|||
) |
|||
|
|||
type ( |
|||
Options struct { |
|||
modules.Options |
|||
GinDebug bool //web引擎日志开关
|
|||
ListenPort int //websocket 监听端口
|
|||
} |
|||
) |
|||
|
|||
func (this *Options) LoadConfig(settings map[string]interface{}) (err error) { |
|||
if settings != nil { |
|||
if err = this.Options.LoadConfig(settings); err != nil { |
|||
return |
|||
} |
|||
if err = mapstructure.Decode(settings, this); err != nil { |
|||
return |
|||
} |
|||
} |
|||
return |
|||
} |
|||
@ -1,623 +0,0 @@ |
|||
package music |
|||
|
|||
type ( |
|||
|
|||
// MusicResponse 顶层响应结构
|
|||
SearchResultResponse struct { |
|||
Result struct { |
|||
SearchQcReminder interface{} `json:"searchQcReminder"` |
|||
Songs []struct { |
|||
Name string `json:"name"` |
|||
Id int64 `json:"id"` |
|||
Pst int `json:"pst"` |
|||
T int `json:"t"` |
|||
Ar []struct { // 内嵌艺术家结构
|
|||
Id int64 `json:"id"` |
|||
Name string `json:"name"` |
|||
Tns []string `json:"tns"` |
|||
Alias []string `json:"alias"` |
|||
Alia []string `json:"alia"` |
|||
} `json:"ar"` |
|||
Alia []string `json:"alia"` |
|||
Pop int `json:"pop"` |
|||
St int `json:"st"` |
|||
Rt string `json:"rt"` |
|||
Fee int `json:"fee"` |
|||
V int `json:"v"` |
|||
Crbt interface{} `json:"crbt"` |
|||
Cf string `json:"cf"` |
|||
Al struct { // 内嵌专辑结构
|
|||
Id int64 `json:"id"` |
|||
Name string `json:"name"` |
|||
PicUrl string `json:"picUrl"` |
|||
Tns []string `json:"tns"` |
|||
PicStr string `json:"pic_str"` |
|||
Pic int64 `json:"pic"` |
|||
} `json:"al"` |
|||
Dt int `json:"dt"` |
|||
H struct { // 内嵌高品质音频信息
|
|||
Br int `json:"br"` |
|||
Fid int `json:"fid"` |
|||
Size int `json:"size"` |
|||
Vd int `json:"vd"` |
|||
Sr int `json:"sr"` |
|||
} `json:"h"` |
|||
M struct { // 内嵌中品质音频信息
|
|||
Br int `json:"br"` |
|||
Fid int `json:"fid"` |
|||
Size int `json:"size"` |
|||
Vd int `json:"vd"` |
|||
Sr int `json:"sr"` |
|||
} `json:"m"` |
|||
L struct { // 内嵌低品质音频信息
|
|||
Br int `json:"br"` |
|||
Fid int `json:"fid"` |
|||
Size int `json:"size"` |
|||
Vd int `json:"vd"` |
|||
Sr int `json:"sr"` |
|||
} `json:"l"` |
|||
Sq struct { // 内嵌无损品质音频信息
|
|||
Br int `json:"br"` |
|||
Fid int `json:"fid"` |
|||
Size int `json:"size"` |
|||
Vd int `json:"vd"` |
|||
Sr int `json:"sr"` |
|||
} `json:"sq"` |
|||
Hr struct { // 内嵌高清品质音频信息
|
|||
Br int `json:"br"` |
|||
Fid int `json:"fid"` |
|||
Size int `json:"size"` |
|||
Vd int `json:"vd"` |
|||
Sr int `json:"sr"` |
|||
} `json:"hr"` |
|||
A interface{} `json:"a"` |
|||
Cd string `json:"cd"` |
|||
No int `json:"no"` |
|||
RtUrl interface{} `json:"rtUrl"` |
|||
Ftype int `json:"ftype"` |
|||
RtUrls []interface{} `json:"rtUrls"` |
|||
DjId int `json:"djId"` |
|||
Copyright int `json:"copyright"` |
|||
SId int `json:"s_id"` |
|||
Mark int64 `json:"mark"` |
|||
OriginCoverType int `json:"originCoverType"` |
|||
OriginSongSimpleData interface{} `json:"originSongSimpleData"` |
|||
TagPicList interface{} `json:"tagPicList"` |
|||
ResourceState bool `json:"resourceState"` |
|||
Version int `json:"version"` |
|||
SongJumpInfo interface{} `json:"songJumpInfo"` |
|||
EntertainmentTags interface{} `json:"entertainmentTags"` |
|||
Single int `json:"single"` |
|||
NoCopyrightRcmd interface{} `json:"noCopyrightRcmd"` |
|||
Rtype int `json:"rtype"` |
|||
Rurl interface{} `json:"rurl"` |
|||
Mst int `json:"mst"` |
|||
Cp int `json:"cp"` |
|||
Mv int `json:"mv"` |
|||
PublishTime int `json:"publishTime"` |
|||
Privilege struct { // 内嵌权限信息结构
|
|||
Id int64 `json:"id"` |
|||
Fee int `json:"fee"` |
|||
Payed int `json:"payed"` |
|||
St int `json:"st"` |
|||
Pl int `json:"pl"` |
|||
Dl int `json:"dl"` |
|||
Sp int `json:"sp"` |
|||
Cp int `json:"cp"` |
|||
Subp int `json:"subp"` |
|||
Cs bool `json:"cs"` |
|||
Maxbr int `json:"maxbr"` |
|||
Fl int `json:"fl"` |
|||
Toast bool `json:"toast"` |
|||
Flag int `json:"flag"` |
|||
PreSell bool `json:"preSell"` |
|||
PlayMaxbr int `json:"playMaxbr"` |
|||
DownloadMaxbr int `json:"downloadMaxbr"` |
|||
MaxBrLevel string `json:"maxBrLevel"` |
|||
PlayMaxBrLevel string `json:"playMaxBrLevel"` |
|||
DownloadMaxBrLevel string `json:"downloadMaxBrLevel"` |
|||
PlLevel string `json:"plLevel"` |
|||
DlLevel string `json:"dlLevel"` |
|||
FlLevel string `json:"flLevel"` |
|||
Rscl interface{} `json:"rscl"` |
|||
FreeTrialPrivilege struct { // 内嵌免费试用权限
|
|||
ResConsumable bool `json:"resConsumable"` |
|||
UserConsumable bool `json:"userConsumable"` |
|||
ListenType interface{} `json:"listenType"` |
|||
CannotListenReason interface{} `json:"cannotListenReason"` |
|||
PlayReason interface{} `json:"playReason"` |
|||
FreeLimitTagType interface{} `json:"freeLimitTagType"` |
|||
} `json:"freeTrialPrivilege"` |
|||
RightSource int `json:"rightSource"` |
|||
ChargeInfoList []struct { // 内嵌收费信息列表
|
|||
Rate int `json:"rate"` |
|||
ChargeUrl interface{} `json:"chargeUrl"` |
|||
ChargeMessage interface{} `json:"chargeMessage"` |
|||
ChargeType int `json:"chargeType"` |
|||
} `json:"chargeInfoList"` |
|||
Code int `json:"code"` |
|||
Message interface{} `json:"message"` |
|||
PlLevels interface{} `json:"plLevels"` |
|||
DlLevels interface{} `json:"dlLevels"` |
|||
IgnoreCache interface{} `json:"ignoreCache"` |
|||
Bd interface{} `json:"bd"` |
|||
} `json:"privilege"` |
|||
} `json:"songs"` |
|||
SongCount int `json:"songCount"` |
|||
} `json:"result"` |
|||
Code int `json:"code"` |
|||
} |
|||
|
|||
FreeTrialInfo struct { |
|||
ResConsumable bool `json:"resConsumable"` |
|||
UserConsumable bool `json:"userConsumable"` |
|||
ListenType interface{} `json:"listenType"` |
|||
CannotListenReason interface{} `json:"cannotListenReason"` |
|||
PlayReason interface{} `json:"playReason"` |
|||
FreeLimitTagType interface{} `json:"freeLimitTagType"` |
|||
} |
|||
|
|||
FreeTimeTrialPrivilege struct { |
|||
ResConsumable bool `json:"resConsumable"` |
|||
UserConsumable bool `json:"userConsumable"` |
|||
Type int `json:"type"` |
|||
RemainTime int `json:"remainTime"` |
|||
} |
|||
|
|||
UrlResult struct { |
|||
Data struct { |
|||
Size int `json:"size"` |
|||
Br int `json:"br"` |
|||
URL string `json:"url"` |
|||
MD5 string `json:"md5"` |
|||
Source string `json:"source"` |
|||
} `json:"data"` |
|||
Code int `json:"code"` |
|||
} |
|||
//歌单搜索
|
|||
PlaylistSearchResponse struct { |
|||
Code int `json:"code"` // 响应状态码
|
|||
|
|||
Result struct { |
|||
PlaylistCount int `json:"playlistCount"` // 歌单总数
|
|||
HasMore bool `json:"hasMore"` // 是否有更多数据
|
|||
HlWords []string `json:"hlWords"` // 高亮词(通常为搜索关键词)
|
|||
SearchQcReminder interface{} `json:"searchQcReminder"` // 提示信息(暂无结构)
|
|||
|
|||
Playlists []struct { |
|||
ID int64 `json:"id"` // 歌单 ID
|
|||
Name string `json:"name"` // 歌单名称
|
|||
CoverImgUrl string `json:"coverImgUrl"` // 歌单封面图
|
|||
Subscribed bool `json:"subscribed"` // 是否已收藏
|
|||
TrackCount int `json:"trackCount"` // 歌曲数量
|
|||
UserId int64 `json:"userId"` // 创建者用户 ID
|
|||
PlayCount int64 `json:"playCount"` // 播放次数
|
|||
BookCount int64 `json:"bookCount"` // 收藏次数
|
|||
SpecialType int `json:"specialType"` // 特殊标记(如官方歌单)
|
|||
Action string `json:"action"` // 播放链接
|
|||
ActionType string `json:"actionType"` // 动作类型
|
|||
RecommendText interface{} `json:"recommendText"` // 推荐文案(可能为空)
|
|||
Score string `json:"score"` // 推荐分数(字符串)
|
|||
Description string `json:"description"` // 歌单描述
|
|||
HighQuality bool `json:"highQuality"` // 是否高品质歌单
|
|||
OfficialTags []string `json:"officialTags"` // 官方标签
|
|||
|
|||
Creator struct { |
|||
Nickname string `json:"nickname"` // 创建者昵称
|
|||
UserId int64 `json:"userId"` // 用户 ID
|
|||
UserType int `json:"userType"` // 用户类型
|
|||
AvatarUrl string `json:"avatarUrl"` // 用户头像
|
|||
AuthStatus int `json:"authStatus"` // 认证状态
|
|||
ExpertTags interface{} `json:"expertTags"` // 专家标签(空或未知结构)
|
|||
Experts interface{} `json:"experts"` // 专家信息(空或未知结构)
|
|||
} `json:"creator"` // 创建者信息
|
|||
|
|||
Track struct { |
|||
Name string `json:"name"` // 歌曲名称
|
|||
ID int64 `json:"id"` // 歌曲 ID
|
|||
Position int `json:"position"` // 歌曲在歌单中的位置
|
|||
Alias []string `json:"alias"` // 别名
|
|||
Status int `json:"status"` // 状态
|
|||
Fee int `json:"fee"` // 费用
|
|||
CopyrightId int `json:"copyrightId"` // 版权 ID
|
|||
Disc string `json:"disc"` // 所属碟片
|
|||
No int `json:"no"` // 曲目编号
|
|||
|
|||
Artists []struct { |
|||
Name string `json:"name"` // 歌手名
|
|||
ID int64 `json:"id"` // 歌手 ID
|
|||
PicId int64 `json:"picId"` |
|||
Img1v1Id int64 `json:"img1v1Id"` |
|||
BriefDesc string `json:"briefDesc"` |
|||
PicUrl string `json:"picUrl"` |
|||
Img1v1Url string `json:"img1v1Url"` |
|||
AlbumSize int `json:"albumSize"` |
|||
Alias []string `json:"alias"` |
|||
Trans string `json:"trans"` |
|||
MusicSize int `json:"musicSize"` |
|||
TopicPerson int `json:"topicPerson"` |
|||
} `json:"artists"` // 歌手列表
|
|||
|
|||
Album struct { |
|||
Name string `json:"name"` // 专辑名称
|
|||
ID int64 `json:"id"` // 专辑 ID
|
|||
IdStr *string `json:"idStr"` // ID 字符串
|
|||
Type string `json:"type"` // 类型
|
|||
Size int `json:"size"` // 歌曲数
|
|||
PicId int64 `json:"picId"` // 封面图 ID
|
|||
BlurPicUrl string `json:"blurPicUrl"` // 模糊封面图
|
|||
CompanyId int `json:"companyId"` |
|||
Pic int64 `json:"pic"` |
|||
PicUrl string `json:"picUrl"` |
|||
PublishTime int64 `json:"publishTime"` // 发布时间
|
|||
Description string `json:"description"` // 描述
|
|||
Tags string `json:"tags"` // 标签
|
|||
Company string `json:"company"` // 出版公司
|
|||
BriefDesc string `json:"briefDesc"` // 简要描述
|
|||
|
|||
Artist struct { |
|||
Name string `json:"name"` |
|||
ID int64 `json:"id"` |
|||
PicId int64 `json:"picId"` |
|||
Img1v1Id int64 `json:"img1v1Id"` |
|||
BriefDesc string `json:"briefDesc"` |
|||
PicUrl string `json:"picUrl"` |
|||
Img1v1Url string `json:"img1v1Url"` |
|||
AlbumSize int `json:"albumSize"` |
|||
Alias []string `json:"alias"` |
|||
Trans string `json:"trans"` |
|||
MusicSize int `json:"musicSize"` |
|||
TopicPerson int `json:"topicPerson"` |
|||
} `json:"artist"` |
|||
|
|||
Songs []interface{} `json:"songs"` // 预留字段
|
|||
Alias []string `json:"alias"` |
|||
Status int `json:"status"` |
|||
CopyrightId int `json:"copyrightId"` |
|||
CommentThreadId string `json:"commentThreadId"` |
|||
Artists []struct { |
|||
Name string `json:"name"` |
|||
ID int64 `json:"id"` |
|||
PicId int64 `json:"picId"` |
|||
Img1v1Id int64 `json:"img1v1Id"` |
|||
BriefDesc string `json:"briefDesc"` |
|||
PicUrl string `json:"picUrl"` |
|||
Img1v1Url string `json:"img1v1Url"` |
|||
AlbumSize int `json:"albumSize"` |
|||
Alias []string `json:"alias"` |
|||
Trans string `json:"trans"` |
|||
MusicSize int `json:"musicSize"` |
|||
TopicPerson int `json:"topicPerson"` |
|||
} `json:"artists"` |
|||
|
|||
OnSale bool `json:"onSale"` |
|||
PicIdStr string `json:"picId_str"` |
|||
} `json:"album"` |
|||
|
|||
Starred bool `json:"starred"` |
|||
Popularity int `json:"popularity"` |
|||
Score int `json:"score"` |
|||
StarredNum int `json:"starredNum"` |
|||
Duration int `json:"duration"` // 时长(毫秒)
|
|||
PlayedNum int `json:"playedNum"` |
|||
DayPlays int `json:"dayPlays"` |
|||
HearTime int `json:"hearTime"` |
|||
Ringtone string `json:"ringtone"` |
|||
CRBT interface{} `json:"crbt"` |
|||
Audition interface{} `json:"audition"` |
|||
CopyFrom string `json:"copyFrom"` |
|||
CommentThreadId string `json:"commentThreadId"` |
|||
RtUrl interface{} `json:"rtUrl"` |
|||
Ftype int `json:"ftype"` |
|||
RtUrls []string `json:"rtUrls"` |
|||
Copyright int `json:"copyright"` |
|||
|
|||
HMusic struct { |
|||
Name *string `json:"name"` |
|||
ID int64 `json:"id"` |
|||
Size int `json:"size"` |
|||
Extension string `json:"extension"` |
|||
Sr int `json:"sr"` |
|||
DfsId int64 `json:"dfsId"` |
|||
Bitrate int `json:"bitrate"` |
|||
PlayTime int `json:"playTime"` |
|||
VolumeDelta int `json:"volumeDelta"` |
|||
} `json:"hMusic"` |
|||
|
|||
MMusic struct { |
|||
Name *string `json:"name"` |
|||
ID int64 `json:"id"` |
|||
Size int `json:"size"` |
|||
Extension string `json:"extension"` |
|||
Sr int `json:"sr"` |
|||
DfsId int64 `json:"dfsId"` |
|||
Bitrate int `json:"bitrate"` |
|||
PlayTime int `json:"playTime"` |
|||
VolumeDelta int `json:"volumeDelta"` |
|||
} `json:"mMusic"` |
|||
|
|||
LMusic struct { |
|||
Name *string `json:"name"` |
|||
ID int64 `json:"id"` |
|||
Size int `json:"size"` |
|||
Extension string `json:"extension"` |
|||
Sr int `json:"sr"` |
|||
DfsId int64 `json:"dfsId"` |
|||
Bitrate int `json:"bitrate"` |
|||
PlayTime int `json:"playTime"` |
|||
VolumeDelta int `json:"volumeDelta"` |
|||
} `json:"lMusic"` |
|||
|
|||
BMusic struct { |
|||
Name *string `json:"name"` |
|||
ID int64 `json:"id"` |
|||
Size int `json:"size"` |
|||
Extension string `json:"extension"` |
|||
Sr int `json:"sr"` |
|||
DfsId int64 `json:"dfsId"` |
|||
Bitrate int `json:"bitrate"` |
|||
PlayTime int `json:"playTime"` |
|||
VolumeDelta int `json:"volumeDelta"` |
|||
} `json:"bMusic"` |
|||
|
|||
Mvid int `json:"mvid"` |
|||
Rtype int `json:"rtype"` |
|||
Rurl interface{} `json:"rurl"` |
|||
Mp3Url interface{} `json:"mp3Url"` |
|||
} `json:"track"` |
|||
|
|||
Alg string `json:"alg"` // 推荐算法字段
|
|||
} `json:"playlists"` |
|||
} `json:"result"` |
|||
} |
|||
|
|||
SongResponse struct { |
|||
Songs []struct { |
|||
Name string `json:"name"` // 歌曲名称
|
|||
MainTitle *string `json:"mainTitle"` // 主标题
|
|||
AdditionalTitle *string `json:"additionalTitle"` // 附加标题
|
|||
ID int `json:"id"` // 歌曲 ID
|
|||
Pst int `json:"pst"` // 排序字段
|
|||
T int `json:"t"` // 类型字段
|
|||
Ar []struct { |
|||
ID int `json:"id"` // 歌手 ID
|
|||
Name string `json:"name"` // 歌手名
|
|||
Tns []string `json:"tns"` // 翻译名
|
|||
Alias []string `json:"alias "` // 别名
|
|||
} `json:"ar"` |
|||
Alia []string `json:"alia"` // 歌曲别名
|
|||
Pop int `json:"pop"` // 热度
|
|||
St int `json:"st"` // 状态码
|
|||
Rt string `json:"rt"` // 资源标识
|
|||
Fee int `json:"费用"` // 是否收费
|
|||
V int `json:"v "` // 版本
|
|||
Crbt *string `json:"crbt"` // 彩铃信息
|
|||
Cf string `json:"cf"` // 未知字段
|
|||
Al struct { |
|||
ID int `json:"id"` // 专辑 ID
|
|||
Name string `json:"name"` // 专辑名
|
|||
PicURL string `json:"picUrl"` // 专辑封面
|
|||
Tns []string `json:"tns"` // 翻译名
|
|||
PicStr string `json:"pic_str"` // 图片字符串 ID
|
|||
Pic int64 `json:"pic"` // 图片 ID
|
|||
} `json:"al"` |
|||
Dt int `json:"dt"` // 时长(毫秒)
|
|||
|
|||
H struct{ Br, Fid, Size, Vd, Sr int } `json:"h"` // 高音质
|
|||
M struct{ Br, Fid, Size, Vd, Sr int } `json:"m"` // 中音质
|
|||
L struct{ Br, Fid, Size, Vd, Sr int } `json:"l"` // 低音质
|
|||
Sq struct{ Br, Fid, Size, Vd, Sr int } `json:"sq"` // 无损音质
|
|||
|
|||
Hr interface{} `json:"hr"` |
|||
A *string `json:"a"` |
|||
Cd string `json:"cd"` |
|||
No int `json:"no"` |
|||
RtUrl *string `json:"rtUrl"` |
|||
Ftype int `json:"ftype"` |
|||
RtUrls []string `json:"rtUrls"` |
|||
DjId int `json:"djId"` |
|||
Copyright int `json:"copyright"` |
|||
SId int `json:"s_id"` |
|||
Mark int64 `json:"mark"` |
|||
OriginCoverType int `json:"originCoverType"` |
|||
Tns []string `json:"tns"` |
|||
OriginSongSimpleData interface{} `json:"originSongSimpleData"` |
|||
TagPicList *string `json:"tagPicList"` |
|||
ResourceState bool `json:"resourceState"` |
|||
Version int `json:"version"` |
|||
SongJumpInfo *string `json:"songJumpInfo"` |
|||
EntertainmentTags *string `json:"entertainmentTags"` |
|||
AwardTags *string `json:"awardTags"` |
|||
DisplayTags *string `json:"displayTags"` |
|||
Single int `json:"single"` |
|||
NoCopyrightRcmd *string `json:"noCopyrightRcmd"` |
|||
Mv int `json:"mv"` |
|||
Rtype int `json:"rtype"` |
|||
Rurl *string `json:"rurl"` |
|||
Mst int `json:"mst"` |
|||
Cp int `json:"cp"` |
|||
PublishTime int64 `json:"publishTime"` |
|||
} `json:"songs"` |
|||
|
|||
Privileges []struct { |
|||
ID int `json:"id"` |
|||
Fee int `json:"fee"` |
|||
Payed int `json:"payed"` |
|||
St int `json:"st"` |
|||
Pl int `json:"pl"` |
|||
Dl int `json:"dl"` |
|||
Sp int `json:"sp"` |
|||
Cp int `json:"cp"` |
|||
Subp int `json:"subp"` |
|||
Cs bool `json:"cs"` |
|||
Maxbr int `json:"maxbr"` |
|||
Fl int `json:"fl"` |
|||
Toast bool `json:"toast"` |
|||
Flag int `json:"flag"` |
|||
PreSell bool `json:"preSell"` |
|||
PlayMaxbr int `json:"playMaxbr"` |
|||
DownloadMaxbr int `json:"downloadMaxbr"` |
|||
MaxBrLevel string `json:"maxBrLevel"` |
|||
PlayMaxBrLevel string `json:"playMaxBrLevel"` |
|||
DownloadMaxBrLevel string `json:"downloadMaxBrLevel"` |
|||
PlLevel string `json:"plLevel"` |
|||
DlLevel string `json:"dlLevel"` |
|||
FlLevel string `json:"flLevel"` |
|||
Rscl interface{} `json:"rscl"` |
|||
FreeTrialPrivilege struct { |
|||
ResConsumable bool `json:"resConsumable"` |
|||
UserConsumable bool `json:"userConsumable"` |
|||
ListenType interface{} `json:"listenType"` |
|||
CannotListenReason interface{} `json:"cannotListenReason"` |
|||
PlayReason interface{} `json:"playReason"` |
|||
FreeLimitTagType interface{} `json:"freeLimitTagType"` |
|||
} `json:"freeTrialPrivilege"` |
|||
RightSource int `json:"rightSource"` |
|||
ChargeInfoList []struct { |
|||
Rate int `json:"rate"` |
|||
ChargeUrl interface{} `json:"chargeUrl"` |
|||
ChargeMessage interface{} `json:"chargeMessage"` |
|||
ChargeType int `json:"chargeType"` |
|||
} `json:"chargeInfoList"` |
|||
Code int `json:"code"` |
|||
Message *string `json:"message"` |
|||
} `json:"privileges"` |
|||
|
|||
Code int `json:"code"` // 响应状态码
|
|||
} |
|||
//播放列表详情回复
|
|||
MusicListDetailResponse struct { |
|||
Code int `json:"code"` // 响应状态码
|
|||
|
|||
Songs []struct { |
|||
Name string `json:"name"` // 歌曲名称
|
|||
MainTitle *string `json:"mainTitle"` // 主标题(可选)
|
|||
AdditionalTitle *string `json:"additionalTitle"` // 副标题(可选)
|
|||
ID int64 `json:"id"` // 歌曲 ID
|
|||
Pst int `json:"pst"` |
|||
T int `json:"t"` |
|||
|
|||
Ar []struct { |
|||
ID int64 `json:"id"` // 歌手 ID
|
|||
Name string `json:"name"` // 歌手名称
|
|||
Tns []string `json:"tns"` // 翻译名
|
|||
Alias []string `json:"alias"` // 别名
|
|||
} `json:"ar"` // 歌手列表
|
|||
|
|||
Al struct { |
|||
ID int64 `json:"id"` // 专辑 ID
|
|||
Name string `json:"name"` // 专辑名称
|
|||
PicURL string `json:"picUrl"` // 专辑封面 URL
|
|||
PicStr string `json:"pic_str"` // 封面图字符串
|
|||
Pic int64 `json:"pic"` // 封面图 ID
|
|||
Tns []string `json:"tns"` // 专辑翻译名
|
|||
} `json:"al"` // 专辑信息
|
|||
|
|||
Pop int `json:"pop"` // 热度
|
|||
|
|||
Fee int `json:"fee"` // 版权费用标记
|
|||
|
|||
DT int `json:"dt"` // 时长(单位:毫秒)
|
|||
|
|||
H struct { |
|||
Br int `json:"br"` // 码率
|
|||
Size int `json:"size"` // 文件大小(字节)
|
|||
Vd int `json:"vd"` // 音量动态
|
|||
Sr int `json:"sr"` // 采样率
|
|||
Fid int `json:"fid"` // 文件 ID
|
|||
} `json:"h"` // 高音质
|
|||
|
|||
M struct { |
|||
Br int `json:"br"` |
|||
Size int `json:"size"` |
|||
Vd int `json:"vd"` |
|||
Sr int `json:"sr"` |
|||
Fid int `json:"fid"` |
|||
} `json:"m"` // 中音质
|
|||
|
|||
L struct { |
|||
Br int `json:"br"` |
|||
Size int `json:"size"` |
|||
Vd int `json:"vd"` |
|||
Sr int `json:"sr"` |
|||
Fid int `json:"fid"` |
|||
} `json:"l"` // 低音质
|
|||
|
|||
SQ *struct { |
|||
Br int `json:"br"` |
|||
Size int `json:"size"` |
|||
Vd int `json:"vd"` |
|||
Sr int `json:"sr"` |
|||
Fid int `json:"fid"` |
|||
} `json:"sq,omitempty"` // 超高音质(可选)
|
|||
|
|||
MV int `json:"mv"` // MV ID
|
|||
PublishTime int64 `json:"publishTime"` // 发布时间(时间戳)
|
|||
Tns []string `json:"tns"` // 歌曲翻译名
|
|||
} `json:"songs"` |
|||
|
|||
Privileges []struct { |
|||
ID int64 `json:"id"` // 歌曲 ID
|
|||
Fee int `json:"fee"` // 收费类型
|
|||
Payed int `json:"payed"` // 是否已付费
|
|||
ST int `json:"st"` // 状态码
|
|||
Pl int `json:"pl"` // 播放权限
|
|||
Dl int `json:"dl"` // 下载权限
|
|||
Sp int `json:"sp"` // 播放标记
|
|||
Cp int `json:"cp"` // 复制权限
|
|||
Subp int `json:"subp"` // 订阅权限
|
|||
Maxbr int `json:"maxbr"` // 最大码率
|
|||
Flag int `json:"flag"` // 标记位
|
|||
Fl int `json:"fl"` // 免费播放标记
|
|||
|
|||
ChargeInfoList []struct { |
|||
Rate int `json:"rate"` // 码率
|
|||
ChargeType int `json:"chargeType"` // 收费类型
|
|||
ChargeUrl string `json:"chargeUrl"` // 收费链接(一般为空)
|
|||
ChargeMsg string `json:"chargeMessage"` // 提示信息(一般为空)
|
|||
} `json:"chargeInfoList"` // 收费信息列表
|
|||
|
|||
FreeTrialPrivilege struct { |
|||
ResConsumable bool `json:"resConsumable"` // 资源是否可消费
|
|||
UserConsumable bool `json:"userConsumable"` // 用户是否可试听
|
|||
ListenType interface{} `json:"listenType"` // 听歌类型(可空)
|
|||
CannotListenReason interface{} `json:"cannotListenReason"` // 无法播放原因(可空)
|
|||
} `json:"freeTrialPrivilege"` // 试听权限设置
|
|||
} `json:"privileges"` |
|||
} |
|||
|
|||
ISys interface { |
|||
SearchMusic(keywords string, limit, offset int) (result *SearchResultResponse, err error) |
|||
SearchMusicList(keywords string, limit, offset int) (result *PlaylistSearchResponse, err error) |
|||
MusicListDetail(id int64, limit, offset int) (result *MusicListDetailResponse, err error) |
|||
MusicUrl(id int64) (result *UrlResult, err error) |
|||
} |
|||
) |
|||
|
|||
var defsys ISys |
|||
|
|||
func OnInit(config map[string]interface{}, option ...Option) (err error) { |
|||
defsys, err = newSys(newOptions(config, option...)) |
|||
return |
|||
} |
|||
|
|||
func NewSys(option ...Option) (sys ISys, err error) { |
|||
sys, err = newSys(newOptionsByOption(option...)) |
|||
return |
|||
} |
|||
|
|||
func SearchMusic(keywords string, limit, offset int) (result *SearchResultResponse, err error) { |
|||
return defsys.SearchMusic(keywords, limit, offset) |
|||
} |
|||
|
|||
func SearchMusicList(keywords string, limit, offset int) (result *PlaylistSearchResponse, err error) { |
|||
return defsys.SearchMusicList(keywords, limit, offset) |
|||
} |
|||
|
|||
func MusicListDetail(id int64, limit, offset int) (result *MusicListDetailResponse, err error) { |
|||
return defsys.MusicListDetail(id, limit, offset) |
|||
} |
|||
|
|||
func MusicUrl(id int64) (result *UrlResult, err error) { |
|||
return defsys.MusicUrl(id) |
|||
} |
|||
@ -1,187 +0,0 @@ |
|||
package music |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"fmt" |
|||
"io" |
|||
"net/http" |
|||
"net/url" |
|||
) |
|||
|
|||
func newSys(options Options) (sys *Music, err error) { |
|||
sys = &Music{ |
|||
options: options, |
|||
} |
|||
return |
|||
} |
|||
|
|||
type Music struct { |
|||
options Options |
|||
} |
|||
|
|||
// 搜索歌曲
|
|||
func (this *Music) SearchMusic(keywords string, limit, offset int) (result *SearchResultResponse, err error) { |
|||
var ( |
|||
req *http.Request |
|||
resp *http.Response |
|||
body []byte |
|||
) |
|||
keywords = url.QueryEscape(keywords) |
|||
url := fmt.Sprintf("%s/cloudsearch?keywords=%s&limit=%d&offset=%d", this.options.ApiBaseUrl, keywords, limit, offset) |
|||
|
|||
req, err = http.NewRequest("GET", url, nil) |
|||
if err != nil { |
|||
return |
|||
} |
|||
|
|||
// 执行请求
|
|||
client := &http.Client{} |
|||
resp, err = client.Do(req) |
|||
if err != nil { |
|||
return |
|||
} |
|||
defer resp.Body.Close() |
|||
if resp.StatusCode != http.StatusOK { |
|||
err = fmt.Errorf("StatusCode:%d", resp.StatusCode) |
|||
} |
|||
// 读取响应内容
|
|||
body, err = io.ReadAll(resp.Body) |
|||
if err != nil { |
|||
return |
|||
} |
|||
// 输出响应内容
|
|||
// fmt.Println(string(body))
|
|||
result = &SearchResultResponse{} |
|||
err = json.Unmarshal(body, result) |
|||
return |
|||
} |
|||
|
|||
// 搜索歌单
|
|||
func (this *Music) SearchMusicList(keywords string, limit, offset int) (result *PlaylistSearchResponse, err error) { |
|||
var ( |
|||
req *http.Request |
|||
resp *http.Response |
|||
body []byte |
|||
) |
|||
keywords = url.QueryEscape(keywords) |
|||
url := fmt.Sprintf("%s/search?type=1000&keywords=%s&limit=%d&offset=%d", this.options.ApiBaseUrl, keywords, limit, offset) |
|||
|
|||
req, err = http.NewRequest("GET", url, nil) |
|||
if err != nil { |
|||
return |
|||
} |
|||
|
|||
// 设置请求头
|
|||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7") |
|||
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9") |
|||
req.Header.Set("Cache-Control", "no-cache") |
|||
req.Header.Set("Connection", "keep-alive") |
|||
req.Header.Set("Pragma", "no-cache") |
|||
req.Header.Set("Sec-Fetch-Dest", "document") |
|||
req.Header.Set("Sec-Fetch-Mode", "navigate") |
|||
req.Header.Set("Sec-Fetch-Site", "none") |
|||
req.Header.Set("Sec-Fetch-User", "?1") |
|||
req.Header.Set("Upgrade-Insecure-Requests", "1") |
|||
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36") |
|||
req.Header.Set("sec-ch-ua", `"Google Chrome";v="135", "Not-A.Brand";v="8", "Chromium";v="135"`) |
|||
req.Header.Set("sec-ch-ua-mobile", "?0") |
|||
req.Header.Set("sec-ch-ua-platform", `"macOS"`) |
|||
|
|||
// 设置 Cookie
|
|||
req.Header.Set("Cookie", this.options.Cookie) |
|||
|
|||
// 执行请求
|
|||
client := &http.Client{} |
|||
resp, err = client.Do(req) |
|||
if err != nil { |
|||
return |
|||
} |
|||
defer resp.Body.Close() |
|||
if resp.StatusCode != http.StatusOK { |
|||
err = fmt.Errorf("StatusCode:%d", resp.StatusCode) |
|||
} |
|||
// 读取响应内容
|
|||
body, err = io.ReadAll(resp.Body) |
|||
if err != nil { |
|||
return |
|||
} |
|||
// 输出响应内容
|
|||
// fmt.Println(string(body))
|
|||
result = &PlaylistSearchResponse{} |
|||
err = json.Unmarshal(body, result) |
|||
return |
|||
} |
|||
|
|||
// 歌单详情
|
|||
func (this *Music) MusicListDetail(id int64, limit, offset int) (result *MusicListDetailResponse, err error) { |
|||
var ( |
|||
req *http.Request |
|||
resp *http.Response |
|||
body []byte |
|||
) |
|||
url := fmt.Sprintf("%s/playlist/track/all?id=%d&limit=%d&offset=%d", this.options.ApiBaseUrl, id, limit, offset) |
|||
req, err = http.NewRequest("GET", url, nil) |
|||
if err != nil { |
|||
return |
|||
} |
|||
|
|||
// 发起请求
|
|||
client := &http.Client{} |
|||
resp, err = client.Do(req) |
|||
if err != nil { |
|||
return |
|||
} |
|||
defer resp.Body.Close() |
|||
|
|||
// 读取响应内容
|
|||
body, err = io.ReadAll(resp.Body) |
|||
if err != nil { |
|||
return |
|||
} |
|||
if resp.StatusCode != http.StatusOK { |
|||
err = fmt.Errorf("StatusCode:%d", resp.StatusCode) |
|||
} |
|||
// 打印结果
|
|||
fmt.Println(string(body)) |
|||
result = &MusicListDetailResponse{} |
|||
err = json.Unmarshal(body, result) |
|||
return |
|||
} |
|||
|
|||
// 音乐连接获取
|
|||
func (this *Music) MusicUrl(id int64) (result *UrlResult, err error) { |
|||
var ( |
|||
req *http.Request |
|||
resp *http.Response |
|||
body []byte |
|||
) |
|||
|
|||
url := fmt.Sprintf("%s/song/url/unblock?id=%d", this.options.ApiBaseUrl, id) |
|||
req, err = http.NewRequest("GET", url, nil) |
|||
if err != nil { |
|||
return |
|||
} |
|||
|
|||
// 发起请求
|
|||
client := &http.Client{} |
|||
resp, err = client.Do(req) |
|||
if err != nil { |
|||
return |
|||
} |
|||
defer resp.Body.Close() |
|||
|
|||
// 读取响应内容
|
|||
body, err = io.ReadAll(resp.Body) |
|||
if err != nil { |
|||
return |
|||
} |
|||
if resp.StatusCode != http.StatusOK { |
|||
err = fmt.Errorf("StatusCode:%d", resp.StatusCode) |
|||
return |
|||
} |
|||
// 打印结果
|
|||
// fmt.Println(string(body))
|
|||
result = &UrlResult{} |
|||
err = json.Unmarshal(body, result) |
|||
return |
|||
} |
|||
@ -1,40 +0,0 @@ |
|||
package music |
|||
|
|||
import ( |
|||
"yunyan/lego/utils/mapstructure" |
|||
) |
|||
|
|||
type Option func(*Options) |
|||
type Options struct { |
|||
ApiBaseUrl string |
|||
Cookie string |
|||
} |
|||
|
|||
func SetApiBaseUrl(v string) Option { |
|||
return func(o *Options) { |
|||
o.ApiBaseUrl = v |
|||
} |
|||
} |
|||
func SetCookie(v string) Option { |
|||
return func(o *Options) { |
|||
o.Cookie = v |
|||
} |
|||
} |
|||
func newOptions(config map[string]interface{}, opts ...Option) Options { |
|||
options := Options{} |
|||
if config != nil { |
|||
mapstructure.Decode(config, &options) |
|||
} |
|||
for _, o := range opts { |
|||
o(&options) |
|||
} |
|||
return options |
|||
} |
|||
|
|||
func newOptionsByOption(opts ...Option) Options { |
|||
options := Options{} |
|||
for _, o := range opts { |
|||
o(&options) |
|||
} |
|||
return options |
|||
} |
|||
@ -1,83 +0,0 @@ |
|||
package music_test |
|||
|
|||
import ( |
|||
"yunyan/sys/music" |
|||
"fmt" |
|||
"io" |
|||
"net/http" |
|||
"net/url" |
|||
"os" |
|||
"testing" |
|||
"time" |
|||
) |
|||
|
|||
// Test_SearchMusic 调用外部音乐搜索接口做最小联通性验证。
|
|||
// 参数:
|
|||
// - t: Go 测试对象
|
|||
//
|
|||
// 返回值:
|
|||
// - 无
|
|||
//
|
|||
// 异常:
|
|||
// - 当未启用集成测试时跳过
|
|||
// - 当外部接口不可用或返回异常时,测试失败
|
|||
func Test_SearchMusic(t *testing.T) { |
|||
if os.Getenv("MUSIC_INTEGRATION_TEST") == "" { |
|||
t.Skip("跳过测试:未设置环境变量 MUSIC_INTEGRATION_TEST") |
|||
} |
|||
keywords := url.QueryEscape("泡沫") |
|||
url := fmt.Sprintf("http://api.ideapsound.com:3000/cloudsearch?keywords=%s&limit=1&offset=0", keywords) |
|||
|
|||
req, err := http.NewRequest("GET", url, nil) |
|||
if err != nil { |
|||
t.Fatalf("构造请求失败: %v", err) |
|||
} |
|||
// 执行请求
|
|||
client := &http.Client{Timeout: 10 * time.Second} |
|||
resp, err := client.Do(req) |
|||
if err != nil { |
|||
t.Fatalf("请求失败: %v", err) |
|||
} |
|||
defer resp.Body.Close() |
|||
|
|||
// 读取响应内容
|
|||
body, err := io.ReadAll(resp.Body) |
|||
if err != nil { |
|||
t.Fatalf("读取响应失败: %v", err) |
|||
} |
|||
|
|||
// 输出响应内容
|
|||
fmt.Println(string(body)) |
|||
} |
|||
|
|||
// Test_Sys_SearchMusic 使用系统封装进行音乐 URL 获取的最小集成测试。
|
|||
// 参数:
|
|||
// - t: Go 测试对象
|
|||
//
|
|||
// 返回值:
|
|||
// - 无
|
|||
//
|
|||
// 异常:
|
|||
// - 当未启用集成测试时跳过
|
|||
// - 当请求失败时,测试失败
|
|||
func Test_Sys_SearchMusic(t *testing.T) { |
|||
if os.Getenv("MUSIC_INTEGRATION_TEST") == "" { |
|||
t.Skip("跳过测试:未设置环境变量 MUSIC_INTEGRATION_TEST") |
|||
} |
|||
if sys, err := music.NewSys( |
|||
music.SetApiBaseUrl("https://music.voitrans.net"), |
|||
); err != nil { |
|||
t.Fatalf("Sys Init err:%v", err) |
|||
} else { |
|||
var ( |
|||
searchResultResponse *music.UrlResult |
|||
) |
|||
if searchResultResponse, err = sys.MusicUrl(441491828); err == nil { |
|||
|
|||
} |
|||
if err != nil { |
|||
t.Fatalf("播放音乐失败: %v", err) |
|||
} |
|||
t.Logf("播放音乐成功 searchResultResponse:%+v", searchResultResponse) |
|||
} |
|||
} |
|||
@ -1,66 +0,0 @@ |
|||
package musicobj |
|||
|
|||
import "errors" |
|||
|
|||
type ( |
|||
SearchResultResponse struct { |
|||
Msg string `json:"msg"` |
|||
Data struct { |
|||
Musics []struct { |
|||
Id int64 `json:"id"` |
|||
Name string `json:"name"` |
|||
Image string `json:"image"` |
|||
Singer string `json:"singer"` |
|||
} `json:"musics"` |
|||
} `json:"data"` |
|||
Code int `json:"code"` |
|||
} |
|||
UrlResultResponse struct { |
|||
Msg string `json:"msg"` |
|||
Data struct { |
|||
Id int64 `json:"id"` |
|||
URL string `json:"url"` |
|||
Ts int64 `json:"ts"` |
|||
} `json:"data"` |
|||
Code int `json:"code"` |
|||
} |
|||
|
|||
ISys interface { |
|||
SearchMusic(keywords string, limit, offset int) (result *SearchResultResponse, err error) |
|||
MusicUrl(id int64) (result *UrlResultResponse, err error) |
|||
} |
|||
) |
|||
|
|||
var defsys ISys |
|||
|
|||
// ErrNotInited 音乐对象存储未初始化。OnInit 失败时调用方可选择降级;此时包级函数返回本错误,而不是 panic。
|
|||
var ErrNotInited = errors.New("musicobj 未初始化:配置缺失") |
|||
|
|||
func OnInit(config map[string]interface{}, option ...Option) error { |
|||
sys, err := newSys(newOptions(config, option...)) |
|||
if err != nil { |
|||
// 出错时不要给 defsys 赋值,避免「非 nil 接口 + nil 指针」让守卫失效。
|
|||
return err |
|||
} |
|||
defsys = sys |
|||
return nil |
|||
} |
|||
|
|||
func NewSys(option ...Option) (sys ISys, err error) { |
|||
sys, err = newSys(newOptionsByOption(option...)) |
|||
return |
|||
} |
|||
|
|||
func SearchMusic(keywords string, limit, offset int) (result *SearchResultResponse, err error) { |
|||
if defsys == nil { |
|||
return nil, ErrNotInited |
|||
} |
|||
return defsys.SearchMusic(keywords, limit, offset) |
|||
} |
|||
|
|||
func MusicUrl(id int64) (result *UrlResultResponse, err error) { |
|||
if defsys == nil { |
|||
return nil, ErrNotInited |
|||
} |
|||
return defsys.MusicUrl(id) |
|||
} |
|||
@ -1,117 +0,0 @@ |
|||
package musicobj |
|||
|
|||
import ( |
|||
"bytes" |
|||
"encoding/json" |
|||
"fmt" |
|||
"io" |
|||
"net/http" |
|||
) |
|||
|
|||
func newSys(options Options) (sys *Music, err error) { |
|||
sys = &Music{ |
|||
options: options, |
|||
} |
|||
return |
|||
} |
|||
|
|||
type Music struct { |
|||
options Options |
|||
} |
|||
|
|||
// 搜索歌曲
|
|||
func (this *Music) SearchMusic(keywords string, limit, offset int) (result *SearchResultResponse, err error) { |
|||
var ( |
|||
req *http.Request |
|||
resp *http.Response |
|||
body []byte |
|||
) |
|||
// keywords = url.QueryEscape(keywords)
|
|||
|
|||
// 创建JSON请求体
|
|||
requestData := map[string]interface{}{ |
|||
"keywords": keywords, |
|||
"limit": limit, |
|||
"offset": offset, |
|||
} |
|||
url := fmt.Sprintf("%s/api/music/music_search", this.options.ApiBaseUrl) |
|||
|
|||
jsonData, err := json.Marshal(requestData) |
|||
if err != nil { |
|||
return |
|||
} |
|||
|
|||
req, err = http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) |
|||
if err != nil { |
|||
return |
|||
} |
|||
// 设置请求头
|
|||
req.Header.Set("Content-Type", "application/json") |
|||
// 执行请求
|
|||
client := &http.Client{} |
|||
resp, err = client.Do(req) |
|||
if err != nil { |
|||
return |
|||
} |
|||
defer resp.Body.Close() |
|||
if resp.StatusCode != http.StatusOK { |
|||
err = fmt.Errorf("StatusCode:%d", resp.StatusCode) |
|||
} |
|||
// 读取响应内容
|
|||
body, err = io.ReadAll(resp.Body) |
|||
if err != nil { |
|||
return |
|||
} |
|||
// 输出响应内容
|
|||
// fmt.Println(string(body))
|
|||
result = &SearchResultResponse{} |
|||
err = json.Unmarshal(body, result) |
|||
return |
|||
} |
|||
|
|||
// 音乐连接获取
|
|||
func (this *Music) MusicUrl(id int64) (result *UrlResultResponse, err error) { |
|||
var ( |
|||
req *http.Request |
|||
resp *http.Response |
|||
body []byte |
|||
) |
|||
// 创建JSON请求体
|
|||
requestData := map[string]interface{}{ |
|||
"id": id, |
|||
} |
|||
jsonData, err := json.Marshal(requestData) |
|||
if err != nil { |
|||
return |
|||
} |
|||
url := fmt.Sprintf("%s/api/music/music_url", this.options.ApiBaseUrl) |
|||
req, err = http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) |
|||
if err != nil { |
|||
return |
|||
} |
|||
|
|||
// 设置请求头
|
|||
req.Header.Set("Content-Type", "application/json") |
|||
// 发起请求
|
|||
client := &http.Client{} |
|||
resp, err = client.Do(req) |
|||
if err != nil { |
|||
return |
|||
} |
|||
defer resp.Body.Close() |
|||
|
|||
// 读取响应内容
|
|||
body, err = io.ReadAll(resp.Body) |
|||
if err != nil { |
|||
return |
|||
} |
|||
if resp.StatusCode != http.StatusOK { |
|||
err = fmt.Errorf("StatusCode:%d", resp.StatusCode) |
|||
return |
|||
} |
|||
// 打印结果
|
|||
fmt.Println(string(body)) |
|||
result = &UrlResultResponse{} |
|||
err = json.Unmarshal(body, result) |
|||
return |
|||
} |
|||
@ -1,40 +0,0 @@ |
|||
package musicobj |
|||
|
|||
import ( |
|||
"yunyan/lego/utils/mapstructure" |
|||
) |
|||
|
|||
type Option func(*Options) |
|||
type Options struct { |
|||
ApiBaseUrl string |
|||
Cookie string |
|||
} |
|||
|
|||
func SetApiBaseUrl(v string) Option { |
|||
return func(o *Options) { |
|||
o.ApiBaseUrl = v |
|||
} |
|||
} |
|||
func SetCookie(v string) Option { |
|||
return func(o *Options) { |
|||
o.Cookie = v |
|||
} |
|||
} |
|||
func newOptions(config map[string]interface{}, opts ...Option) Options { |
|||
options := Options{} |
|||
if config != nil { |
|||
mapstructure.Decode(config, &options) |
|||
} |
|||
for _, o := range opts { |
|||
o(&options) |
|||
} |
|||
return options |
|||
} |
|||
|
|||
func newOptionsByOption(opts ...Option) Options { |
|||
options := Options{} |
|||
for _, o := range opts { |
|||
o(&options) |
|||
} |
|||
return options |
|||
} |
|||
@ -1,29 +0,0 @@ |
|||
package musicobj_test |
|||
|
|||
import ( |
|||
music "yunyan/sys/musicobj" |
|||
"fmt" |
|||
"testing" |
|||
) |
|||
|
|||
// http://www.deapsound.com:3000/search?keywords=%E7%AC%A8%E5%B0%8F%E5%AD%A9
|
|||
func Test_Sys_SearchMusic(t *testing.T) { |
|||
if sys, err := music.NewSys( |
|||
music.SetApiBaseUrl("https://hw.music.voitrans.net"), |
|||
); err != nil { |
|||
fmt.Printf("Sys Init err:%v", err) |
|||
} else { |
|||
var ( |
|||
searchResultResponse *music.SearchResultResponse |
|||
) |
|||
if searchResultResponse, err = sys.SearchMusic("童年", 10, 0); err == nil { |
|||
|
|||
} |
|||
if err != nil { |
|||
fmt.Printf("播放音乐失败, err:%v", err) |
|||
return |
|||
} else { |
|||
fmt.Printf("播放音乐成功 searchResultResponse:%+v", searchResultResponse) |
|||
} |
|||
} |
|||
} |
|||
@ -1,55 +0,0 @@ |
|||
package cos |
|||
|
|||
import ( |
|||
"io" |
|||
|
|||
"github.com/tencentyun/cos-go-sdk-v5" |
|||
sts "github.com/tencentyun/qcloud-cos-sts-sdk/go" |
|||
) |
|||
|
|||
type ( |
|||
ISys interface { |
|||
Sts() (result *sts.CredentialResult, err error) |
|||
Get(name string) (resp *cos.Response, err error) |
|||
Put(name string, r io.Reader) (publicURL string, err error) |
|||
Delete(name string) (err error) |
|||
GetFileUrl(name string) (publicURL string) |
|||
} |
|||
) |
|||
|
|||
var defsys ISys |
|||
var defopt Options |
|||
|
|||
func OnInit(config map[string]interface{}, option ...Option) (err error) { |
|||
defopt = newOptions(config, option...) |
|||
defsys, err = newSys(defopt) |
|||
return |
|||
} |
|||
|
|||
func GetConfig() Options { |
|||
return defopt |
|||
} |
|||
|
|||
func NewSys(option ...Option) (sys ISys, err error) { |
|||
sys, err = newSys(newOptionsByOption(option...)) |
|||
return |
|||
} |
|||
|
|||
func Sts() (result *sts.CredentialResult, err error) { |
|||
return defsys.Sts() |
|||
} |
|||
|
|||
func Get(name string) (resp *cos.Response, err error) { |
|||
return defsys.Get(name) |
|||
} |
|||
func Put(name string, r io.Reader) (publicURL string, err error) { |
|||
return defsys.Put(name, r) |
|||
} |
|||
|
|||
func Delete(name string) (err error) { |
|||
return defsys.Delete(name) |
|||
} |
|||
|
|||
func GetFileUrl(name string) (publicURL string) { |
|||
return defsys.GetFileUrl(name) |
|||
} |
|||
@ -1,100 +0,0 @@ |
|||
package cos |
|||
|
|||
import ( |
|||
"context" |
|||
"fmt" |
|||
"io" |
|||
"net/http" |
|||
"net/url" |
|||
"time" |
|||
|
|||
"github.com/tencentyun/cos-go-sdk-v5" |
|||
sts "github.com/tencentyun/qcloud-cos-sts-sdk/go" |
|||
) |
|||
|
|||
func newSys(options Options) (sys *COS, err error) { |
|||
sys = &COS{ |
|||
options: options, |
|||
} |
|||
bucketURL, _ := url.Parse(options.BucketURL) |
|||
baseURL := &cos.BaseURL{BucketURL: bucketURL} |
|||
// 创建客户端,使用永久密钥
|
|||
sys.client = cos.NewClient(baseURL, &http.Client{ |
|||
Transport: &cos.AuthorizationTransport{ |
|||
SecretID: options.SecretID, // 腾讯云密钥 SecretId
|
|||
SecretKey: options.SecretKey, // 腾讯云密钥 SecretKey
|
|||
}, |
|||
}) |
|||
return |
|||
} |
|||
|
|||
type COS struct { |
|||
options Options |
|||
client *cos.Client |
|||
} |
|||
|
|||
// 临时访问
|
|||
func (this *COS) Sts() (result *sts.CredentialResult, err error) { |
|||
// STS 配置
|
|||
client := sts.NewClient(this.options.SecretID, this.options.SecretKey, nil) |
|||
|
|||
// 定义权限策略
|
|||
policy := &sts.CredentialPolicy{ |
|||
Version: "2.0", |
|||
Statement: []sts.CredentialPolicyStatement{ |
|||
{ |
|||
Effect: "allow", |
|||
Action: []string{ |
|||
"name/cos:GetObject", |
|||
"name/cos:PutObject", |
|||
"name/cos:ListBucket", |
|||
}, |
|||
Resource: []string{ |
|||
fmt.Sprintf("qcs::cos:%s:uid/%s:%s", this.options.Region, this.options.AppId, this.options.BucketName), |
|||
fmt.Sprintf("qcs::cos:%s:uid/%s:%s/*", this.options.Region, this.options.AppId, this.options.BucketName), |
|||
}, |
|||
}, |
|||
}, |
|||
} |
|||
// 设置临时密钥参数
|
|||
opt := &sts.CredentialOptions{ |
|||
DurationSeconds: int64(time.Hour.Seconds()), // 有效期:1小时
|
|||
Region: this.options.Region, // 替换为你的存储桶所在地域
|
|||
Policy: policy, |
|||
} |
|||
// 获取临时密钥
|
|||
result, err = client.GetCredential(opt) |
|||
if err != nil { |
|||
return |
|||
} |
|||
return |
|||
} |
|||
|
|||
func (this *COS) Get(name string) (resp *cos.Response, err error) { |
|||
resp, err = this.client.Object.Get(context.Background(), name, nil) |
|||
return |
|||
} |
|||
func (this *COS) GetFileUrl(name string) (publicURL string) { |
|||
if this.options.DomainName == "" { |
|||
publicURL = fmt.Sprintf("https://%s.cos.%s.myqcloud.com/%s", this.options.BucketName, this.options.Region, name) |
|||
} else { |
|||
publicURL = fmt.Sprintf("https://%s/%s", this.options.DomainName, name) |
|||
} |
|||
|
|||
return |
|||
} |
|||
|
|||
func (this *COS) Put(name string, r io.Reader) (publicURL string, err error) { |
|||
_, err = this.client.Object.Put(context.Background(), name, r, nil) |
|||
if this.options.DomainName == "" { |
|||
publicURL = fmt.Sprintf("https://%s.cos.%s.myqcloud.com/%s", this.options.BucketName, this.options.Region, name) |
|||
} else { |
|||
publicURL = fmt.Sprintf("https://%s/%s", this.options.DomainName, name) |
|||
} |
|||
return |
|||
} |
|||
|
|||
func (this *COS) Delete(name string) (err error) { |
|||
_, err = this.client.Object.Delete(context.Background(), name, nil) |
|||
return |
|||
} |
|||
@ -1,80 +0,0 @@ |
|||
package cos |
|||
|
|||
import ( |
|||
"yunyan/lego/sys/log" |
|||
"yunyan/lego/utils/mapstructure" |
|||
) |
|||
|
|||
type Option func(*Options) |
|||
type Options struct { |
|||
Debug bool //日志是否开启
|
|||
Log log.ILogger |
|||
SecretID string |
|||
SecretKey string |
|||
Region string |
|||
AppId string |
|||
BucketName string |
|||
BucketURL string |
|||
DomainName string // 自定义域名
|
|||
} |
|||
|
|||
func SetSecretID(v string) Option { |
|||
return func(o *Options) { |
|||
o.SecretID = v |
|||
} |
|||
} |
|||
func SetSecretKey(v string) Option { |
|||
return func(o *Options) { |
|||
o.SecretKey = v |
|||
} |
|||
} |
|||
func SetRegion(v string) Option { |
|||
return func(o *Options) { |
|||
o.Region = v |
|||
} |
|||
} |
|||
func SetAppId(v string) Option { |
|||
return func(o *Options) { |
|||
o.AppId = v |
|||
} |
|||
} |
|||
func SetBucketName(v string) Option { |
|||
return func(o *Options) { |
|||
o.BucketName = v |
|||
} |
|||
} |
|||
func SetBucketURL(v string) Option { |
|||
return func(o *Options) { |
|||
o.BucketURL = v |
|||
} |
|||
} |
|||
func SetDomainName(v string) Option { |
|||
return func(o *Options) { |
|||
o.DomainName = v |
|||
} |
|||
} |
|||
|
|||
func newOptions(config map[string]interface{}, opts ...Option) Options { |
|||
options := Options{} |
|||
if config != nil { |
|||
mapstructure.Decode(config, &options) |
|||
} |
|||
for _, o := range opts { |
|||
o(&options) |
|||
} |
|||
if options.Log == nil { |
|||
options.Log = log.NewTurnlog(options.Debug, log.Clone("sys.tavily", 3)) |
|||
} |
|||
return options |
|||
} |
|||
|
|||
func newOptionsByOption(opts ...Option) Options { |
|||
options := Options{} |
|||
for _, o := range opts { |
|||
o(&options) |
|||
} |
|||
if options.Log == nil { |
|||
options.Log = log.NewTurnlog(options.Debug, log.Clone("sys.tavily", 3)) |
|||
} |
|||
return options |
|||
} |
|||
@ -1,193 +0,0 @@ |
|||
package cos_test |
|||
|
|||
import ( |
|||
"context" |
|||
lgcos "yunyan/sys/tencentyun/cos" |
|||
"fmt" |
|||
"io" |
|||
"log" |
|||
"net/http" |
|||
"net/url" |
|||
"strings" |
|||
"testing" |
|||
"time" |
|||
|
|||
"github.com/tencentyun/cos-go-sdk-v5" |
|||
sts "github.com/tencentyun/qcloud-cos-sts-sdk/go" |
|||
) |
|||
|
|||
func Test_Sys(t *testing.T) { |
|||
if err := lgcos.OnInit(nil, |
|||
lgcos.SetSecretID("AKID9F8qNiqlTzIMFwQ9rQxNcgYCdsukNmzA"), |
|||
lgcos.SetSecretKey("OYVGo8HEzWpvpfHgtfHfqpcDbnlY7Tdc"), |
|||
lgcos.SetAppId("1253517901"), |
|||
lgcos.SetRegion("ap-guangzhou"), |
|||
lgcos.SetBucketName("deepsound-1253517901"), |
|||
lgcos.SetBucketURL("https://deepsound-1253517901.cos.ap-guangzhou.myqcloud.com"), |
|||
lgcos.SetDomainName("app.deepsoud.com"), |
|||
); err != nil { |
|||
return |
|||
} else { |
|||
// var file *os.File
|
|||
// defer file.Close()
|
|||
// if file, err = os.Open("./DeapSound_0619.apk"); err != nil {
|
|||
// fmt.Println("打开文件失败!", err)
|
|||
// return
|
|||
// }
|
|||
results := lgcos.GetFileUrl("app/DeapSound_0619.apk") |
|||
fmt.Printf("results:%s", results) |
|||
} |
|||
} |
|||
|
|||
func Test_Sys_Sts(t *testing.T) { |
|||
if err := lgcos.OnInit(nil, |
|||
lgcos.SetSecretID("AKID9F8qNiqlTzIMFwQ9rQxNcgYCdsukNmzA"), |
|||
lgcos.SetSecretKey("OYVGo8HEzWpvpfHgtfHfqpcDbnlY7Tdc"), |
|||
lgcos.SetAppId("1253517901"), |
|||
lgcos.SetRegion("ap-guangzhou"), |
|||
lgcos.SetBucketName("deepsound-1253517901"), |
|||
lgcos.SetBucketURL("https://deepsound-1253517901.cos.ap-guangzhou.myqcloud.com"), |
|||
); err != nil { |
|||
return |
|||
} else { |
|||
results, err := lgcos.Sts() |
|||
// fmt.Printf("TmpSecretId:%s TmpSecretKey:%s SessionToken:%s err:%v", results.Credentials.TmpSecretID, results.Credentials.TmpSecretKey, results.Credentials.SessionToken, err)
|
|||
|
|||
// 你的 COS 存储桶信息
|
|||
bucketURL := "https://deepsound-1253517901.cos.ap-guangzhou.myqcloud.com" |
|||
|
|||
// 通过 STS 获取的临时凭证
|
|||
tmpSecretID := results.Credentials.TmpSecretID |
|||
tmpSecretKey := results.Credentials.TmpSecretKey |
|||
sessionToken := results.Credentials.SessionToken |
|||
|
|||
// 初始化 COS 客户端
|
|||
u, _ := url.Parse(bucketURL) |
|||
b := &cos.BaseURL{BucketURL: u} |
|||
|
|||
client := cos.NewClient(b, &http.Client{ |
|||
Transport: &cos.AuthorizationTransport{ |
|||
SecretID: tmpSecretID, |
|||
SecretKey: tmpSecretKey, |
|||
SessionToken: sessionToken, // 关键部分,必须加上 SessionToken
|
|||
}, |
|||
}) |
|||
// objectKey := "test.txt"
|
|||
// content := "Hello, COS with STS!"
|
|||
// _, err = client.Object.Put(context.Background(), objectKey,
|
|||
// strings.NewReader(content), nil)
|
|||
// if err != nil {
|
|||
// log.Fatalf("上传对象失败: %v", err)
|
|||
// }
|
|||
// fmt.Printf("成功上传对象: %s\n", objectKey)
|
|||
|
|||
// 测试下载对象
|
|||
// resp, err := client.Object.Get(context.Background(), objectKey, nil)
|
|||
// if err != nil {
|
|||
// log.Fatalf("下载对象失败: %v", err)
|
|||
// }
|
|||
// defer resp.Body.Close()
|
|||
// body, err := io.ReadAll(resp.Body)
|
|||
// if err != nil {
|
|||
// log.Fatalf("读取对象内容失败: %v", err)
|
|||
// }
|
|||
// fmt.Printf("下载对象内容: %s\n", string(body))
|
|||
// 示例:列出存储桶中的对象
|
|||
result, _, err := client.Bucket.Get(context.Background(), nil) |
|||
if err != nil { |
|||
fmt.Println("请求失败:", err) |
|||
return |
|||
} |
|||
|
|||
for _, v := range result.Contents { |
|||
fmt.Println("文件名:", v.Key) |
|||
} |
|||
} |
|||
} |
|||
|
|||
func Test_Sys_Sts1(t *testing.T) { |
|||
// 替换为您的永久密钥
|
|||
secretID := "AKID9F8qNiqlTzIMFwQ9rQxNcgYCdsukNmzA" |
|||
secretKey := "OYVGo8HEzWpvpfHgtfHfqpcDbnlY7Tdc" |
|||
|
|||
// 配置存储桶信息
|
|||
appID := "1253517901" |
|||
bucket := "deepsound-1253517901" |
|||
region := "ap-guangzhou" |
|||
bucketURL := fmt.Sprintf("https://%s.cos.%s.myqcloud.com", bucket, region) |
|||
|
|||
// 创建 STS 客户端
|
|||
client := sts.NewClient(secretID, secretKey, nil) |
|||
|
|||
// 配置策略
|
|||
policy := &sts.CredentialPolicy{ |
|||
Version: "2.0", |
|||
Statement: []sts.CredentialPolicyStatement{ |
|||
{ |
|||
Effect: "allow", |
|||
Action: []string{ |
|||
"name/cos:GetObject", |
|||
"name/cos:PutObject", |
|||
"name/cos:ListBucket", |
|||
}, |
|||
Resource: []string{ |
|||
fmt.Sprintf("qcs::cos:%s:uid/%s:%s", region, appID, bucket), |
|||
fmt.Sprintf("qcs::cos:%s:uid/%s:%s/*", region, appID, bucket), |
|||
}, |
|||
}, |
|||
}, |
|||
} |
|||
|
|||
// 配置临时密钥参数
|
|||
opt := &sts.CredentialOptions{ |
|||
DurationSeconds: int64(time.Hour.Seconds()), // 有效期:1小时
|
|||
Region: region, |
|||
Policy: policy, |
|||
} |
|||
|
|||
// 获取临时密钥
|
|||
result, err := client.GetCredential(opt) |
|||
if err != nil { |
|||
log.Fatalf("获取临时密钥失败: %v", err) |
|||
} |
|||
|
|||
fmt.Printf("临时密钥信息:\n") |
|||
fmt.Printf("TmpSecretId: %s\n", result.Credentials.TmpSecretID) |
|||
fmt.Printf("TmpSecretKey: %s\n", result.Credentials.TmpSecretKey) |
|||
fmt.Printf("SessionToken: %s\n", result.Credentials.SessionToken) |
|||
fmt.Printf("开始时间: %d\n", result.StartTime) |
|||
fmt.Printf("过期时间: %d\n", result.ExpiredTime) |
|||
|
|||
// 使用临时密钥配置 COS 客户端
|
|||
u, _ := url.Parse(bucketURL) |
|||
b := &cos.BaseURL{BucketURL: u} |
|||
cosClient := cos.NewClient(b, &http.Client{ |
|||
Transport: &cos.AuthorizationTransport{ |
|||
SecretID: result.Credentials.TmpSecretID, |
|||
SecretKey: result.Credentials.TmpSecretKey, |
|||
SessionToken: result.Credentials.SessionToken, |
|||
}, |
|||
}) |
|||
|
|||
// 测试上传对象
|
|||
objectKey := "test.txt" |
|||
content := "Hello, COS with STS!" |
|||
_, err = cosClient.Object.Put(context.Background(), objectKey, |
|||
strings.NewReader(content), nil) |
|||
if err != nil { |
|||
log.Fatalf("上传对象失败: %v", err) |
|||
} |
|||
fmt.Printf("成功上传对象: %s\n", objectKey) |
|||
|
|||
// 测试下载对象
|
|||
resp, err := cosClient.Object.Get(context.Background(), objectKey, nil) |
|||
if err != nil { |
|||
log.Fatalf("下载对象失败: %v", err) |
|||
} |
|||
defer resp.Body.Close() |
|||
body, err := io.ReadAll(resp.Body) |
|||
if err != nil { |
|||
log.Fatalf("读取对象内容失败: %v", err) |
|||
} |
|||
fmt.Printf("下载对象内容: %s\n", string(body)) |
|||
} |
|||
@ -0,0 +1,209 @@ |
|||
# 下载落地页(dl.ymaikj.com) |
|||
|
|||
一个纯静态页,托管在阿里云 OSS 上,**不占服务器、不经 NPM**。 |
|||
安装包也放同一个桶里,页面按 UA 决定跳商店还是给 APK 直链。 |
|||
|
|||
``` |
|||
deploy/dl/ |
|||
├── index.template.html ← 源文件,改页面改这个 |
|||
├── build.sh ← 把 App 图标内联进去,生成 index.html |
|||
└── index.html ← 生成物,**上传的是它**(已入库,方便直接传) |
|||
``` |
|||
|
|||
**版本号、更新日志、各商店链接都不在页面里维护**,实时取自管理后台(见下文「三」)。 |
|||
图标取自 `apps/client/assets/images/app_icon.png`,与 App 用的是同一份素材。 |
|||
|
|||
--- |
|||
|
|||
## 一、为什么放 OSS 而不是放业务服务器 |
|||
|
|||
下载页一般要挂 APK 直链。微信、各家浏览器对「发 apk 的域名」判定很粗暴, |
|||
一旦被拦截或标记为风险,**同域名上的接口请求也会一起遭殃**。所以它必须与 |
|||
`ym.ymaikj.com`(业务网关)分开。 |
|||
|
|||
放 OSS 还有两个顺带好处:页面和安装包同源(没有跨域、没有防盗链问题), |
|||
以及不占业务机的带宽——一个版本几十 MB,几千次下载就是几百 GB。 |
|||
|
|||
--- |
|||
|
|||
## 二、部署步骤(首次) |
|||
|
|||
> ✅ 2026-09-23:`ymai-dl` 桶、`dl.ymaikj.com` 域名与证书**均已建好并实测可用** |
|||
> (证书有效期至 2027-03-23,404 路由正确,与 `ym.ymaikj.com` 的跨域也已实测通过)。 |
|||
> 下面三步留档,重建时照做。 |
|||
|
|||
### 1. 准备存储桶 |
|||
|
|||
**建议新建一个专用桶**,例如 `ymai-dl`(上海,与 `ymaioss` 同地域): |
|||
|
|||
- 读写权限:**公共读**(安装包本来就要公开下载) |
|||
- 不要复用 `ymaioss`。那个桶里是用户录音,把公开分发的东西混进去会让 |
|||
权限、生命周期规则、流量账单都分不清谁是谁。 |
|||
|
|||
> 想省事复用 `ymaioss` 也能跑通,但页面要放在 `EAIMAR/dist/index.html` |
|||
> 这种子路径下,且 `oss.ymaikj.com` 已经绑给它了,你还得再绑一个 `dl.ymaikj.com`。 |
|||
> 收益不大,不推荐。 |
|||
|
|||
### 2. 开启静态网站托管 |
|||
|
|||
桶 → **数据管理 → 静态页面**: |
|||
|
|||
| 项 | 填 | |
|||
|---|---| |
|||
| 默认首页 | `index.html` | |
|||
| 默认 404 页 | `index.html`(可选,让任何路径都落到下载页) | |
|||
| 子目录首页 | 开启 | |
|||
|
|||
不开这一项,访问 `https://dl.ymaikj.com/` 会返回桶的 XML 列表或 403,而不是页面。 |
|||
|
|||
### 3. 绑定域名 `dl.ymaikj.com` |
|||
|
|||
桶 → **传输管理 → 域名管理 → 绑定域名**: |
|||
|
|||
1. 填 `dl.ymaikj.com`,勾选「自动添加 CNAME 记录」(域名在同一个阿里云账号下时可用; |
|||
否则去 DNS 那边手工加一条 CNAME 指向桶的外网 Endpoint) |
|||
2. ⚠️ **中国大陆地域的桶绑自定义域名,域名必须已备案**。`ymaikj.com` 应该已备案, |
|||
确认一下子域名是否需要单独报备。 |
|||
3. 证书:同一页的「证书托管」里传一张 `dl.ymaikj.com` 的证书。 |
|||
阿里云「数字证书管理服务」可以申请免费的 DV 证书,签完直接在这里选。 |
|||
**不配证书 = 只能 http 访问**,微信和现在的浏览器都会拦。 |
|||
|
|||
### 4. 上传页面与安装包 |
|||
|
|||
用 ossutil(推荐,能重复执行): |
|||
|
|||
```bash |
|||
# 首次配置:ossutil config -e oss-cn-shanghai.aliyuncs.com -i <AK> -k <SK> |
|||
|
|||
# 页面(放桶根) |
|||
ossutil cp deploy/dl/index.html oss://ymai-dl/index.html \ |
|||
--meta "Content-Type:text/html; charset=utf-8#Cache-Control:no-cache, must-revalidate" |
|||
|
|||
# 安装包 |
|||
ossutil cp eaimar-release-1.0.2-20260923.apk \ |
|||
oss://ymai-dl/android/EAIMAR/eaimar-release-1.0.2-20260923.apk \ |
|||
--meta "Content-Type:application/vnd.android.package-archive#Content-Disposition:attachment" |
|||
``` |
|||
|
|||
也可以直接在 OSS 控制台点「上传文件」,然后在文件的「设置 HTTP 头」里改这两项。 |
|||
|
|||
⚠️ **页面必须设 `Cache-Control: no-cache`**。默认缓存会让你改了页面之后老用户 |
|||
还拿着旧版本,而这种问题根本没人会来报。 |
|||
|
|||
⚠️ **APK 建议设 `Content-Disposition: attachment`**。不设的话部分浏览器会尝试 |
|||
「预览」这个文件,表现为点了没反应或者打开一堆乱码。 |
|||
|
|||
--- |
|||
|
|||
## 三、与管理后台联动(版本号、商店链接、更新日志) |
|||
|
|||
页面打开时会调一次 `POST https://ym.ymaikj.com/api/home/user_getchannelapps` |
|||
(该接口在网关白名单里,未登录可调),把后台配的东西取回来: |
|||
|
|||
| 页面上的 | 来自后台哪里 | 落到哪个字段 | |
|||
|---|---|---| |
|||
| 版本号 | 服务配置 → **版本与分发** → 发版配置 → 线上版本 | `app_release.version` | |
|||
| 「本次更新」 | 版本与分发 → 发版配置 → 更新日志 | `app_release.changelog` | |
|||
| App Store 链接 | 版本与分发 → **渠道分发** → 全球-苹果(101) 的下载地址 | `channel_app.address` | |
|||
| 各安卓商店链接 | 渠道分发 → 华为(1) / 小米(2) / OPPO(3) / VIVO(4) / 荣耀(5) | 同上 | |
|||
| APK 直链 | 渠道分发 → **官方渠道(100)** 的下载地址 | 同上 | |
|||
|
|||
**后台改完,页面刷新即生效,不需要重新上传。** |
|||
|
|||
渠道地址留空时后端会自动回退到「发版配置」里的默认下载地址(`AppRelease.DownloadAddress`), |
|||
所以只想维护一套地址的话,只填那一处就够——填成 `https://dl.ymaikj.com` 即可兜住所有渠道。 |
|||
|
|||
⚠️ **APK 直链约定挂在「官方渠道(100)」下**。这是本页面单方面的约定,后台界面上没有任何提示, |
|||
所以要么按这个约定配,要么去改 `index.template.html` 里 `CFG.channels` 的映射。 |
|||
|
|||
⚠️ 接口打不通时(断网 / 网关挂了 / 跨域被拦)页面用 `CFG.fallback` 的兜底值渲染,**不报错**—— |
|||
用户是来下载的,给他看一个报错页没有任何用。代价是兜底值会比后台旧, |
|||
所以**别把 fallback 当配置入口去维护**。 |
|||
|
|||
--- |
|||
|
|||
## 四、发版时要做什么 |
|||
|
|||
1. 打包,产物名按仓库约定:`eaimar-release-<版本号>-<时间>.apk` |
|||
(**不要留 `app-release.apk`**) |
|||
2. 传安装包 —— 两条路任选: |
|||
- **后台传(推荐)**:服务配置 → **版本与分发** → 「默认下载地址」旁边的 **上传 APK** 按钮。 |
|||
传到分发桶的 `android/<应用>/` 目录(按应用分目录,先选应用才能传),带进度条, |
|||
传完**自动写进「渠道分发 → 官方渠道(100)」**的下载地址,不动「默认下载地址」那一格。 |
|||
- 手工传:`oss://ymai-dl/android/`,见上文「二、4」 |
|||
3. 到后台「版本与分发」改线上版本与更新日志(用「上传 APK」传的话,官方渠道地址已自动写好) |
|||
|
|||
### ⚠️ 后台上传的前置:分发桶要配跨域(CORS) |
|||
|
|||
浏览器是**直传到 OSS**(预签名 PUT 打的是 `ymai-dl.oss-cn-shanghai.aliyuncs.com`, |
|||
不是 `dl.ymaikj.com`),所以桶上必须有 CORS 规则,否则浏览器直接拦掉、 |
|||
XHR 只报一句没有细节的「网络错误」。 |
|||
|
|||
OSS 控制台 → `ymai-dl` → **权限管理 → 跨域设置 → 创建规则**: |
|||
|
|||
| 项 | 填 | |
|||
|---|---| |
|||
| 来源 | `https://console.ymaikj.com`<br>`https://console-dev.ymaikj.com` | |
|||
| 允许 Methods | `PUT`(顺带勾 GET、HEAD) | |
|||
| 允许 Headers | `content-type`(或 `*`) | |
|||
|
|||
对照:主桶 `ymaioss` 早就配好了(所以产品固件上传一直能用), |
|||
2026-09-23 实测 `ymai-dl` 的预检返回 **403**,需要照着补一条。 |
|||
|
|||
页面本身不用动。只有改页面文案/样式,或者 **App 图标换了**,才需要: |
|||
|
|||
```bash |
|||
cd deploy/dl && ./build.sh # 重新内联图标,生成 index.html |
|||
# 再把 index.html 传一次 |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 五、页面行为 |
|||
|
|||
| 环境 | 行为 | |
|||
|---|---| |
|||
| iOS | 跳 App Store | |
|||
| Android(认出品牌) | 主按钮跳对应商店,副按钮给 APK 直链 | |
|||
| Android(认不出品牌) | 直接给 APK 直链 | |
|||
| 桌面浏览器 | 两个按钮都给,提示用手机打开 | |
|||
| **微信 / QQ 内置浏览器** | **一进页面就弹遮罩**,引导「右上角 ··· → 在浏览器中打开」 | |
|||
|
|||
⚠️ 微信那一层不是可选项。微信会拦截 apk 下载而且**静默失败**——点了什么都不发生, |
|||
用户只会认为这个页面坏了。所以是进页面就提示,而不是等他点了才说。 |
|||
|
|||
品牌判断的口径与 App 内 `getAndroidStoreInfoByBrand` 一致:**判的是手机品牌, |
|||
不是从哪家商店下载的**(国内五家共用一个包)。认不出就走 APK,这个兜底方向是安全的。 |
|||
|
|||
商店地址与 APK 地址相同时(后台该渠道没单独配、回退到了默认地址)只显示一个按钮—— |
|||
两个一模一样的按钮只会让人犹豫点哪个。 |
|||
|
|||
--- |
|||
|
|||
## 六、待办 |
|||
|
|||
**首选是去后台「渠道分发」配**(见上文「三」),配了就不用动代码。 |
|||
|
|||
`index.template.html` 里 `CFG.fallback` 的商店链接目前是占位地址 |
|||
`https://emai.ymaikj.com/1223`,搜 `TODO(store-links)` 能定位,它只在接口打不通时才会被用到。 |
|||
各商店上架后可以顺手更新: |
|||
|
|||
| key | 从哪拿 | |
|||
|---|---| |
|||
| `appStore` | App Store Connect →「App 信息 → Apple ID」,一串纯数字,拼成 `https://apps.apple.com/cn/app/id<数字>` | |
|||
| `huawei` / `honor` | 各自开发者后台的应用详情页链接,形如 `https://appgallery.huawei.com/app/C<数字>` | |
|||
| `xiaomi` | `https://app.mi.com/details?id=com.yimai.eaimar` | |
|||
| `oppo` / `vivo` | 各自开发者后台的详情页链接 | |
|||
|
|||
⚠️ 包名是 **`com.yimai.eaimar`**(iOS bundle id 同名)。 |
|||
|
|||
--- |
|||
|
|||
## 七、注意 |
|||
|
|||
- **别在这个页面做 Universal Link 唤起**:UL 域名是 `yimai.games`,不是 `ymaikj.com`, |
|||
在 ymaikj 的任何子域上配都不会生效。 |
|||
- 桶是公共读,APK 谁都能下——这对安装包是对的。但**别把别的东西往这个桶里放**。 |
|||
- 下载量上来之后再在 OSS 前面挂 CDN,页面不用改(域名不变)。 |
|||
- 图标在页面里只内联一份(`<img>`),favicon 与 apple-touch-icon 由 JS 复用同一个 |
|||
data URI。在 `<head>` 里再写两个 href 会把几十 KB 的 base64 存三份,页面体积直接翻三倍。 |
|||
@ -0,0 +1,31 @@ |
|||
#!/usr/bin/env bash |
|||
# 由 index.template.html 生成可上传的 index.html:把 App 图标内联成 data URI。 |
|||
# |
|||
# 为什么内联而不是放一个 icon.png 在旁边:这页只有一个文件要上传, |
|||
# 少一个文件就少一种「漏传了导致图标裂开」的失败形态,也少一次请求。 |
|||
# 代价是 index.html 会大几十 KB —— 对一个要发 60MB 安装包的页面无所谓。 |
|||
# |
|||
# App 图标换了就重跑本脚本,再把 index.html 传一次。 |
|||
set -euo pipefail |
|||
|
|||
cd "$(dirname "$0")" |
|||
SRC="../../apps/client/assets/images/app_icon.png" # App 图标的唯一来源,与客户端同一份素材 |
|||
SIZE=192 # 展示 92px,@2x 够用;再大只是徒增体积 |
|||
|
|||
[ -f "$SRC" ] || { echo "✗ 找不到 App 图标: $SRC" >&2; exit 1; } |
|||
|
|||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT |
|||
sips -Z "$SIZE" "$SRC" --out "$TMP/icon.png" >/dev/null |
|||
B64="$(base64 -i "$TMP/icon.png" | tr -d '\n')" |
|||
|
|||
# 用 python 替换:图标 base64 有几万字符,sed 在这个量级上会报 "RE error" |
|||
python3 - "$B64" <<'PY' |
|||
import sys |
|||
b64 = sys.argv[1] |
|||
src = open('index.template.html').read() |
|||
out = src.replace('__ICON_DATA_URI__', 'data:image/png;base64,' + b64) |
|||
assert '__ICON_DATA_URI__' not in out, '占位符没替换干净' |
|||
open('index.html', 'w').write(out) |
|||
PY |
|||
|
|||
printf "✓ 已生成 index.html(%s KB)\n" "$(( $(stat -f%z index.html) / 1024 ))" |
|||
File diff suppressed because one or more lines are too long
@ -0,0 +1,380 @@ |
|||
<!DOCTYPE html> |
|||
<html lang="zh-CN"> |
|||
<head> |
|||
<meta charset="utf-8"> |
|||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> |
|||
<title>EAIMAR · 下载</title> |
|||
<meta name="description" content="EAIMAR —— 实时记录会议纪要,多语言翻译"> |
|||
<meta name="theme-color" content="#13171c" media="(prefers-color-scheme: dark)"> |
|||
<meta name="theme-color" content="#eef1f6" media="(prefers-color-scheme: light)"> |
|||
<style> |
|||
/* ─────────────────────────────────────────────────────────────── |
|||
配色取自 App 图标本身(apps/client/assets/images/app_icon.png): |
|||
深色场 #13171c → #3a3f49,图形色 #d1d6e3。 |
|||
于是页面沿用图标的视觉语言——「深底浅形」,主按钮在深色下是浅的、 |
|||
在浅色下是深的,始终与背景相反,和图标上那个浅色蝶形一个道理。 |
|||
─────────────────────────────────────────────────────────────── */ |
|||
:root{ |
|||
--mark:#d1d6e3; /* 图标上那个浅蓝灰,全站强调色 */ |
|||
--bg-a:#eef1f6; --bg-b:#dde3ee; |
|||
--fg:#1b1f27; --muted:#66707f; --line:rgba(23,27,34,.10); |
|||
--card:rgba(255,255,255,.72); |
|||
--btn-bg:linear-gradient(180deg,#2c333f,#171b22); |
|||
--btn-fg:#f4f6fa; |
|||
--btn-shadow:0 8px 22px -8px rgba(23,27,34,.55); |
|||
--net:rgba(23,27,34,.30); /* 背景星座线条 */ |
|||
--glow:rgba(120,138,190,.38); /* 图标后的光晕 */ |
|||
--dot-halo:rgba(209,214,227,.22); |
|||
} |
|||
@media (prefers-color-scheme:dark){ |
|||
:root{ |
|||
--bg-a:#1c212a; --bg-b:#0c0f13; |
|||
--fg:#e9ecf3; --muted:#8e97a6; --line:rgba(255,255,255,.09); |
|||
--card:rgba(255,255,255,.045); |
|||
--btn-bg:linear-gradient(180deg,#e7ebf4,#c5cde0); |
|||
--btn-fg:#151a21; |
|||
--btn-shadow:0 10px 26px -10px rgba(0,0,0,.85); |
|||
--net:rgba(209,214,227,.30); |
|||
--glow:rgba(140,158,210,.30); |
|||
--dot-halo:rgba(209,214,227,.16); |
|||
} |
|||
} |
|||
|
|||
*{box-sizing:border-box} |
|||
html,body{margin:0;padding:0;min-height:100%} |
|||
body{ |
|||
color:var(--fg); |
|||
font:15px/1.65 -apple-system,BlinkMacSystemFont,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif; |
|||
-webkit-text-size-adjust:100%; |
|||
-webkit-font-smoothing:antialiased; |
|||
/* 斜向渐变的方向与图标一致:左上亮、右下暗 */ |
|||
background:linear-gradient(155deg,var(--bg-a) 0%,var(--bg-b) 72%) fixed; |
|||
} |
|||
|
|||
/* 背景星座:呼应图标里那层网点连线。纯内联 SVG,不额外发请求 */ |
|||
.net{ |
|||
position:fixed;inset:0;z-index:0;pointer-events:none; |
|||
color:var(--net);opacity:.42; |
|||
/* 中心淡出:内容区在中间,线条横穿卡片会很乱。只让它在四周留一点氛围 */ |
|||
-webkit-mask-image:radial-gradient(ellipse 62% 46% at 50% 42%,transparent 10%,#000 78%); |
|||
mask-image:radial-gradient(ellipse 62% 46% at 50% 42%,transparent 10%,#000 78%); |
|||
} |
|||
.net svg{width:100%;height:100%;display:block} |
|||
|
|||
.stage{position:relative;z-index:1;max-width:460px;margin:0 auto;padding:52px 20px 44px;text-align:center} |
|||
|
|||
/* ── 图标 ── */ |
|||
.hero{position:relative;width:100px;height:100px;margin:0 auto 20px} |
|||
.hero::before{ /* 背后的柔光,让图标从背景里浮起来 */ |
|||
content:"";position:absolute;inset:-26%;border-radius:50%; |
|||
background:radial-gradient(circle,var(--glow) 0%,transparent 68%); |
|||
filter:blur(2px); |
|||
} |
|||
.icon{ |
|||
position:relative;width:100px;height:100px;display:block;border-radius:23px; |
|||
box-shadow:0 14px 34px -12px rgba(0,0,0,.55),0 0 0 1px var(--line); |
|||
} |
|||
|
|||
h1{font-size:28px;margin:0 0 6px;letter-spacing:1.5px;font-weight:700} |
|||
.sub{color:var(--muted);margin:0 0 6px;font-size:14px} |
|||
.ver{color:var(--muted);font-size:12.5px;margin:0 0 30px;min-height:19px;letter-spacing:.3px} |
|||
|
|||
/* ── 按钮 ── */ |
|||
.btn{ |
|||
position:relative;display:block;width:100%;padding:16px 18px;margin:0 0 11px; |
|||
border:0;border-radius:14px;background:var(--btn-bg);color:var(--btn-fg); |
|||
font:600 16px/1.2 inherit;text-decoration:none;cursor:pointer; |
|||
box-shadow:var(--btn-shadow); |
|||
transition:transform .14s ease,box-shadow .14s ease,opacity .14s ease; |
|||
} |
|||
.btn:active{transform:translateY(1px) scale(.988);box-shadow:0 4px 12px -6px rgba(0,0,0,.5)} |
|||
@media (hover:hover){.btn:hover{transform:translateY(-1px)}} |
|||
.btn small{display:block;font-weight:400;font-size:12px;opacity:.7;margin-top:4px;letter-spacing:.2px} |
|||
|
|||
.btn.ghost{ |
|||
background:var(--card);color:var(--fg); |
|||
border:1px solid var(--line);font-weight:500; |
|||
box-shadow:none;-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px); |
|||
} |
|||
.btn.ghost:active{transform:translateY(1px) scale(.988)} |
|||
|
|||
/* ── 卡片 ── */ |
|||
.log,.feat li{ |
|||
background:var(--card);border:1px solid var(--line);border-radius:14px; |
|||
-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px); |
|||
} |
|||
.log{padding:14px 16px;margin:20px 0 0;text-align:left;font-size:13px;color:var(--muted);white-space:pre-wrap;word-break:break-word} |
|||
.log b{display:block;color:var(--fg);font-size:13px;margin-bottom:6px} |
|||
|
|||
.feat{margin:26px 0 0;padding:0;list-style:none;text-align:left} |
|||
.feat li{position:relative;padding:14px 16px 14px 34px;margin-bottom:10px;font-size:14px} |
|||
.feat li::before{ /* 小圆点用强调色,把图标的颜色带下来 */ |
|||
content:"";position:absolute;left:16px;top:20px; |
|||
width:6px;height:6px;border-radius:50%;background:var(--mark); |
|||
box-shadow:0 0 0 3px var(--dot-halo); |
|||
} |
|||
.feat b{display:block;margin-bottom:2px;font-weight:600} |
|||
.feat span{color:var(--muted);font-size:13px} |
|||
|
|||
.foot{color:var(--muted);font-size:12px;margin-top:30px;line-height:1.9} |
|||
.foot a{color:var(--fg);text-decoration:none;border-bottom:1px solid var(--line)} |
|||
|
|||
/* 入场动画。reduced-motion 下直接显示,不做位移 */ |
|||
@keyframes rise{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}} |
|||
.stage>*{animation:rise .5s cubic-bezier(.2,.7,.3,1) both} |
|||
.stage>*:nth-child(2){animation-delay:.04s} |
|||
.stage>*:nth-child(3){animation-delay:.07s} |
|||
.stage>*:nth-child(4){animation-delay:.10s} |
|||
.stage>*:nth-child(5){animation-delay:.13s} |
|||
.stage>*:nth-child(6){animation-delay:.16s} |
|||
.stage>*:nth-child(7){animation-delay:.19s} |
|||
@media (prefers-reduced-motion:reduce){ |
|||
.stage>*{animation:none} |
|||
.btn{transition:none} |
|||
} |
|||
|
|||
/* 微信/QQ 内置浏览器的引导遮罩:不做这层,安卓上点下载就是静默无反应 */ |
|||
#mask{position:fixed;inset:0;z-index:99;background:rgba(6,8,11,.88);color:#fff;padding:22px;display:none;-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px)} |
|||
#mask.on{display:block} |
|||
#mask .arrow{text-align:right;font-size:46px;line-height:1;padding:0 8px} |
|||
#mask .tip{max-width:330px;margin:14px 0 0 auto;text-align:right;font-size:17px;line-height:1.75} |
|||
#mask .tip em{font-style:normal;color:var(--mark);font-weight:600} |
|||
</style> |
|||
</head> |
|||
<body> |
|||
|
|||
<div class="net" aria-hidden="true"> |
|||
<svg viewBox="0 0 400 800" preserveAspectRatio="xMidYMid slice" fill="none"> |
|||
<g stroke="currentColor" stroke-width=".8" opacity=".45"> |
|||
<path d="M40 90 L150 40 L300 120 L370 60"/> |
|||
<path d="M150 40 L120 200 L300 120"/> |
|||
<path d="M120 200 L30 300 L110 430 L250 380 L300 120"/> |
|||
<path d="M250 380 L370 300 L300 120"/> |
|||
<path d="M110 430 L60 600 L200 690 L330 600 L370 300"/> |
|||
<path d="M200 690 L250 380"/> |
|||
<path d="M330 600 L360 760"/> |
|||
<path d="M60 600 L20 740"/> |
|||
</g> |
|||
<g fill="currentColor"> |
|||
<circle cx="40" cy="90" r="3"/><circle cx="150" cy="40" r="4"/><circle cx="300" cy="120" r="3.5"/> |
|||
<circle cx="370" cy="60" r="2.5"/><circle cx="120" cy="200" r="3"/><circle cx="30" cy="300" r="3.5"/> |
|||
<circle cx="110" cy="430" r="3"/><circle cx="250" cy="380" r="4"/><circle cx="370" cy="300" r="3"/> |
|||
<circle cx="60" cy="600" r="3.5"/><circle cx="200" cy="690" r="3"/><circle cx="330" cy="600" r="3.5"/> |
|||
<circle cx="360" cy="760" r="2.5"/><circle cx="20" cy="740" r="2.5"/> |
|||
</g> |
|||
</svg> |
|||
</div> |
|||
|
|||
<div id="mask" aria-hidden="true"> |
|||
<div class="arrow">⤴</div> |
|||
<div class="tip"> |
|||
请点击右上角 <em>···</em><br> |
|||
选择「<em>在浏览器中打开</em>」<br> |
|||
再进行下载 |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="stage"> |
|||
<div class="hero"><img class="icon" src="__ICON_DATA_URI__" alt="EAIMAR"></div> |
|||
<h1>EAIMAR</h1> |
|||
<p class="sub">实时记录会议纪要 · 多语言翻译</p> |
|||
<p class="ver" id="ver"></p> |
|||
|
|||
<div id="actions"></div> |
|||
<div class="log" id="log" hidden></div> |
|||
|
|||
<ul class="feat"> |
|||
<li><b>会议纪要</b><span>录音自动转写,AI 生成结构化纪要</span></li> |
|||
<li><b>同声传译</b><span>通话 / 面对面实时翻译,支持数十种语言</span></li> |
|||
<li><b>智能硬件</b><span>配套耳机、支架、车载设备开箱即用</span></li> |
|||
</ul> |
|||
|
|||
<div class="foot"> |
|||
<span id="tip"></span><a href="https://web.ymaikj.com">官网</a> |
|||
</div> |
|||
</div> |
|||
|
|||
<script> |
|||
(function () { |
|||
'use strict'; |
|||
|
|||
// ══════════════════════════════════════════════════════════════════ |
|||
// 配置区 |
|||
// |
|||
// ⚠️ 版本号与各商店链接**不在这里维护** —— 它们实时取自管理后台: |
|||
// · 版本号 ← 「服务配置 → 版本控制」的线上版本(app_release.version) |
|||
// · 各渠道商店链接 ← 「SaaS 管理 → 渠道分发」各渠道的下载地址, |
|||
// 渠道留空时后端自动回退到「版本控制」里的默认下载地址 |
|||
// 后台改完,这个页面**刷新即生效,不需要重新上传**。 |
|||
// |
|||
// 下面的 fallback 只在「接口打不通」时兜底(断网、网关挂了、跨域被拦)。 |
|||
// 它的值会比后台旧,所以别把它当成配置入口去维护。 |
|||
// ══════════════════════════════════════════════════════════════════ |
|||
var CFG = { |
|||
// 取版本与商店链接的后端。必须是**正式**网关,别指到 ym-dev。 |
|||
api: 'https://ym.ymaikj.com', |
|||
|
|||
fallback: { |
|||
version: '1.0.2', |
|||
// TODO(store-links): 后台「渠道分发」把各渠道地址配齐之后,这几行就只是兜底了。 |
|||
// · appStore → https://apps.apple.com/cn/app/id<AppID> |
|||
// AppID 在 App Store Connect 的「App 信息 → Apple ID」,一串纯数字 |
|||
// · 安卓各商店填各自开发者后台的应用详情页链接 |
|||
// · apk 填安装包地址。安装包与本页同桶(ymai-dl),所以走 dl.ymaikj.com; |
|||
// 文件名按仓库约定 eaimar-release-<版本号>-<时间>.apk,别留 app-release.apk |
|||
appStore: 'https://emai.ymaikj.com/1223', |
|||
apk: 'https://dl.ymaikj.com/android/eaimar-release-1.0.2-latest.apk', |
|||
stores: { |
|||
huawei: 'https://emai.ymaikj.com/1223', |
|||
honor: 'https://emai.ymaikj.com/1223', |
|||
xiaomi: 'https://emai.ymaikj.com/1223', |
|||
oppo: 'https://emai.ymaikj.com/1223', |
|||
vivo: 'https://emai.ymaikj.com/1223' |
|||
} |
|||
}, |
|||
|
|||
// 后台渠道枚举 → 本页用的 key。与客户端 UserChannel(channel_app_model.dart) 同一套编号, |
|||
// ⚠️ 加渠道要两边一起加,这里漏了只表现为「后台配了地址但页面还用兜底值」。 |
|||
channels: { |
|||
1: 'huawei', 2: 'xiaomi', 3: 'oppo', 4: 'vivo', 5: 'honor', |
|||
100: 'apk', // 官方渠道:约定用它的下载地址放 APK 直链 |
|||
101: 'appStore' // 全球-苹果 |
|||
// 102 = GLOBAL_GOOGLE(Google Play),国内落地页用不到,刻意不映射 |
|||
} |
|||
}; |
|||
// ══════════════════════════════════════════════════════════════════ |
|||
|
|||
var ua = navigator.userAgent || ''; |
|||
var isIOS = /iPad|iPhone|iPod/.test(ua) || (/Macintosh/.test(ua) && 'ontouchend' in document); |
|||
var isAndroid = /Android/i.test(ua); |
|||
// 微信与 QQ 的内置浏览器都会拦截 apk 下载,而且**静默失败**:点了什么都不发生。 |
|||
// 必须先引导到系统浏览器,否则用户只会觉得"这页坏了"。 |
|||
var blocked = /MicroMessenger/i.test(ua) || /\sQQ\/|QQBrowser/i.test(ua); |
|||
|
|||
var $ = function (id) { return document.getElementById(id); }; |
|||
|
|||
// 图标只在 <img> 里内联一份,favicon / apple-touch-icon 复用同一个 data URI。 |
|||
// 直接在 <head> 里再写两个 href 会把几十 KB 的 base64 存三份,页面体积直接翻三倍。 |
|||
(function () { |
|||
var src = document.querySelector('.icon').getAttribute('src'); |
|||
['icon', 'apple-touch-icon'].forEach(function (rel) { |
|||
var l = document.createElement('link'); |
|||
l.rel = rel; l.href = src; |
|||
document.head.appendChild(l); |
|||
}); |
|||
})(); |
|||
|
|||
function openMask() { |
|||
var m = $('mask'); |
|||
m.className = 'on'; |
|||
m.setAttribute('aria-hidden', 'false'); |
|||
m.onclick = function () { m.className = ''; m.setAttribute('aria-hidden', 'true'); }; |
|||
} |
|||
|
|||
function btn(href, label, note, ghost) { |
|||
var a = document.createElement('a'); |
|||
a.className = 'btn' + (ghost ? ' ghost' : ''); |
|||
a.href = href; |
|||
a.innerHTML = label + (note ? '<small>' + note + '</small>' : ''); |
|||
if (blocked) a.onclick = function (e) { e.preventDefault(); openMask(); }; |
|||
return a; |
|||
} |
|||
|
|||
function androidStoreKey() { |
|||
// 判的是**手机品牌**不是下载来源,与 App 内 getAndroidStoreInfoByBrand 同口径 |
|||
// (国内五家共用一个包)。认不出就走 APK 直链,这个兜底方向是安全的。 |
|||
if (/honor/i.test(ua)) return 'honor'; |
|||
if (/huawei/i.test(ua)) return 'huawei'; |
|||
if (/xiaomi|redmi|poco/i.test(ua)) return 'xiaomi'; |
|||
if (/oppo|realme|oneplus/i.test(ua)) return 'oppo'; |
|||
if (/vivo|iqoo/i.test(ua)) return 'vivo'; |
|||
return ''; |
|||
} |
|||
|
|||
// 渲染整页。后台数据回来后会再调一次,所以这里必须是**幂等重绘**,不能只做增量追加。 |
|||
function render(cfg) { |
|||
$('ver').textContent = cfg.version ? ('版本 ' + cfg.version) : ''; |
|||
|
|||
var actions = $('actions'); |
|||
actions.innerHTML = ''; |
|||
|
|||
if (isIOS) { |
|||
actions.appendChild(btn(cfg.appStore, 'App Store 下载')); |
|||
$('tip').innerHTML = '需要 iOS 13 及以上 · '; |
|||
} else if (isAndroid) { |
|||
var key = androidStoreKey(); |
|||
var store = key && cfg.stores[key]; |
|||
// 商店地址与 APK 是同一个值时(后台该渠道没单独配、回退到了默认下载地址), |
|||
// 只给一个按钮——两个一模一样的按钮只会让人犹豫点哪个。 |
|||
if (store && store !== cfg.apk) { |
|||
actions.appendChild(btn(store, '应用商店下载', '推荐,可自动更新')); |
|||
actions.appendChild(btn(cfg.apk, '直接下载安装包', 'APK · ' + cfg.version, true)); |
|||
} else { |
|||
actions.appendChild(btn(cfg.apk, '下载安装包', 'APK · ' + cfg.version)); |
|||
} |
|||
$('tip').innerHTML = '需要 Android 8.0 及以上 · '; |
|||
} else { |
|||
actions.appendChild(btn(cfg.appStore, 'iPhone / iPad', 'App Store', true)); |
|||
actions.appendChild(btn(cfg.apk, 'Android', 'APK · ' + cfg.version, true)); |
|||
$('tip').innerHTML = '请用手机打开本页面下载 · '; |
|||
} |
|||
|
|||
var log = $('log'); |
|||
if (cfg.changelog) { |
|||
log.innerHTML = '<b>本次更新</b>' + String(cfg.changelog) |
|||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); |
|||
log.hidden = false; |
|||
} else { |
|||
log.hidden = true; |
|||
} |
|||
} |
|||
|
|||
// 先用兜底值画一版:接口再快也有几百毫秒,先白屏再出内容的观感很差。 |
|||
var cur = { |
|||
version: CFG.fallback.version, |
|||
appStore: CFG.fallback.appStore, |
|||
apk: CFG.fallback.apk, |
|||
stores: CFG.fallback.stores, |
|||
changelog: '' |
|||
}; |
|||
render(cur); |
|||
if (blocked) openMask(); |
|||
|
|||
// ── 从管理后台取线上版本与各渠道下载地址 ────────────────────────── |
|||
// 走 user_getchannelapps:它在网关白名单里(未登录可调),下发的每一条是 |
|||
// {channel, version, address, changelog, ...},version 来自 app_release(全渠道同一个)。 |
|||
var xhr = new XMLHttpRequest(); |
|||
xhr.open('POST', CFG.api + '/api/home/user_getchannelapps', true); |
|||
xhr.setRequestHeader('Content-Type', 'application/json'); |
|||
xhr.timeout = 8000; |
|||
xhr.onload = function () { |
|||
var res; |
|||
try { res = JSON.parse(xhr.responseText); } catch (e) { return; } // 拿不到就保持兜底值 |
|||
if (!res || res.code !== 0 || !res.data || !res.data.apps) return; |
|||
|
|||
var apps = res.data.apps, next = { |
|||
version: cur.version, appStore: cur.appStore, apk: cur.apk, |
|||
stores: {}, changelog: '' |
|||
}; |
|||
for (var k in cur.stores) if (cur.stores.hasOwnProperty(k)) next.stores[k] = cur.stores[k]; |
|||
|
|||
for (var i = 0; i < apps.length; i++) { |
|||
var a = apps[i] || {}; |
|||
if (a.version) next.version = a.version; |
|||
if (a.changelog && !next.changelog) next.changelog = a.changelog; |
|||
var key = CFG.channels[a.channel]; |
|||
// 地址为空就保留兜底值:后台某个渠道没配时,不该把按钮变成一个点了没反应的空链接 |
|||
if (!key || !a.address) continue; |
|||
if (key === 'appStore') next.appStore = a.address; |
|||
else if (key === 'apk') next.apk = a.address; |
|||
else next.stores[key] = a.address; |
|||
} |
|||
cur = next; |
|||
render(cur); |
|||
}; |
|||
// onerror/ontimeout 不做任何事:页面已经用兜底值渲染过了,此时报错只会吓到用户 |
|||
xhr.send(JSON.stringify({ version: '' })); |
|||
})(); |
|||
</script> |
|||
</body> |
|||
</html> |
|||
Loading…
Reference in new issue