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