Browse Source

服务端:音乐模块与腾讯云 COS 整体下线;新增分发桶(App 安装包与录音/固件分开放)

音乐: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
Rodger-Wang 3 weeks ago
parent
commit
2cf260eb05
  1. 46
      apps/services/comm/ossconf.go
  2. 8
      apps/services/comm/svcruntime.go
  3. 2
      apps/services/modules/agents/api_test.go
  4. 31
      apps/services/modules/api/api_getcostoken.go
  5. 2
      apps/services/modules/api/model_test.go
  6. 11
      apps/services/modules/console/api_svctemplate.go
  7. 54
      apps/services/modules/console/api_upload_test.go
  8. 24
      apps/services/modules/console/inspect_probe.go
  9. 2
      apps/services/modules/console/model_environment.go
  10. 6
      apps/services/modules/mcp/module.go
  11. 115
      apps/services/modules/mcp/tool_music.go
  12. 27
      apps/services/modules/music/api.go
  13. 55
      apps/services/modules/music/api_search.go
  14. 38
      apps/services/modules/music/api_url.go
  15. 26
      apps/services/modules/music/core.go
  16. 20
      apps/services/modules/music/model.go
  17. 1
      apps/services/modules/music/model_test.go
  18. 49
      apps/services/modules/music/module.go
  19. 27
      apps/services/modules/music/options.go
  20. 47
      apps/services/modules/user/api_setting.go
  21. 1
      apps/services/modules/user/options.go
  22. 8
      apps/services/services/api/main.go
  23. 14
      apps/services/services/home/main.go
  24. 4
      apps/services/sys/aliyun/filetrans/sys_test.go
  25. 2
      apps/services/sys/bytedance/audomodel/sys_test.go
  26. 623
      apps/services/sys/music/core.go
  27. 187
      apps/services/sys/music/music.go
  28. 40
      apps/services/sys/music/options.go
  29. 83
      apps/services/sys/music/sys_test.go
  30. 66
      apps/services/sys/musicobj/core.go
  31. 117
      apps/services/sys/musicobj/music.go
  32. 40
      apps/services/sys/musicobj/options.go
  33. 29
      apps/services/sys/musicobj/sys_test.go
  34. 2
      apps/services/sys/pay/alipay/sys_test.go
  35. 2
      apps/services/sys/pay/appleiap/sys_test.go
  36. 2
      apps/services/sys/pay/appleiap/token_test.go
  37. 2
      apps/services/sys/pay/googleiap/sys_test.go
  38. 2
      apps/services/sys/pay/wechat/sys_test.go
  39. 55
      apps/services/sys/tencentyun/cos/core.go
  40. 100
      apps/services/sys/tencentyun/cos/cos.go
  41. 80
      apps/services/sys/tencentyun/cos/options.go
  42. 193
      apps/services/sys/tencentyun/cos/sys_test.go
  43. 2
      build.sh
  44. 10
      deploy/README.md
  45. 5
      deploy/admin/README.md
  46. 8
      deploy/admin/dev-deploy.sh
  47. 4
      deploy/admin/docker-compose.yml
  48. 4
      deploy/admin/prod-build.sh
  49. 6
      deploy/admin/prod-deploy.sh
  50. 3
      deploy/app/README.md
  51. 9
      deploy/app/confs/api.yaml.example
  52. 18
      deploy/app/confs/home.yaml.example
  53. 2
      deploy/app/confs/mcp.yaml.example
  54. 2
      deploy/app/confs/timer.yaml.example
  55. 6
      deploy/app/dev-deploy.sh
  56. 2
      deploy/app/docker-compose.yml
  57. 2
      deploy/app/prod-build.sh
  58. 10
      deploy/app/prod-deploy.sh
  59. 3
      deploy/console/README.md
  60. 6
      deploy/console/dev-deploy.sh
  61. 4
      deploy/console/docker-compose.yml
  62. 2
      deploy/console/prod-build.sh
  63. 6
      deploy/console/prod-deploy.sh
  64. 4
      deploy/dev-deploy-all.sh
  65. 209
      deploy/dl/README.md
  66. 31
      deploy/dl/build.sh
  67. 380
      deploy/dl/index.html
  68. 380
      deploy/dl/index.template.html
  69. 37
      deploy/registry-profiles.sh

46
apps/services/comm/ossconf.go

@ -15,6 +15,44 @@ type AliyunOSSConf struct {
AccessSecret string AccessSecret string
Bucket string Bucket string
Domain string // 可选:自定义 CDN 域名,配了则访问 URL 走该域名 Domain string // 可选:自定义 CDN 域名,配了则访问 URL 走该域名
// 「分发桶」:放对外公开下载的东西(App 安装包),与放用户录音/固件的主桶分开。
// 分开的理由不是洁癖:安装包域名容易被微信/浏览器判风险,混在主桶会连累
// 录音和后台直传;而且几十 MB × 几千次下载的流量账单要能单独看。
// 同一个 OSS 账号,所以 Endpoint/AK/SK 复用主桶那份,只有桶名和域名不同。
DistBucket string // 留空用 DefaultDistBucket
DistDomain string // 留空用 DefaultDistDomain
}
// 分发桶的默认值。运营在【第三方服务配置 → 存储】填了 dist_bucket / dist_domain 就以配置为准。
//
// ⚠️ DefaultDistDomain 必须与 deploy/dl 那个下载落地页所在的桶一致——
// 页面和安装包同源才没有跨域与防盗链问题。
const (
DefaultDistBucket = "ymai-dl"
DefaultDistDomain = "https://dl.ymaikj.com"
)
// DistUploadDirs 分发桶里允许上传到的顶层目录白名单。
//
// ⚠️ 不做白名单的话,前端传什么 path 就在桶里造什么目录——这个桶是公共读、
// 直接挂在 dl.ymaikj.com 上的,等于把它变成任人摆放的公开网盘。
var DistUploadDirs = map[string]bool{
"android": true, // App 安装包(apk)
}
// ForDist 返回一份指向「分发桶」的配置副本。凭据与主桶共用。
func (oc *AliyunOSSConf) ForDist() *AliyunOSSConf {
d := *oc
d.Bucket = oc.DistBucket
if d.Bucket == "" {
d.Bucket = DefaultDistBucket
}
d.Domain = oc.DistDomain
if d.Domain == "" {
d.Domain = DefaultDistDomain
}
return &d
} }
// AliyunOSSProvider svc_config / sys_service_config 中阿里云对象存储的 provider 值。 // AliyunOSSProvider svc_config / sys_service_config 中阿里云对象存储的 provider 值。
@ -94,6 +132,8 @@ func LoadAliyunOSSConf(appName, encKey string) (*AliyunOSSConf, error) {
AccessSecret: get("access_key_secret"), AccessSecret: get("access_key_secret"),
Bucket: get("bucket"), Bucket: get("bucket"),
Domain: get("domain"), Domain: get("domain"),
DistBucket: get("dist_bucket"),
DistDomain: get("dist_domain"),
} }
if err := oc.normalize(); err != nil { if err := oc.normalize(); err != nil {
return nil, err return nil, err
@ -121,6 +161,8 @@ func loadOSSFromSysConfig(encKey string) *AliyunOSSConf {
AccessSecret: strings.TrimSpace(cfg.SysFieldPlain("access_key_secret", encKey)), AccessSecret: strings.TrimSpace(cfg.SysFieldPlain("access_key_secret", encKey)),
Bucket: strings.TrimSpace(cfg.SysFieldPlain("bucket", encKey)), Bucket: strings.TrimSpace(cfg.SysFieldPlain("bucket", encKey)),
Domain: strings.TrimSpace(cfg.SysFieldPlain("domain", encKey)), Domain: strings.TrimSpace(cfg.SysFieldPlain("domain", encKey)),
DistBucket: strings.TrimSpace(cfg.SysFieldPlain("dist_bucket", encKey)),
DistDomain: strings.TrimSpace(cfg.SysFieldPlain("dist_domain", encKey)),
} }
if oc.normalize() != nil { if oc.normalize() != nil {
return nil return nil
@ -150,8 +192,8 @@ func (oc *AliyunOSSConf) PublicURL(signedURL, key string) string {
if d := strings.TrimSpace(oc.Domain); d != "" { if d := strings.TrimSpace(oc.Domain); d != "" {
d = strings.TrimPrefix(strings.TrimPrefix(d, "https://"), "http://") d = strings.TrimPrefix(strings.TrimPrefix(d, "https://"), "http://")
d = strings.Trim(d, "/") d = strings.Trim(d, "/")
// 只取第一段做 host:运营常把域名连着路径一起填(cdn.ymaikj.com/), // 只取第一段做 host:运营常把域名连着路径一起填(oss.ymaikj.com/),
// 那样直接当 Host 用会拼出 https://cdn.ymaikj.com//EAIMAR/... 这种取不到的地址。 // 那样直接当 Host 用会拼出 https://oss.ymaikj.com//EAIMAR/... 这种取不到的地址。
if i := strings.Index(d, "/"); i >= 0 { if i := strings.Index(d, "/"); i >= 0 {
d = d[:i] d = d[:i]
} }

8
apps/services/comm/svcruntime.go

@ -15,16 +15,16 @@ import (
// //
// 目标:业务服务启动时—— // 目标:业务服务启动时——
// 1. 先用本地 yaml/env 连上 console 共享库(postgres); // 1. 先用本地 yaml/env 连上 console 共享库(postgres);
// 2. 若库中没有本(应用,区域,服务)的配置,则把当前文件里的基础设施配置(DSN/Redis/NATS/COS…) seed 进库(source=file), // 2. 若库中没有本(应用,区域,服务)的配置,则把当前文件里的基础设施配置(DSN/Redis/NATS…) seed 进库(source=file),
// 这样 console「应用的服务配置」页立刻能看到默认值; // 这样 console「应用的服务配置」页立刻能看到默认值;
// 3. 若库中已有,则以库为准,覆盖内存里的 Sys 配置(后台改过的值生效),再继续初始化 mysql/redis/cos 等。 // 3. 若库中已有,则以库为准,覆盖内存里的 Sys 配置(后台改过的值生效),再继续初始化 mysql/redis 等。
// //
// 注意:postgres 自身是 bootstrap(要先连它才能读配置),故它不纳入托管;其覆盖也不会在本次启动生效。 // 注意:postgres 自身是 bootstrap(要先连它才能读配置),故它不纳入托管;其覆盖也不会在本次启动生效。
// 密钥字段(DSN/密码/secret/token)以 AES-256-GCM 密文落库(comm.Encrypt,密钥 ${FIELD_ENCRYPT_KEY}), // 密钥字段(DSN/密码/secret/token)以 AES-256-GCM 密文落库(comm.Encrypt,密钥 ${FIELD_ENCRYPT_KEY}),
// console 与业务服务必须配同一把 key。 // console 与业务服务必须配同一把 key。
// svcRuntimeSections 纳入托管的 Sys 子系统(基础设施/运行配置)。postgres/log/lgid 等 bootstrap 不含。 // svcRuntimeSections 纳入托管的 Sys 子系统(基础设施/运行配置)。postgres/log/lgid 等 bootstrap 不含。
var svcRuntimeSections = []string{"mysql", "redis", "nats", "cos", "email", "translate"} var svcRuntimeSections = []string{"mysql", "redis", "nats", "email", "translate"}
// AppServiceConfig 一行 = 某应用×区域×服务 的一个配置项(扁平化点号无关,直接存 section+key)。 // AppServiceConfig 一行 = 某应用×区域×服务 的一个配置项(扁平化点号无关,直接存 section+key)。
// 存 console 共享库(postgres.GetSys()),与 global_config 同库;console 前端读写、业务服务启动 seed/读取。 // 存 console 共享库(postgres.GetSys()),与 global_config 同库;console 前端读写、业务服务启动 seed/读取。
@ -33,7 +33,7 @@ type AppServiceConfig struct {
AppName string `gorm:"column:app_name;size:64;uniqueIndex:uidx_svcconf" json:"app_name"` // 应用名(app_registry.app_name) AppName string `gorm:"column:app_name;size:64;uniqueIndex:uidx_svcconf" json:"app_name"` // 应用名(app_registry.app_name)
Region string `gorm:"column:region;size:32;uniqueIndex:uidx_svcconf" json:"region"` // 区域(app_registry.region),可空 Region string `gorm:"column:region;size:32;uniqueIndex:uidx_svcconf" json:"region"` // 区域(app_registry.region),可空
Service string `gorm:"column:service;size:32;uniqueIndex:uidx_svcconf" json:"service"` // 服务类型 api/gateway/home… Service string `gorm:"column:service;size:32;uniqueIndex:uidx_svcconf" json:"service"` // 服务类型 api/gateway/home…
Section string `gorm:"column:section;size:32;uniqueIndex:uidx_svcconf" json:"section"` // 配置分区 mysql/redis/nats/cos… Section string `gorm:"column:section;size:32;uniqueIndex:uidx_svcconf" json:"section"` // 配置分区 mysql/redis/nats…
Key string `gorm:"column:key;size:64;uniqueIndex:uidx_svcconf" json:"key"` // 配置键 Dsn/Addr/Password… Key string `gorm:"column:key;size:64;uniqueIndex:uidx_svcconf" json:"key"` // 配置键 Dsn/Addr/Password…
Value string `gorm:"column:value;type:text" json:"value"` // 值(密钥字段存 AES-GCM 密文;非标量存 JSON) Value string `gorm:"column:value;type:text" json:"value"` // 值(密钥字段存 AES-GCM 密文;非标量存 JSON)
Encrypted bool `gorm:"column:encrypted" json:"encrypted"` // 是否密钥字段(前端脱敏、落库加密) Encrypted bool `gorm:"column:encrypted" json:"encrypted"` // 是否密钥字段(前端脱敏、落库加密)

2
apps/services/modules/agents/api_test.go

@ -9,7 +9,7 @@ import (
) )
const ( const (
baseURL = "https://voitrans.ideapsound.com" baseURL = "https://ym-dev.ymaikj.com"
appID = "app_346bbeb6dc3bd37c" appID = "app_346bbeb6dc3bd37c"
appSecret = "sk_RJHRhzpost64qus5SCBlzECbPdMaiG_fcv7eJ-3u" appSecret = "sk_RJHRhzpost64qus5SCBlzECbPdMaiG_fcv7eJ-3u"
) )

31
apps/services/modules/api/api_getcostoken.go

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

2
apps/services/modules/api/model_test.go

@ -183,7 +183,7 @@ func Test_TavilyApi(t *testing.T) {
//  测试api返回效率 //  测试api返回效率
func Test_API_AI(t *testing.T) { func Test_API_AI(t *testing.T) {
// 测试参数 // 测试参数
apiURL := "http://ideapsound.com/ai/funcchat" // 替换为你的实际地址 apiURL := "http://127.0.0.1:8080/ai/funcchat" // 替换为你的实际地址
testMessage := &AIChatReq{ testMessage := &AIChatReq{
Msg: "今天的天气如何", Msg: "今天的天气如何",
Contexts: []string{ Contexts: []string{

11
apps/services/modules/console/api_svctemplate.go

@ -352,9 +352,14 @@ func builtinSvcTemplates() []ThirdSvcTemplate {
// 必须仍是 OSS 源站,签名是按源站 host 算的,换域名会被判签名不符。 // 必须仍是 OSS 源站,签名是按源站 host 算的,换域名会被判签名不符。
tpl("storage_aliyun_oss", "aliyun_oss", "阿里云 OSS", svcCatStorage, 0, tpl("storage_aliyun_oss", "aliyun_oss", "阿里云 OSS", svcCatStorage, 0,
sf("access_key_id", "AccessKey ID", true), sf("access_key_secret", "AccessKey Secret", true), sf("bucket", "存储桶", false), sf("endpoint", "地域节点 Endpoint", false), sf("access_key_id", "AccessKey ID", true), sf("access_key_secret", "AccessKey Secret", true), sf("bucket", "存储桶", false), sf("endpoint", "地域节点 Endpoint", false),
sf("domain", "自定义域名(可选,如 cdn.ymaikj.com;留空用 OSS 源站地址)", false)), sf("domain", "自定义域名(可选,如 oss.ymaikj.com;留空用 OSS 源站地址)", false),
tpl("storage_tencent_cos", "tencent_cos", "腾讯云 COS", svcCatStorage, 1, // 分发桶:放对外公开下载的 App 安装包,与放录音/固件的主桶分开。凭据复用上面那套。
sf("secret_id", "SecretId", true), sf("secret_key", "SecretKey", true), sf("bucket", "存储桶", false), sf("region", "地域,如 ap-guangzhou", false)), // 留空则用 comm.DefaultDistBucket / DefaultDistDomain(ymai-dl / dl.ymaikj.com)。
sf("dist_bucket", "分发桶(可选,放App安装包;留空用 ymai-dl)", false),
sf("dist_domain", "分发桶域名(可选;留空用 https://dl.ymaikj.com)", false)),
// 2026-09-23:腾讯云 COS 模板已删除。服务端读存储配置的唯一入口是 comm.LoadAliyunOSSConf,
// 它只认 provider=aliyun_oss —— 留着 COS 这一项只会让运营配完发现上传照样走 OSS,
// 是个配得上、却不生效的陷阱。存量 tencent_cos 行不会被自动删除,但也不再有任何读取方。
// MCP 服务(模型上下文协议)。字段均非加密:url 服务地址,type 传输类型(0=HTTP/1=SSE),tools 启用工具白名单。 // MCP 服务(模型上下文协议)。字段均非加密:url 服务地址,type 传输类型(0=HTTP/1=SSE),tools 启用工具白名单。
// 各区域的实际 url/tools 通过「区域覆盖分叉」维护,运行时按客户端区域解析。 // 各区域的实际 url/tools 通过「区域覆盖分叉」维护,运行时按客户端区域解析。

54
apps/services/modules/console/api_upload_test.go

@ -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"])
}
}

24
apps/services/modules/console/inspect_probe.go

@ -22,7 +22,6 @@ import (
"crypto/sha1" "crypto/sha1"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
@ -32,7 +31,6 @@ import (
"time" "time"
ossSdk "github.com/aliyun/aliyun-oss-go-sdk/oss" ossSdk "github.com/aliyun/aliyun-oss-go-sdk/oss"
cosSdk "github.com/tencentyun/cos-go-sdk-v5"
"golang.org/x/oauth2/google" "golang.org/x/oauth2/google"
"yunyan/comm" "yunyan/comm"
@ -82,8 +80,6 @@ func probeService(ctx context.Context, provider string, cats map[int32]bool, fie
return probeGoogle(ctx, fields) return probeGoogle(ctx, fields)
case "aliyun_oss": case "aliyun_oss":
return probeAliyunOSS(ctx, fields) return probeAliyunOSS(ctx, fields)
case "tencent_cos":
return probeTencentCOS(ctx, fields)
case "alibaba": case "alibaba":
return probeAlibaba(ctx, fields) return probeAlibaba(ctx, fields)
case "doubao": case "doubao":
@ -431,26 +427,6 @@ func probeAliyunOSS(ctx context.Context, fields map[string]string) probeResult {
return credOK("阿里云 OSS 凭据有效,存储桶 " + bucket + " 可访问") return credOK("阿里云 OSS 凭据有效,存储桶 " + bucket + " 可访问")
} }
// probeTencentCOS HEAD Bucket:只读、免费,验证 SecretId/Key + 桶存在 + 有权限。
func probeTencentCOS(ctx context.Context, fields map[string]string) probeResult {
id, key := pick(fields, "secret_id"), pick(fields, "secret_key")
bucket, region := pick(fields, "bucket"), pick(fields, "region")
if id == "" || key == "" || bucket == "" || region == "" {
return credFail("腾讯云 COS 缺少 SecretId/SecretKey/bucket/region,无法验证")
}
u, err := url.Parse(fmt.Sprintf("https://%s.cos.%s.myqcloud.com", bucket, region))
if err != nil {
return credFail("腾讯云 COS 地址拼装失败: " + err.Error())
}
client := cosSdk.NewClient(&cosSdk.BaseURL{BucketURL: u}, &http.Client{
Transport: &cosSdk.AuthorizationTransport{SecretID: id, SecretKey: key},
})
if _, err = client.Bucket.Head(ctx); err != nil {
return credFail("腾讯云 COS 访问存储桶失败: " + err.Error())
}
return credOK("腾讯云 COS 凭据有效,存储桶 " + bucket + " 可访问")
}
// ============================== 火山引擎 / 字节跳动 ============================== // ============================== 火山引擎 / 字节跳动 ==============================
// probeDoubao 豆包有两套凭据:方舟大模型走 api_key(OpenAI 兼容),翻译走火山 AK/SK 签名。 // probeDoubao 豆包有两套凭据:方舟大模型走 api_key(OpenAI 兼容),翻译走火山 AK/SK 签名。

2
apps/services/modules/console/model_environment.go

@ -28,7 +28,7 @@ type AppEnvironment struct {
SshPrivateKey string `gorm:"column:ssh_private_key;type:text" json:"ssh_private_key"` // DEPLOY_KEY 私钥内容(PEM)【加密】 SshPrivateKey string `gorm:"column:ssh_private_key;type:text" json:"ssh_private_key"` // DEPLOY_KEY 私钥内容(PEM)【加密】
// ── 私有镜像仓库 + Docker 网络(节点级共享;镜像名/tag 是应用维度,放部署行)── // ── 私有镜像仓库 + Docker 网络(节点级共享;镜像名/tag 是应用维度,放部署行)──
Registry string `gorm:"column:registry;size:255" json:"registry"` // 如 docker-registry.ideapsound.com Registry string `gorm:"column:registry;size:255" json:"registry"` // 如 registry.ymaikj.com
RegistryUser string `gorm:"column:registry_user;size:64" json:"registry_user"` // RegistryUser string `gorm:"column:registry_user;size:64" json:"registry_user"` //
RegistryPass string `gorm:"column:registry_pass;size:255" json:"registry_pass"` // 【加密】 RegistryPass string `gorm:"column:registry_pass;size:255" json:"registry_pass"` // 【加密】
DockerNetwork string `gorm:"column:docker_network;size:64" json:"docker_network"` // 如 1panel-network DockerNetwork string `gorm:"column:docker_network;size:64" json:"docker_network"` // 如 1panel-network

6
apps/services/modules/mcp/module.go

@ -81,13 +81,7 @@ func (this *Mcp) OnInstallComp() {
this.RegisterComp(new(tool_tavily_search)) this.RegisterComp(new(tool_tavily_search))
this.RegisterComp(new(tool_hefeng_weather)) this.RegisterComp(new(tool_hefeng_weather))
this.RegisterComp(new(tool_music))
this.RegisterComp(new(tool_migu_music)) this.RegisterComp(new(tool_migu_music))
// this.RegisterComp(new(tool_music_pause))
// this.RegisterComp(new(tool_music_resume))
// this.RegisterComp(new(tool_music_prev))
// this.RegisterComp(new(tool_music_next))
// this.RegisterComp(new(tool_music_close))
// 记忆中心(拾忆):只读工具。写操作留在 home 的 memory 模块, // 记忆中心(拾忆):只读工具。写操作留在 home 的 memory 模块,
// 在这里复制一份写路径的校验/幂等/提醒重算必然漂移。 // 在这里复制一份写路径的校验/幂等/提醒重算必然漂移。

115
apps/services/modules/mcp/tool_music.go

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

27
apps/services/modules/music/api.go

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

55
apps/services/modules/music/api_search.go

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

38
apps/services/modules/music/api_url.go

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

26
apps/services/modules/music/core.go

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

20
apps/services/modules/music/model.go

@ -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
apps/services/modules/music/model_test.go

@ -1 +0,0 @@
package music_test

49
apps/services/modules/music/module.go

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

27
apps/services/modules/music/options.go

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

47
apps/services/modules/user/api_setting.go

@ -1,14 +1,21 @@
package user package user
import ( import (
"fmt"
"io"
"path"
"strings"
"yunyan/comm" "yunyan/comm"
"yunyan/lego/sys/mysql" "yunyan/lego/sys/mysql"
"yunyan/lego/sys/wordfilter" "yunyan/lego/sys/wordfilter"
"yunyan/pb" "yunyan/pb"
"yunyan/sys/tencentyun/cos" "yunyan/sys/aliyun/oss"
"yunyan/utils"
ossSDK "github.com/aliyun/aliyun-oss-go-sdk/oss"
"os" "os"
"path/filepath" "path/filepath"
"yunyan/utils"
) )
// @Summary 用户设置接口 // @Summary 用户设置接口
@ -72,7 +79,7 @@ func (this *apiComp) Setting(session comm.IUserSession, req *pb.UserSettingReq)
// 获取文件扩展名 // 获取文件扩展名
ext := filepath.Ext(filename) ext := filepath.Ext(filename)
// 上传 - 使用用户ID+扩展名作为文件名避免冲突 // 上传 - 使用用户ID+扩展名作为文件名避免冲突
publicURL, err = cos.Put("/userAvatar/"+session.GetUserId()+ext, file) publicURL, err = putAvatar(session.GetUserId(), ext, file)
if err != nil { if err != nil {
errdata = &pb.ErrorData{ errdata = &pb.ErrorData{
Code: pb.ErrorCode_StorageError, Code: pb.ErrorCode_StorageError,
@ -98,3 +105,37 @@ func (this *apiComp) Setting(session comm.IUserSession, req *pb.UserSettingReq)
} }
return return
} }
// putAvatar 把头像传到阿里云 OSS,返回可直接访问的地址。
//
// 原实现走腾讯云 COS(sys/tencentyun/cos),2026-09-23 随 COS 整体下线改到 OSS,
// 与 user_getuploadurl / console 后台直传共用 comm.LoadAliyunOSSConf 那一份配置——
// 分家会让运维要盯两套存储。
//
// object key 用 uid 拼死且**不带随机段**,即同一用户换头像覆盖同一个对象:
// 与 COS 时期的行为一致(不留历史文件),代价是 URL 不变、客户端可能吃到缓存。
func putAvatar(uid, ext string, r io.Reader) (string, error) {
// 扩展名白名单,且必须是图片:这个入口收的是头像,放开任意扩展名等于多一个任人上传的通道。
ct := uploadExtWhitelist[strings.ToLower(ext)]
if !strings.HasPrefix(ct, "image/") {
return "", fmt.Errorf("不支持的头像格式: %s", ext)
}
conf, err := comm.LoadAliyunOSSConf(comm.AppName(), os.Getenv("FIELD_ENCRYPT_KEY"))
if err != nil {
return "", err
}
client, err := oss.NewPresigner(conf.Endpoint, conf.AccessKeyId, conf.AccessSecret, conf.Bucket)
if err != nil {
return "", fmt.Errorf("初始化阿里云 OSS 失败: %w", err)
}
key := path.Join(comm.OSSUploadRoot, "User", uid, "Avatar", uid+strings.ToLower(ext))
if err = client.UploadObject(key, r, ossSDK.ContentType(ct)); err != nil {
return "", err
}
// 没配自定义域名时 PublicURL 要从一个签名 URL 上摘 host,签名是纯本地运算、不发网络请求。
signed, err := client.GetURL(key, 60)
if err != nil {
return "", err
}
return conf.PublicURL(signed, key), nil
}

1
apps/services/modules/user/options.go

@ -19,7 +19,6 @@ type (
// user_getmcptoken 直接报错,不会退化成用 TokenKey 签。 // user_getmcptoken 直接报错,不会退化成用 TokenKey 签。
McpTokenKey string McpTokenKey string
CBCKey string CBCKey string
AvatarUrl string //头像地址
UserGroup int //用户分组 UserGroup int //用户分组
OpenTestAccount bool //是否开启测试账号 OpenTestAccount bool //是否开启测试账号
TestAccounts []string //是否测试账号 TestAccounts []string //是否测试账号

8
apps/services/services/api/main.go

@ -9,7 +9,6 @@ import (
"yunyan/services" "yunyan/services"
"yunyan/sys/email" "yunyan/sys/email"
"yunyan/sys/microsoft/translate" "yunyan/sys/microsoft/translate"
"yunyan/sys/tencentyun/cos"
"flag" "flag"
"fmt" "fmt"
"os" "os"
@ -69,7 +68,7 @@ func (this *Service) InitSys() {
// 服务配置托管:库优先、无则读文件回写(seed)。应用标识取部署身份(comm.AppName/AppRegion, // 服务配置托管:库优先、无则读文件回写(seed)。应用标识取部署身份(comm.AppName/AppRegion,
// 由 .env 的 ANALYZE_APP_NAME/ANALYZE_REGION 声明),FIELD_ENCRYPT_KEY 需与 console 一致。 // 由 .env 的 ANALYZE_APP_NAME/ANALYZE_REGION 声明),FIELD_ENCRYPT_KEY 需与 console 一致。
// 未声明应用名则跳过、保持纯文件配置(不产生孤儿数据)。 // 未声明应用名则跳过、保持纯文件配置(不产生孤儿数据)。
// 命中库配置时就地覆盖 Sys(mysql/redis/cos…),随后各 OnInit 使用被覆盖后的值。 // 命中库配置时就地覆盖 Sys(mysql/redis/nats…),随后各 OnInit 使用被覆盖后的值。
appName, region, encKey := comm.AppName(), comm.AppRegion(), os.Getenv("FIELD_ENCRYPT_KEY") appName, region, encKey := comm.AppName(), comm.AppRegion(), os.Getenv("FIELD_ENCRYPT_KEY")
if seeded, err := comm.LoadOrSeedServiceConfig(encKey, appName, region, "api", this.GetSettings().Sys); err != nil { if seeded, err := comm.LoadOrSeedServiceConfig(encKey, appName, region, "api", this.GetSettings().Sys); err != nil {
log.Warnf("init service config 托管失败(降级为纯文件配置): %s", err.Error()) log.Warnf("init service config 托管失败(降级为纯文件配置): %s", err.Error())
@ -100,11 +99,6 @@ func (this *Service) InitSys() {
} else { } else {
log.Infof("init sys.redis success!") log.Infof("init sys.redis success!")
} }
if err := cos.OnInit(this.GetSettings().Sys["cos"]); err != nil {
panic(fmt.Sprintf("init sys.cos err: %s", err.Error()))
} else {
log.Infof("init sys.cos success!")
}
// 邮件服务:可选能力,缺配置降级(未初始化时 SendMail* 返回错误),console 配好后可热重载生效。 // 邮件服务:可选能力,缺配置降级(未初始化时 SendMail* 返回错误),console 配好后可热重载生效。
// 不能 panic —— api 在 5-in-1 容器里一死,entrypoint 会连坐杀掉其余服务(测试服常不配发信凭据)。 // 不能 panic —— api 在 5-in-1 容器里一死,entrypoint 会连坐杀掉其余服务(测试服常不配发信凭据)。

14
apps/services/services/home/main.go

@ -9,7 +9,6 @@ import (
"yunyan/modules/analyze" "yunyan/modules/analyze"
"yunyan/modules/echomeet" "yunyan/modules/echomeet"
"yunyan/modules/memory" "yunyan/modules/memory"
musicmodule "yunyan/modules/music"
"yunyan/modules/pay" "yunyan/modules/pay"
"yunyan/modules/svccfg" "yunyan/modules/svccfg"
"yunyan/modules/user" "yunyan/modules/user"
@ -23,7 +22,6 @@ import (
"yunyan/sys/email" "yunyan/sys/email"
"yunyan/sys/google" "yunyan/sys/google"
"yunyan/sys/ipinfo" "yunyan/sys/ipinfo"
"yunyan/sys/musicobj"
"yunyan/sys/nats" "yunyan/sys/nats"
"yunyan/sys/pay/alipay" "yunyan/sys/pay/alipay"
"yunyan/sys/pay/appleiap" "yunyan/sys/pay/appleiap"
@ -31,7 +29,6 @@ import (
"yunyan/sys/pay/paypal" "yunyan/sys/pay/paypal"
"yunyan/sys/pay/wechat" "yunyan/sys/pay/wechat"
"yunyan/sys/sms" "yunyan/sys/sms"
"yunyan/sys/tencentyun/cos"
"yunyan/sys/websearch/bochasearch" "yunyan/sys/websearch/bochasearch"
"flag" "flag"
"fmt" "fmt"
@ -79,7 +76,6 @@ func main() {
agents.NewModule(), agents.NewModule(),
pay.NewModule(), pay.NewModule(),
echomeet.NewModule(), echomeet.NewModule(),
musicmodule.NewModule(),
allhelp.NewModule(), allhelp.NewModule(),
memory.NewModule(), //记忆中心(拾忆):闹钟/待办/灵感/花销 + 周报月报 memory.NewModule(), //记忆中心(拾忆):闹钟/待办/灵感/花销 + 周报月报
// bailianmodule.NewModule(), // bailianmodule.NewModule(),
@ -146,11 +142,6 @@ func (this *Service) InitSys() {
} else { } else {
log.Infof("init sys.nats success!") log.Infof("init sys.nats success!")
} }
if err := cos.OnInit(this.GetSettings().Sys["cos"]); err != nil {
panic(fmt.Sprintf("init sys.cos err: %s", err.Error()))
} else {
log.Infof("init sys.cos success!")
}
// 短信服务:可选能力,缺配置降级(未初始化时 SendCaptcha 返回错误),console 配好后可热重载生效。 // 短信服务:可选能力,缺配置降级(未初始化时 SendCaptcha 返回错误),console 配好后可热重载生效。
// 不能 panic —— home 在 5-in-1 容器里一死,entrypoint 会连坐杀掉其余服务(测试服常不配短信凭据)。 // 不能 panic —— home 在 5-in-1 容器里一死,entrypoint 会连坐杀掉其余服务(测试服常不配短信凭据)。
if err := sms.OnInit(this.GetSettings().Sys["sms"]); err != nil { if err := sms.OnInit(this.GetSettings().Sys["sms"]); err != nil {
@ -200,11 +191,6 @@ func (this *Service) InitSys() {
log.Infof("init sys.wechat_auth success!") log.Infof("init sys.wechat_auth success!")
} }
if err := musicobj.OnInit(this.GetSettings().Sys["musicobj"]); err != nil {
log.Warnf("init sys.musicobj err: %s (音乐对象存储未配置,相关功能不可用)", err.Error())
} else {
log.Infof("init sys.musicobj success!")
}
// 邮件服务:同 sms,可选能力,缺配置降级(未初始化时 SendMail* 返回错误)。 // 邮件服务:同 sms,可选能力,缺配置降级(未初始化时 SendMail* 返回错误)。
if err := email.OnInit(this.GetSettings().Sys["email"]); err != nil { if err := email.OnInit(this.GetSettings().Sys["email"]); err != nil {

4
apps/services/sys/aliyun/filetrans/sys_test.go

@ -16,7 +16,7 @@ const (
testWorkspaceKey = "sk-ws-H.ERXDMMY.8sEM.MEUCIDeZPGn6VJdlGEoWwhLG-w_2c4OT2IZSmmucug1sFPtmAiEA79Se7JiZ7CXw5AB33r3zXvSHPqwtcL7fThJSrq1e-gM" testWorkspaceKey = "sk-ws-H.ERXDMMY.8sEM.MEUCIDeZPGn6VJdlGEoWwhLG-w_2c4OT2IZSmmucug1sFPtmAiEA79Se7JiZ7CXw5AB33r3zXvSHPqwtcL7fThJSrq1e-gM"
) )
const testVietnameseAudio = "https://deepsound-1253517901.cos.ap-guangzhou.myqcloud.com/User/1942916924698525696/LocalAudio/2026/02/27/jLEIQzYIAnqy.wav" const testVietnameseAudio = "https://oss.ymaikj.com/EAIMAR/User/1942916924698525696/LocalAudio/2026/02/27/jLEIQzYIAnqy.wav"
// 注意 language 传的是 **短码**(language_hints 格式,即 toDashScopeLang 的输出),不是 BCP-47。 // 注意 language 传的是 **短码**(language_hints 格式,即 toDashScopeLang 的输出),不是 BCP-47。
// 传 "vi-VN" 会被 CreateTask 的语种校验直接拒掉。 // 传 "vi-VN" 会被 CreateTask 的语种校验直接拒掉。
@ -121,7 +121,7 @@ func TestCreateAndPollTask(t *testing.T) {
} }
taskID, err := sys.CreateTask( taskID, err := sys.CreateTask(
"https://deepsound-1253517901.cos.ap-guangzhou.myqcloud.com/User/2014183956768882688/LocalAudio/2026/01/22/kUqCZgiVkspH.wav", "https://oss.ymaikj.com/EAIMAR/User/2014183956768882688/LocalAudio/2026/01/22/kUqCZgiVkspH.wav",
"zh", "zh",
true, true,
"", "",

2
apps/services/sys/bytedance/audomodel/sys_test.go

@ -86,7 +86,7 @@ func TestCreateTask(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("初始化失败: %v", err) t.Fatalf("初始化失败: %v", err)
} }
taskID, logID, err := sys.CreateTask("2019585175758831616", "https://deepsound-1253517901.cos.ap-guangzhou.myqcloud.com/User/2019585175758831616/LocalAudio/2026/02/27/rLLkZPargyKZ.wav", false, "en-US", "https://zens.yunyanservice.xyz/api/home/echomeet_backcall", fmt.Sprintf("%d", 49)) taskID, logID, err := sys.CreateTask("2019585175758831616", "https://oss.ymaikj.com/EAIMAR/User/2019585175758831616/LocalAudio/2026/02/27/rLLkZPargyKZ.wav", false, "en-US", "https://ym-dev.ymaikj.com/api/home/echomeet_backcall", fmt.Sprintf("%d", 49))
if err != nil { if err != nil {
fmt.Printf("创建任务失败: %v (taskID=%s, logID=%s)", err, taskID, logID) fmt.Printf("创建任务失败: %v (taskID=%s, logID=%s)", err, taskID, logID)
} }

623
apps/services/sys/music/core.go

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

187
apps/services/sys/music/music.go

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

40
apps/services/sys/music/options.go

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

83
apps/services/sys/music/sys_test.go

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

66
apps/services/sys/musicobj/core.go

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

117
apps/services/sys/musicobj/music.go

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

40
apps/services/sys/musicobj/options.go

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

29
apps/services/sys/musicobj/sys_test.go

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

2
apps/services/sys/pay/alipay/sys_test.go

@ -33,7 +33,7 @@ func Test_Sys(t *testing.T) {
} else { } else {
orderid := sys.GenerateOrderNo("APP") orderid := sys.GenerateOrderNo("APP")
fmt.Println(orderid) fmt.Println(orderid)
results, err := sys.CreateAppOrder(context.TODO(), orderid, 100, "vip购买", "https://api.ideapsound.com/api/heom/pay_wechatnotify") results, err := sys.CreateAppOrder(context.TODO(), orderid, 100, "vip购买", "https://pay.ymaikj.com/api/home/pay_alipaynotify")
if err != nil { if err != nil {
t.Fatalf("CreateAppOrder 失败: %v", err) t.Fatalf("CreateAppOrder 失败: %v", err)
} }

2
apps/services/sys/pay/appleiap/sys_test.go

@ -8,7 +8,7 @@ import (
func TestNewSys(t *testing.T) { func TestNewSys(t *testing.T) {
sys, err := appleiap.NewSys( sys, err := appleiap.NewSys(
appleiap.SetAppStoreBundleID("com.saitong.voitrans"), appleiap.SetAppStoreBundleID("com.yimai.eaimar"),
appleiap.SetAppStoreIssuerID("96c4b3eb-5e5c-4719-8b07-db9cc4dc1f89"), appleiap.SetAppStoreIssuerID("96c4b3eb-5e5c-4719-8b07-db9cc4dc1f89"),
appleiap.SetAppStoreKeyID("VS538D3ZR5"), appleiap.SetAppStoreKeyID("VS538D3ZR5"),
appleiap.SetAppStorePrivateKeyPEM(`-----BEGIN PRIVATE KEY----- appleiap.SetAppStorePrivateKeyPEM(`-----BEGIN PRIVATE KEY-----

2
apps/services/sys/pay/appleiap/token_test.go

@ -29,7 +29,7 @@ func TestServerAPICredentials(t *testing.T) {
iss := "96c4b3eb-5e5c-4719-8b07-db9cc4dc1f89" iss := "96c4b3eb-5e5c-4719-8b07-db9cc4dc1f89"
kid := "VS538D3ZR5" kid := "VS538D3ZR5"
// StoreKit 要求 JWT 载荷包含 bid(bundleId),与交易所属 App 一致 // StoreKit 要求 JWT 载荷包含 bid(bundleId),与交易所属 App 一致
bundleID := "com.saitong.voitrans" bundleID := "com.yimai.eaimar"
// 方式A:把 .p8 全文粘贴到下面的变量(包含完整头尾与换行) // 方式A:把 .p8 全文粘贴到下面的变量(包含完整头尾与换行)
pem := `` pem := ``

2
apps/services/sys/pay/googleiap/sys_test.go

@ -26,7 +26,7 @@ func TestNewSys(t *testing.T) {
t.Errorf("NewSys() error = %v", err) t.Errorf("NewSys() error = %v", err)
return return
} }
ok, err := sys.VerifyProductPurchase(context.Background(), "com.saitong.voitrans", "ai_001", "hghmaneppaommckcmhaacfee.AO-J1OwkyeKZug3-alqW6GUnAJf0fNrrnxYcCJFdZ_0YtG_iUY4U5I50cRc-M3VqvoMnYw_qVO8A51kcrwML0mrgzqGtYlo5lQ") ok, err := sys.VerifyProductPurchase(context.Background(), "com.yimai.eaimar", "ai_001", "hghmaneppaommckcmhaacfee.AO-J1OwkyeKZug3-alqW6GUnAJf0fNrrnxYcCJFdZ_0YtG_iUY4U5I50cRc-M3VqvoMnYw_qVO8A51kcrwML0mrgzqGtYlo5lQ")
if err != nil { if err != nil {
t.Errorf("VerifyProductPurchase() error = %v", err) t.Errorf("VerifyProductPurchase() error = %v", err)
return return

2
apps/services/sys/pay/wechat/sys_test.go

@ -20,7 +20,7 @@ func Test_Sys(t *testing.T) {
} else { } else {
orderid := sys.GenerateOrderNo("APP") orderid := sys.GenerateOrderNo("APP")
fmt.Println(orderid) fmt.Println(orderid)
results, err := sys.CreateAppOrder(context.TODO(), orderid, 100, "vip购买", "https://api.ideapsound.com/api/heom/pay_wechatnotify") results, err := sys.CreateAppOrder(context.TODO(), orderid, 100, "vip购买", "https://pay.ymaikj.com/api/home/pay_wechatnotify")
fmt.Println(results, err) fmt.Println(results, err)
} }
} }

55
apps/services/sys/tencentyun/cos/core.go

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

100
apps/services/sys/tencentyun/cos/cos.go

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

80
apps/services/sys/tencentyun/cos/options.go

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

193
apps/services/sys/tencentyun/cos/sys_test.go

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

2
build.sh

@ -12,7 +12,7 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SERVICE="${1:-}" SERVICE="${1:-}"
[ -n "$SERVICE" ] || { echo "用法: ./build.sh <console|admin>"; exit 1; } [ -n "$SERVICE" ] || { echo "用法: ./build.sh <console|admin>"; exit 1; }
REGISTRY="${REGISTRY:-registry.voitrans.net}" REGISTRY="${REGISTRY:-registry.ymaikj.com}"
TAG="${TAG:-latest}" TAG="${TAG:-latest}"
PLATFORM="${PLATFORM:-linux/amd64}" PLATFORM="${PLATFORM:-linux/amd64}"
PUSH="${PUSH:-1}" PUSH="${PUSH:-1}"

10
deploy/README.md

@ -14,11 +14,11 @@ cd deploy
环境:目前只有 `along`(阿龙),与各服务 `dev-deploy.sh` 的 `ENVS` 同名,脚本启动时会交叉校验, 环境:目前只有 `along`(阿龙),与各服务 `dev-deploy.sh` 的 `ENVS` 同名,脚本启动时会交叉校验,
一边加了新环境另一边忘了加会直接报错。 一边加了新环境另一边忘了加会直接报错。
> ⚠️ **2026-09-13:云雁(yunyan)/灵谱(lingpu) 的部署目标已从本仓库的脚本中移除。** > ⚠️ **本仓库只剩阿龙一家,服务器与镜像仓库都是。**
> 移除的只是「往哪台服务器部署」——这些环境的 SSH 私钥路径指向的是另一台开发机,本机没有钥匙, > 2026-09-13 先移除了云雁/灵谱的部署目标(SSH 私钥指向另一台开发机,本机没有钥匙,
> 留着只会在菜单里制造误选。**镜像仓库没有动**:`prod-build.sh` 仍可选 `yunyan` / `lingpu` > 留着只会在菜单里制造误选);2026-09-23 又删掉了它们的镜像仓库档案
> 把镜像推到 `registry.voitrans.net` / `registry.lingpu.net`。 > (`registry.voitrans.net` / `registry.lingpu.net`),`prod-build.sh` 现在只能推阿龙仓库。
> 要恢复部署目标,从 git 历史里取回对应的 `env_profile` / `region_profile` 段即可(含 IP 与私钥路径)。 > 要恢复,从 git 历史里取回 `env_profile` / `region_profile` 段与 `registry_profile` 的对应 case。
它只是按顺序调各服务自己的 `deploy/<服务>/dev-deploy.sh <环境>` 并汇总结果,部署规则仍然 它只是按顺序调各服务自己的 `deploy/<服务>/dev-deploy.sh <环境>` 并汇总结果,部署规则仍然
只有一处;单独发某个服务照旧进服务目录跑 `./dev-deploy.sh <环境>`。服务器上还没有真实 `.env` 只有一处;单独发某个服务照旧进服务目录跑 `./dev-deploy.sh <环境>`。服务器上还没有真实 `.env`

5
deploy/admin/README.md

@ -46,9 +46,8 @@ docker compose up -d # 本机起 starpivot-admin
```bash ```bash
cd deploy/admin cd deploy/admin
./dev-deploy.sh along # 开发:发布到阿龙测试环境(构建 dev-latest → 推送 → 远端拉取重启) ./dev-deploy.sh along # 开发:发布到阿龙测试环境(构建 dev-latest → 推送 → 远端拉取重启)
./dev-deploy.sh yunyan # 开发:发布到云雁测试环境
./dev-deploy.sh # 不带参数 → 交互选择测试环境 ./dev-deploy.sh # 不带参数 → 交互选择测试环境
./prod-build.sh 0.1.1 yunyan # 生产①:构建并推送 starpivot-admin:0.1.1 (+latest);第2参数选公司 ./prod-build.sh 0.1.1 along # 生产①:构建并推送 starpivot-admin:0.1.1 (+latest);公司只剩 along,可省略
./prod-deploy.sh 0.2.1 along # 生产②:部署 0.2.1 到生产环境(目前只有 along,省略即用它) ./prod-deploy.sh 0.2.1 along # 生产②:部署 0.2.1 到生产环境(目前只有 along,省略即用它)
./prod-deploy.sh rollback along # 生产回滚到上一版本(与当前版本来回切换) ./prod-deploy.sh rollback along # 生产回滚到上一版本(与当前版本来回切换)
``` ```
@ -60,7 +59,7 @@ cd deploy/admin
- **部署目标与凭据**:写死在各脚本顶部。`dev-deploy.sh` 的 `env_profile()`(每个测试环境一套 `REGISTRY/DEV_HOST/DEV_KEY/DEV_DIR`,**目前只有 `along`**;新增环境加一段 case 并写进 `ENVS`)、`prod-deploy.sh` 的 `env_profile()`(每个生产环境一套 `REGISTRY/IMAGE_NAME/DEPLOY_HOST/DEPLOY_KEY/DEPLOY_DIR`,**目前只有 `along`**;新增环境加一段 case 并写进 `PROD_ENVS`); - **部署目标与凭据**:写死在各脚本顶部。`dev-deploy.sh` 的 `env_profile()`(每个测试环境一套 `REGISTRY/DEV_HOST/DEV_KEY/DEV_DIR`,**目前只有 `along`**;新增环境加一段 case 并写进 `ENVS`)、`prod-deploy.sh` 的 `env_profile()`(每个生产环境一套 `REGISTRY/IMAGE_NAME/DEPLOY_HOST/DEPLOY_KEY/DEPLOY_DIR`,**目前只有 `along`**;新增环境加一段 case 并写进 `PROD_ENVS`);
仓库账号 `admin` / 密码已写死;`DEPLOY_KEY` 默认指向阿龙那把钥匙,可用环境变量覆盖:`DEPLOY_KEY=~/Documents/keys/loginscre.pem ./prod-deploy.sh …`。 仓库账号 `admin` / 密码已写死;`DEPLOY_KEY` 默认指向阿龙那把钥匙,可用环境变量覆盖:`DEPLOY_KEY=~/Documents/keys/loginscre.pem ./prod-deploy.sh …`。
- 2026-09-13 移除了云雁/灵谱的部署目标(私钥在另一台开发机上,本机连不上);**镜像仓库未动**,`prod-build.sh` 仍可选这两家推镜像。 - 2026-09-13 移除了云雁/灵谱的部署目标(私钥在另一台开发机上,本机连不上);2026-09-23 又删掉了它们的镜像仓库档案,现在只能推阿龙仓库。
- ⚠️ 本目录脚本纳入 git,**公开仓库前务必清空** `*_HOST` 与 `REGISTRY_PASS`。 - ⚠️ 本目录脚本纳入 git,**公开仓库前务必清空** `*_HOST` 与 `REGISTRY_PASS`。
- **后端地址**:`.env` 的 `CONSOLE_BACKEND`,须指向同网络的 console 容器,默认 `http://starpivot-console:8080`。 - **后端地址**:`.env` 的 `CONSOLE_BACKEND`,须指向同网络的 console 容器,默认 `http://starpivot-console:8080`。

8
deploy/admin/dev-deploy.sh

@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# admin 开发环境:构建开发镜像 → 推送 → 远端拉取重启。 # admin 开发环境:构建开发镜像 → 推送 → 远端拉取重启。
# #
# Nuxt 管理前端打成镜像 yunyan-admin,推到私有仓库后远端拉取重启。 # Nuxt 管理前端打成镜像 starpivot-admin,推到私有仓库后远端拉取重启。
# 仅做镜像部署:服务器上的 docker-compose.yml / .env 由你自行维护 # 仅做镜像部署:服务器上的 docker-compose.yml / .env 由你自行维护
# (照着仓库里的 env/env.example 在服务器上放好真实 .env),本脚本不上传任何真实配置。 # (照着仓库里的 env/env.example 在服务器上放好真实 .env),本脚本不上传任何真实配置。
# #
@ -11,8 +11,8 @@
# #
# 开发版镜像始终使用固定 tag dev-latest,每次构建覆盖同一 tag。 # 开发版镜像始终使用固定 tag dev-latest,每次构建覆盖同一 tag。
set -euo pipefail set -euo pipefail
# ⚠️ 2026-09-13:云雁(yunyan)/灵谱(lingpu) 的部署目标已从本脚本移除——本仓库的部署目标只剩阿龙。 # ⚠️ 本仓库的部署目标只剩阿龙一家:2026-09-13 移除了云雁/灵谱的服务器,2026-09-23 连它们的
# 移除的是「往哪台服务器部署」,**镜像仓库没动**:prod-build.sh 仍可选 yunyan/lingpu 推镜像。 # 镜像仓库档案(registry.voitrans.net / registry.lingpu.net)也一并删除。需要找回查 git 历史。
# 要恢复:git log 里找回对应的 env_profile/region_profile 段即可(含 IP 与各自的 SSH 私钥路径)。 # 要恢复:git log 里找回对应的 env_profile/region_profile 段即可(含 IP 与各自的 SSH 私钥路径)。
SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # deploy/admin SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # deploy/admin
@ -35,7 +35,7 @@ IMAGE_NAME="starpivot-admin" # 业务镜像名
# ║ ⚠️ 公开仓库前务必清空 DEV_HOST / REGISTRY_PASS ║ # ║ ⚠️ 公开仓库前务必清空 DEV_HOST / REGISTRY_PASS ║
# ║ 新增环境:在 env_profile 加一段 case,并把环境名加进 ENVS 数组。 ║ # ║ 新增环境:在 env_profile 加一段 case,并把环境名加进 ENVS 数组。 ║
# ╚══════════════════════════════════════════════════════════════════════╝ # ╚══════════════════════════════════════════════════════════════════════╝
ENVS=(along) # 可选测试环境(目前只剩阿龙;云雁/灵谱已于 2026-09-13 移除,见文件头说明) ENVS=(along) # 可选测试环境(目前只剩阿龙,见文件头说明)
DEFAULT_ENV="along" # 无参数且无 TTY(CI/管道)时的默认环境 DEFAULT_ENV="along" # 无参数且无 TTY(CI/管道)时的默认环境
env_profile() { env_profile() {

4
deploy/admin/docker-compose.yml

@ -1,13 +1,13 @@
# admin 服务部署(单服务) # admin 服务部署(单服务)
# #
# 只部署 yunyan-admin(Nuxt 管理前端)。它通过 CONSOLE_BACKEND 连接同网络的 console 服务。 # 只部署 starpivot-admin(Nuxt 管理前端)。它通过 CONSOLE_BACKEND 连接同网络的 console 服务。
# 依赖服务由目标环境共享提供,不在此 compose。 # 依赖服务由目标环境共享提供,不在此 compose。
# #
# 不直接 docker compose 调用,统一通过 dev-deploy.sh / prod-deploy.sh(与 console 一致)。 # 不直接 docker compose 调用,统一通过 dev-deploy.sh / prod-deploy.sh(与 console 一致)。
services: services:
starpivot-admin: starpivot-admin:
image: ${REGISTRY:-registry.voitrans.net}/starpivot-admin:${TAG:-latest} image: ${REGISTRY:-registry.ymaikj.com}/starpivot-admin:${TAG:-latest}
container_name: starpivot-admin container_name: starpivot-admin
restart: unless-stopped restart: unless-stopped
platform: ${PLATFORM:-linux/amd64} platform: ${PLATFORM:-linux/amd64}

4
deploy/admin/prod-build.sh

@ -1,12 +1,12 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# admin 生产环境:仅「构建带版本号的镜像并推送」,不部署。 # admin 生产环境:仅「构建带版本号的镜像并推送」,不部署。
# #
# Nuxt 管理前端打成镜像 yunyan-admin,同时推送 :<version> 与 :latest 两个 tag。 # Nuxt 管理前端打成镜像 starpivot-admin,同时推送 :<version> 与 :latest 两个 tag。
# 部署用 ./prod-deploy.sh <version>。 # 部署用 ./prod-deploy.sh <version>。
# #
# 用法(在 deploy/admin/ 下): # 用法(在 deploy/admin/ 下):
# ./prod-build.sh <version> # version 必须形如 X.Y.Z,如 0.1.1 # ./prod-build.sh <version> # version 必须形如 X.Y.Z,如 0.1.1
# ./prod-build.sh <version> [公司] # 公司 = yunyan | lingpu | along(省略则交互选择;无 TTY 用默认 yunyan) # ./prod-build.sh <version> [公司] # 公司目前只剩 along,省略即用它
set -euo pipefail set -euo pipefail
SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

6
deploy/admin/prod-deploy.sh

@ -11,8 +11,8 @@
# 环境 = along(阿龙 47.116.104.181);省略 → 直接用它。 # 环境 = along(阿龙 47.116.104.181);省略 → 直接用它。
# ⚠️ 环境必须与 prod-build.sh 构建时所选公司一致,否则拉不到镜像。 # ⚠️ 环境必须与 prod-build.sh 构建时所选公司一致,否则拉不到镜像。
set -euo pipefail set -euo pipefail
# ⚠️ 2026-09-13:云雁(yunyan)/灵谱(lingpu) 的部署目标已从本脚本移除——本仓库的部署目标只剩阿龙。 # ⚠️ 本仓库的部署目标只剩阿龙一家:2026-09-13 移除了云雁/灵谱的服务器,2026-09-23 连它们的
# 移除的是「往哪台服务器部署」,**镜像仓库没动**:prod-build.sh 仍可选 yunyan/lingpu 推镜像。 # 镜像仓库档案(registry.voitrans.net / registry.lingpu.net)也一并删除。需要找回查 git 历史。
# 要恢复:git log 里找回对应的 env_profile/region_profile 段即可(含 IP 与各自的 SSH 私钥路径)。 # 要恢复:git log 里找回对应的 env_profile/region_profile 段即可(含 IP 与各自的 SSH 私钥路径)。
SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@ -35,7 +35,7 @@ DOCKER_NETWORK="1panel-network" # compose 外部网络名(与服务
# 容器名:必须与 docker-compose.yml 的 container_name 一字不差(本脚本会上传覆盖那份 compose)。 # 容器名:必须与 docker-compose.yml 的 container_name 一字不差(本脚本会上传覆盖那份 compose)。
# 仅用于 up 冲突时清理同名遗留容器;不随环境变化。 # 仅用于 up 冲突时清理同名遗留容器;不随环境变化。
CONTAINER_NAME="starpivot-admin" CONTAINER_NAME="starpivot-admin"
PROD_ENVS=(along) # 可选生产环境(目前只剩阿龙;云雁/灵谱已于 2026-09-13 移除,见文件头说明) PROD_ENVS=(along) # 可选生产环境(目前只剩阿龙,见文件头说明)
DEFAULT_PROD_ENV="along" # 无参数且无 TTY(CI/管道)时的默认环境 DEFAULT_PROD_ENV="along" # 无参数且无 TTY(CI/管道)时的默认环境
env_profile() { env_profile() {

3
deploy/app/README.md

@ -38,7 +38,7 @@
dev 部署目录默认 `/home/work/starpivot/app`;prod 按**应用**取自 `prod-deploy.sh` 的 `APPS` 表(如 `ym` → `/home/work/ym-a11`)。 dev 部署目录默认 `/home/work/starpivot/app`;prod 按**应用**取自 `prod-deploy.sh` 的 `APPS` 表(如 `ym` → `/home/work/ym-a11`)。
> 2026-09-13:prod 的可选区域只剩 `along`(云雁的 shanghai/japan/singapore 已移除,私钥在另一台开发机上)。 > prod 的可选区域只剩 `along`(其余几地已于 2026-09-13 移除,私钥在另一台开发机上)。
### 二、部署 ### 二、部署
@ -47,7 +47,6 @@ dev 部署目录默认 `/home/work/starpivot/app`;prod 按**应用**取自 `pr
```bash ```bash
cd deploy/voitrans cd deploy/voitrans
./dev-deploy.sh along # 发布到阿龙测试环境(构建 dev-latest → 推送 → 远端拉取重启) ./dev-deploy.sh along # 发布到阿龙测试环境(构建 dev-latest → 推送 → 远端拉取重启)
./dev-deploy.sh yunyan # 发布到云雁测试环境
./dev-deploy.sh # 不带参数 → 交互选择测试环境 ./dev-deploy.sh # 不带参数 → 交互选择测试环境
``` ```

9
deploy/app/confs/api.yaml.example

@ -23,14 +23,7 @@ sys:
Addr: ["${REDIS_ADDR:-redis:6379}"] Addr: ["${REDIS_ADDR:-redis:6379}"]
Password: ${REDIS_PASSWORD:-} Password: ${REDIS_PASSWORD:-}
DB: ${REDIS_DB:-1} DB: ${REDIS_DB:-1}
KeyPrefix: ${REDIS_KEY_PREFIX:-Voitrans} KeyPrefix: ${REDIS_KEY_PREFIX:-EAIMAR}
cos:
SecretID: ""
SecretKey: ""
AppId: "1253517901"
Region: ap-guangzhou
BucketName: "deepsound-1253517901"
BucketURL: "https://deepsound-1253517901.cos.ap-guangzhou.myqcloud.com"
translate: #Azure Translator,按需填写 translate: #Azure Translator,按需填写
Key: "${AZURE_TRANSLATE_KEY:-}" Key: "${AZURE_TRANSLATE_KEY:-}"
Region: "eastasia" Region: "eastasia"

18
deploy/app/confs/home.yaml.example

@ -52,7 +52,7 @@ sys:
Addr: ["${REDIS_ADDR:-redis:6379}"] Addr: ["${REDIS_ADDR:-redis:6379}"]
Password: ${REDIS_PASSWORD:-} Password: ${REDIS_PASSWORD:-}
DB: ${REDIS_DB:-1} DB: ${REDIS_DB:-1}
KeyPrefix: ${REDIS_KEY_PREFIX:-Voitrans} #key 应用分组前缀 KeyPrefix: ${REDIS_KEY_PREFIX:-EAIMAR} #key 应用分组前缀
sms: #短信服务(服务商 tencent | aliyun,留空按 tencent;也可在 console 后台「业务功能配置」里改,改完热生效) sms: #短信服务(服务商 tencent | aliyun,留空按 tencent;也可在 console 后台「业务功能配置」里改,改完热生效)
Provider: "tencent" Provider: "tencent"
#腾讯云 #腾讯云
@ -89,15 +89,6 @@ sys:
wechat_auth: wechat_auth:
AppID: wx4b5ec8957ad5e425 AppID: wx4b5ec8957ad5e425
AppSecret: "" AppSecret: ""
musicobj:
ApiBaseUrl: https://hw.music.voitrans.net
cos:
SecretID: ""
SecretKey: ""
AppId: "1253517901"
Region: ap-guangzhou
BucketName: "deepsound-1253517901"
BucketURL: "https://deepsound-1253517901.cos.ap-guangzhou.myqcloud.com"
wechatpay: #微信支付,需放置 ./apiclient_key.pem wechatpay: #微信支付,需放置 ./apiclient_key.pem
# 键名必须与 comm.ModuleCatalog 里 wechatpay 的 Key 逐字符一致(AppID/MchID,结尾大写 ID), # 键名必须与 comm.ModuleCatalog 里 wechatpay 的 Key 逐字符一致(AppID/MchID,结尾大写 ID),
# 否则 seedModuleRows 的 sec[f.Key] 查不到,这两个字段永远不入库、后台显示为空。 # 否则 seedModuleRows 的 sec[f.Key] 查不到,这两个字段永远不入库、后台显示为空。
@ -119,7 +110,11 @@ sys:
AppStoreIssuerID: "" AppStoreIssuerID: ""
AppStoreKeyID: "" AppStoreKeyID: ""
AppStorePrivateKeyPEM: "" AppStorePrivateKeyPEM: ""
AppStoreBundleID: com.saitong.voitrans # ⚠️ 必须与 iOS 工程的 PRODUCT_BUNDLE_IDENTIFIER 逐字相同:appleiap 拿它做两件事——
# 签 App Store Server API 的 JWT(载荷 bid)、以及校验交易归属(info.BundleID 不等就拒)。
# 填错的表现是**所有 iOS 内购校验都失败**,报「交易归属的 bundleId 不匹配」。
# 2026-09-23:原值是上一代品牌的 bundle id,与 iOS 工程对不上,已改正。
AppStoreBundleID: com.yimai.eaimar
UseSandbox: true UseSandbox: true
googleiap: googleiap:
ServiceAccountJSON: "" ServiceAccountJSON: ""
@ -143,7 +138,6 @@ modules:
Timezone: "Asia/Shanghai" Timezone: "Asia/Shanghai"
user: user:
Debug: ${LOG_DEBUG:-false} Debug: ${LOG_DEBUG:-false}
AvatarUrl: "https://api.ideapsound.com/avatar/"
TokenKey: "${GATEWAY_TOKEN_KEY:-}" TokenKey: "${GATEWAY_TOKEN_KEY:-}"
# ⚠️ 签发 MCP 会话令牌用,必须与 mcp.yaml 的 McpTokenKey 同值 # ⚠️ 签发 MCP 会话令牌用,必须与 mcp.yaml 的 McpTokenKey 同值
# (两边都读 .env 的 MCP_TOKEN_KEY)。留空则 user_getmcptoken 直接报错, # (两边都读 .env 的 MCP_TOKEN_KEY)。留空则 user_getmcptoken 直接报错,

2
deploy/app/confs/mcp.yaml.example

@ -25,8 +25,6 @@ sys:
ClientID: "" ClientID: ""
ClientSecret: "" ClientSecret: ""
RedirectURI: "http://localhost:8080/callback" RedirectURI: "http://localhost:8080/callback"
musicobj:
ApiBaseUrl: https://hw.music.ideapsound.com
bravesearch: bravesearch:
ApiKey: "" ApiKey: ""

2
deploy/app/confs/timer.yaml.example

@ -18,7 +18,7 @@ sys:
Addr: ["${REDIS_ADDR:-redis:6379}"] Addr: ["${REDIS_ADDR:-redis:6379}"]
Password: ${REDIS_PASSWORD:-} Password: ${REDIS_PASSWORD:-}
DB: ${REDIS_DB:-1} DB: ${REDIS_DB:-1}
KeyPrefix: ${REDIS_KEY_PREFIX:-Voitrans} KeyPrefix: ${REDIS_KEY_PREFIX:-EAIMAR}
modules: modules:
timer: timer:

6
deploy/app/dev-deploy.sh

@ -11,8 +11,8 @@
# #
# 参数只用于选环境;开发版镜像始终使用固定 tag dev-latest,每次构建覆盖同一 tag。 # 参数只用于选环境;开发版镜像始终使用固定 tag dev-latest,每次构建覆盖同一 tag。
set -euo pipefail set -euo pipefail
# ⚠️ 2026-09-13:云雁(yunyan)/灵谱(lingpu) 的部署目标已从本脚本移除——本仓库的部署目标只剩阿龙。 # ⚠️ 本仓库的部署目标只剩阿龙一家:2026-09-13 移除了云雁/灵谱的服务器,2026-09-23 连它们的
# 移除的是「往哪台服务器部署」,**镜像仓库没动**:prod-build.sh 仍可选 yunyan/lingpu 推镜像。 # 镜像仓库档案(registry.voitrans.net / registry.lingpu.net)也一并删除。需要找回查 git 历史。
# 要恢复:git log 里找回对应的 env_profile/region_profile 段即可(含 IP 与各自的 SSH 私钥路径)。 # 要恢复:git log 里找回对应的 env_profile/region_profile 段即可(含 IP 与各自的 SSH 私钥路径)。
SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # deploy/app SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # deploy/app
@ -35,7 +35,7 @@ IMAGE_NAME="starpivot-app" # 业务单镜像名
# ║ ⚠️ 公开仓库前务必清空 DEV_HOST / REGISTRY_PASS ║ # ║ ⚠️ 公开仓库前务必清空 DEV_HOST / REGISTRY_PASS ║
# ║ 新增环境:在 env_profile 加一段 case,并把环境名加进 ENVS 数组。 ║ # ║ 新增环境:在 env_profile 加一段 case,并把环境名加进 ENVS 数组。 ║
# ╚══════════════════════════════════════════════════════════════════════╝ # ╚══════════════════════════════════════════════════════════════════════╝
ENVS=(along) # 可选测试环境(目前只剩阿龙;云雁/灵谱已于 2026-09-13 移除,见文件头说明) ENVS=(along) # 可选测试环境(目前只剩阿龙,见文件头说明)
DEFAULT_ENV="along" # 无参数且无 TTY(CI/管道)时的默认环境 DEFAULT_ENV="along" # 无参数且无 TTY(CI/管道)时的默认环境
env_profile() { env_profile() {

2
deploy/app/docker-compose.yml

@ -14,7 +14,7 @@ services:
app: app:
# 镜像名由部署脚本注入(IMAGE_NAME,来自 deploy/registry-profiles.sh 的 prod_image_name), # 镜像名由部署脚本注入(IMAGE_NAME,来自 deploy/registry-profiles.sh 的 prod_image_name),
# 这里的默认值只在手工 docker compose 时兜底——别在此写死,写死过一次就和脚本漂移了。 # 这里的默认值只在手工 docker compose 时兜底——别在此写死,写死过一次就和脚本漂移了。
image: ${REGISTRY:-registry.voitrans.net}/${IMAGE_NAME:-starpivot-app}:${TAG:-latest} image: ${REGISTRY:-registry.ymaikj.com}/${IMAGE_NAME:-starpivot-app}:${TAG:-latest}
# 容器名按「应用」区分:prod-deploy.sh 注入 CONTAINER_NAME=<应用>-a11(ym → ym-a11), # 容器名按「应用」区分:prod-deploy.sh 注入 CONTAINER_NAME=<应用>-a11(ym → ym-a11),
# 并写回 .env,手工 docker compose 时也用同一个名字。默认值只给 dev / 未注入时兜底。 # 并写回 .env,手工 docker compose 时也用同一个名字。默认值只给 dev / 未注入时兜底。
container_name: ${CONTAINER_NAME:-starpivot-app} container_name: ${CONTAINER_NAME:-starpivot-app}

2
deploy/app/prod-build.sh

@ -6,7 +6,7 @@
# #
# 用法(在 deploy/voitrans/ 下): # 用法(在 deploy/voitrans/ 下):
# ./prod-build.sh <version> # 只要版本号(应用共用同一镜像,构建不分应用);version 必须形如 X.Y.Z,如 0.1.1 # ./prod-build.sh <version> # 只要版本号(应用共用同一镜像,构建不分应用);version 必须形如 X.Y.Z,如 0.1.1
# ./prod-build.sh <version> [公司] # 公司 = yunyan | lingpu | along(省略则交互选择;无 TTY 用默认 yunyan) # ./prod-build.sh <version> [公司] # 公司目前只剩 along,省略即用它
set -euo pipefail set -euo pipefail
SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

10
deploy/app/prod-deploy.sh

@ -20,8 +20,8 @@
# 应用: 见 APPS(默认 voitrans) 区域: along # 应用: 见 APPS(默认 voitrans) 区域: along
# 镜像仓库跟区域走:along → 阿龙仓库 registry.ymaikj.com;需与 prod-build.sh 所选公司一致。 # 镜像仓库跟区域走:along → 阿龙仓库 registry.ymaikj.com;需与 prod-build.sh 所选公司一致。
set -euo pipefail set -euo pipefail
# ⚠️ 2026-09-13:云雁(yunyan)/灵谱(lingpu) 的部署目标已从本脚本移除——本仓库的部署目标只剩阿龙。 # ⚠️ 本仓库的部署目标只剩阿龙一家:2026-09-13 移除了云雁/灵谱的服务器,2026-09-23 连它们的
# 移除的是「往哪台服务器部署」,**镜像仓库没动**:prod-build.sh 仍可选 yunyan/lingpu 推镜像。 # 镜像仓库档案(registry.voitrans.net / registry.lingpu.net)也一并删除。需要找回查 git 历史。
# 要恢复:git log 里找回对应的 env_profile/region_profile 段即可(含 IP 与各自的 SSH 私钥路径)。 # 要恢复:git log 里找回对应的 env_profile/region_profile 段即可(含 IP 与各自的 SSH 私钥路径)。
SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@ -37,7 +37,7 @@ yellow(){ printf "\033[33m%s\033[0m\n" "$*"; }
# ── 私有仓库凭据(写死;公开仓库前清空 REGISTRY_PASS)── # ── 私有仓库凭据(写死;公开仓库前清空 REGISTRY_PASS)──
# 镜像仓库按「区域所属公司」自动选(见 region_profile 里的 REGION_COMPANY:along 走自己的 # 镜像仓库按「区域所属公司」自动选(见 region_profile 里的 REGION_COMPANY:along 走自己的
# registry.ymaikj.com),也可用环境变量强制指定:COMPANY=lingpu ./prod-deploy.sh … # registry.ymaikj.com)。COMPANY 环境变量仍可强制指定,但目前只有 along 一个合法值。
# 所选公司必须与 prod-build.sh 构建时所选公司一致,否则拉不到镜像。 # 所选公司必须与 prod-build.sh 构建时所选公司一致,否则拉不到镜像。
COMPANY_OVERRIDE="${COMPANY:-}" # 环境变量显式指定的公司;留空 = 跟区域走 COMPANY_OVERRIDE="${COMPANY:-}" # 环境变量显式指定的公司;留空 = 跟区域走
# 共用镜像:所有应用都用这一个。名字走 prod_image_name(),与 prod-build.sh 同源,防漂移。 # 共用镜像:所有应用都用这一个。名字走 prod_image_name(),与 prod-build.sh 同源,防漂移。
@ -82,7 +82,7 @@ DEFAULT_APP="${APPS[0]%%|*}" # 省略应用时的默认(首个)
# ║ 写死在此(本文件纳入 git)。公开仓库前清空 HOST/KEY。 ║ # ║ 写死在此(本文件纳入 git)。公开仓库前清空 HOST/KEY。 ║
# ║ 新增区域:在 region_profile 加一段 case,并把区域名加进 REGIONS 数组。 ║ # ║ 新增区域:在 region_profile 加一段 case,并把区域名加进 REGIONS 数组。 ║
# ╚══════════════════════════════════════════════════════════════════════╝ # ╚══════════════════════════════════════════════════════════════════════╝
REGIONS=(along) # 可选环境/区域(目前只剩阿龙;云雁三地已于 2026-09-13 移除,见文件头说明) REGIONS=(along) # 可选环境/区域(目前只剩阿龙,见文件头说明)
region_profile() { # 远端部署目录/容器名由「应用」决定(见 APPS),此处只管各区域服务器连接信息 region_profile() { # 远端部署目录/容器名由「应用」决定(见 APPS),此处只管各区域服务器连接信息
REGION_COMPANY="$DEFAULT_COMPANY" # 该区域镜像仓库所属公司(可被环境变量 COMPANY 覆盖;along 段下面会改写成 along) REGION_COMPANY="$DEFAULT_COMPANY" # 该区域镜像仓库所属公司(可被环境变量 COMPANY 覆盖;along 段下面会改写成 along)
@ -146,7 +146,7 @@ for a in "$@"; do
elif is_removed_region "$a"; then elif is_removed_region "$a"; then
# ⚠️ 变量名必须写成 ${a} 而不是 $a:紧跟其后的是多字节的「」,在非 UTF-8 locale 下 # ⚠️ 变量名必须写成 ${a} 而不是 $a:紧跟其后的是多字节的「」,在非 UTF-8 locale 下
# bash 会把它的首字节并进变量名,报出 `a?: unbound variable`(set -u)。 # bash 会把它的首字节并进变量名,报出 `a?: unbound variable`(set -u)。
red "✗ 区域「${a}」已于 2026-09-13 从本脚本移除(云雁三地的 SSH 私钥在另一台开发机上,本机连不上)。" red "✗ 区域「${a}」已从本脚本移除(那几台的 SSH 私钥在另一台开发机上,本机连不上)。"
red " 现可用区域: ${REGIONS[*]}。需要恢复请从 git 历史取回 region_profile 对应段。" red " 现可用区域: ${REGIONS[*]}。需要恢复请从 git 历史取回 region_profile 对应段。"
exit 1 exit 1
elif is_region "$a"; then elif is_region "$a"; then

3
deploy/console/README.md

@ -52,9 +52,8 @@ docker compose --env-file .env up -d # 本机起 starpivot-console
```bash ```bash
cd deploy/console cd deploy/console
./dev-deploy.sh along # 开发:发布到阿龙测试环境(构建 dev-latest → 推送 → 远端拉取重启) ./dev-deploy.sh along # 开发:发布到阿龙测试环境(构建 dev-latest → 推送 → 远端拉取重启)
./dev-deploy.sh yunyan # 开发:发布到云雁测试环境
./dev-deploy.sh # 不带参数 → 交互选择测试环境 ./dev-deploy.sh # 不带参数 → 交互选择测试环境
./prod-build.sh 0.1.1 yunyan # 生产①:构建并推送 starpivot-console:0.1.1 (+latest);第2参数选公司 ./prod-build.sh 0.1.1 along # 生产①:构建并推送 starpivot-console:0.1.1 (+latest);公司只剩 along,可省略
./prod-deploy.sh 0.2.1 along # 生产②:部署 0.2.1 到生产环境(目前只有 along,省略即用它) ./prod-deploy.sh 0.2.1 along # 生产②:部署 0.2.1 到生产环境(目前只有 along,省略即用它)
./prod-deploy.sh rollback along # 生产回滚到上一版本(与当前版本来回切换) ./prod-deploy.sh rollback along # 生产回滚到上一版本(与当前版本来回切换)
``` ```

6
deploy/console/dev-deploy.sh

@ -11,8 +11,8 @@
# #
# 开发版镜像始终使用固定 tag dev-latest,每次构建覆盖同一 tag。 # 开发版镜像始终使用固定 tag dev-latest,每次构建覆盖同一 tag。
set -euo pipefail set -euo pipefail
# ⚠️ 2026-09-13:云雁(yunyan)/灵谱(lingpu) 的部署目标已从本脚本移除——本仓库的部署目标只剩阿龙。 # ⚠️ 本仓库的部署目标只剩阿龙一家:2026-09-13 移除了云雁/灵谱的服务器,2026-09-23 连它们的
# 移除的是「往哪台服务器部署」,**镜像仓库没动**:prod-build.sh 仍可选 yunyan/lingpu 推镜像。 # 镜像仓库档案(registry.voitrans.net / registry.lingpu.net)也一并删除。需要找回查 git 历史。
# 要恢复:git log 里找回对应的 env_profile/region_profile 段即可(含 IP 与各自的 SSH 私钥路径)。 # 要恢复:git log 里找回对应的 env_profile/region_profile 段即可(含 IP 与各自的 SSH 私钥路径)。
SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # deploy/console SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # deploy/console
@ -35,7 +35,7 @@ IMAGE_NAME="starpivot-console" # 业务镜像名
# ║ ⚠️ 公开仓库前务必清空 DEV_HOST / REGISTRY_PASS ║ # ║ ⚠️ 公开仓库前务必清空 DEV_HOST / REGISTRY_PASS ║
# ║ 新增环境:在 env_profile 加一段 case,并把环境名加进 ENVS 数组。 ║ # ║ 新增环境:在 env_profile 加一段 case,并把环境名加进 ENVS 数组。 ║
# ╚══════════════════════════════════════════════════════════════════════╝ # ╚══════════════════════════════════════════════════════════════════════╝
ENVS=(along) # 可选测试环境(目前只剩阿龙;云雁/灵谱已于 2026-09-13 移除,见文件头说明) ENVS=(along) # 可选测试环境(目前只剩阿龙,见文件头说明)
DEFAULT_ENV="along" # 无参数且无 TTY(CI/管道)时的默认环境 DEFAULT_ENV="along" # 无参数且无 TTY(CI/管道)时的默认环境
env_profile() { env_profile() {

4
deploy/console/docker-compose.yml

@ -1,6 +1,6 @@
# console 服务部署(单服务) # console 服务部署(单服务)
# #
# 只部署 yunyan-console。依赖服务(PostgreSQL / Redis / NATS)由目标环境共享提供, # 只部署 starpivot-console。依赖服务(PostgreSQL / Redis / NATS)由目标环境共享提供,
# 不在此 compose 内搭建——在 .env 里用地址/容器名连接它们。 # 不在此 compose 内搭建——在 .env 里用地址/容器名连接它们。
# #
# 不直接 docker compose 调用,统一通过 deploy.sh, # 不直接 docker compose 调用,统一通过 deploy.sh,
@ -8,7 +8,7 @@
services: services:
starpivot-console: starpivot-console:
image: ${REGISTRY:-registry.voitrans.net}/starpivot-console:${TAG:-latest} image: ${REGISTRY:-registry.ymaikj.com}/starpivot-console:${TAG:-latest}
container_name: starpivot-console container_name: starpivot-console
restart: unless-stopped restart: unless-stopped
platform: ${PLATFORM:-linux/amd64} platform: ${PLATFORM:-linux/amd64}

2
deploy/console/prod-build.sh

@ -6,7 +6,7 @@
# #
# 用法(在 deploy/console/ 下): # 用法(在 deploy/console/ 下):
# ./prod-build.sh <version> # version 必须形如 X.Y.Z,如 0.1.1 # ./prod-build.sh <version> # version 必须形如 X.Y.Z,如 0.1.1
# ./prod-build.sh <version> [公司] # 公司 = yunyan | lingpu | along(省略则交互选择;无 TTY 用默认 yunyan) # ./prod-build.sh <version> [公司] # 公司目前只剩 along,省略即用它
set -euo pipefail set -euo pipefail
SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

6
deploy/console/prod-deploy.sh

@ -11,8 +11,8 @@
# 环境 = along(阿龙 47.116.104.181);省略 → 直接用它。 # 环境 = along(阿龙 47.116.104.181);省略 → 直接用它。
# ⚠️ 环境必须与 prod-build.sh 构建时所选公司一致,否则拉不到镜像。 # ⚠️ 环境必须与 prod-build.sh 构建时所选公司一致,否则拉不到镜像。
set -euo pipefail set -euo pipefail
# ⚠️ 2026-09-13:云雁(yunyan)/灵谱(lingpu) 的部署目标已从本脚本移除——本仓库的部署目标只剩阿龙。 # ⚠️ 本仓库的部署目标只剩阿龙一家:2026-09-13 移除了云雁/灵谱的服务器,2026-09-23 连它们的
# 移除的是「往哪台服务器部署」,**镜像仓库没动**:prod-build.sh 仍可选 yunyan/lingpu 推镜像。 # 镜像仓库档案(registry.voitrans.net / registry.lingpu.net)也一并删除。需要找回查 git 历史。
# 要恢复:git log 里找回对应的 env_profile/region_profile 段即可(含 IP 与各自的 SSH 私钥路径)。 # 要恢复:git log 里找回对应的 env_profile/region_profile 段即可(含 IP 与各自的 SSH 私钥路径)。
SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@ -35,7 +35,7 @@ DOCKER_NETWORK="1panel-network" # compose 外部网络名(与服务
# 容器名:必须与 docker-compose.yml 的 container_name 一字不差(本脚本会上传覆盖那份 compose)。 # 容器名:必须与 docker-compose.yml 的 container_name 一字不差(本脚本会上传覆盖那份 compose)。
# 仅用于 up 冲突时清理同名遗留容器;不随环境变化。 # 仅用于 up 冲突时清理同名遗留容器;不随环境变化。
CONTAINER_NAME="starpivot-console" CONTAINER_NAME="starpivot-console"
PROD_ENVS=(along) # 可选生产环境(目前只剩阿龙;云雁/灵谱已于 2026-09-13 移除,见文件头说明) PROD_ENVS=(along) # 可选生产环境(目前只剩阿龙,见文件头说明)
DEFAULT_PROD_ENV="along" # 无参数且无 TTY(CI/管道)时的默认环境 DEFAULT_PROD_ENV="along" # 无参数且无 TTY(CI/管道)时的默认环境
env_profile() { env_profile() {

4
deploy/dev-deploy-all.sh

@ -16,8 +16,8 @@
# ⚠️ macOS 自带 bash 3.2 不认多字节字符边界,`"$rc)"` 会被当成变量名 `rc)` 而在 set -u 下 # ⚠️ macOS 自带 bash 3.2 不认多字节字符边界,`"$rc)"` 会被当成变量名 `rc)` 而在 set -u 下
# 报 unbound variable。所以本文件里凡是变量后面紧跟中文标点的,一律写成 ${rc}。 # 报 unbound variable。所以本文件里凡是变量后面紧跟中文标点的,一律写成 ${rc}。
set -euo pipefail set -euo pipefail
# ⚠️ 2026-09-13:云雁(yunyan)/灵谱(lingpu) 的部署目标已从本脚本移除——本仓库的部署目标只剩阿龙。 # ⚠️ 本仓库的部署目标只剩阿龙一家:2026-09-13 移除了云雁/灵谱的服务器,2026-09-23 连它们的
# 移除的是「往哪台服务器部署」,**镜像仓库没动**:prod-build.sh 仍可选 yunyan/lingpu 推镜像。 # 镜像仓库档案(registry.voitrans.net / registry.lingpu.net)也一并删除。需要找回查 git 历史。
# 要恢复:git log 里找回对应的 env_profile/region_profile 段即可(含 IP 与各自的 SSH 私钥路径)。 # 要恢复:git log 里找回对应的 env_profile/region_profile 段即可(含 IP 与各自的 SSH 私钥路径)。
DEPLOY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # 仓库的 deploy/ DEPLOY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # 仓库的 deploy/

209
deploy/dl/README.md

@ -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 存三份,页面体积直接翻三倍。

31
deploy/dl/build.sh

@ -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 ))"

380
deploy/dl/index.html

File diff suppressed because one or more lines are too long

380
deploy/dl/index.template.html

@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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>

37
deploy/registry-profiles.sh

@ -6,36 +6,27 @@
# #
# 用法(在脚本里): # 用法(在脚本里):
# source "$REPO_ROOT/deploy/registry-profiles.sh" # source "$REPO_ROOT/deploy/registry-profiles.sh"
# registry_profile yunyan # → 设好 REGISTRY / REGISTRY_USER / REGISTRY_PASS / COMPANY_DESC # registry_profile along # → 设好 REGISTRY / REGISTRY_USER / REGISTRY_PASS / COMPANY_DESC
# COMPANY="$(pick_company)" # → 交互选公司(无 TTY 时用 DEFAULT_COMPANY) # COMPANY="$(pick_company)" # → 选公司(目前只剩 along,直接返回它)
# #
# ⚠️ 本文件纳入 git,下面的仓库密码会随仓库提交(私有仓库内可接受)。 # ⚠️ 本文件纳入 git,下面的仓库密码会随仓库提交(私有仓库内可接受)。
# 公开仓库前务必清空 REGISTRY_PASS。 # 公开仓库前务必清空 REGISTRY_PASS。
set -euo pipefail set -euo pipefail
COMPANIES=(yunyan lingpu along) # 可选公司(交互菜单按此顺序展示;along 2026-08-16 起也有正式环境,故进生产菜单) # 2026-09-23:云雁(yunyan → registry.voitrans.net)与灵谱(lingpu → registry.lingpu.net)两个档案已删除。
DEFAULT_COMPANY="yunyan" # 不指定且无 TTY(CI/管道)时用哪个 # 它们的部署目标早在 2026-09-13 就从本仓库清掉了,仓库档案再留着只会让人误推镜像到没人拉的地方。
# 需要找回:git log -- deploy/registry-profiles.sh
COMPANIES=(along) # 可选公司(目前只剩阿龙一家)
DEFAULT_COMPANY="along" # 不指定且无 TTY(CI/管道)时用哪个
# 按公司设置仓库三件套。失败返回非 0,由调用方决定是否 exit。 # 按公司设置仓库三件套。失败返回非 0,由调用方决定是否 exit。
registry_profile() { registry_profile() {
case "${1:-}" in case "${1:-}" in
yunyan)
COMPANY_DESC="云雁"
REGISTRY="registry.voitrans.net" # 云雁自有仓库(1Panel OpenResty 反代 → docker-registry:5000)
REGISTRY_USER="liwei"
REGISTRY_PASS="li13451234"
;;
lingpu)
COMPANY_DESC="灵谱"
REGISTRY="registry.lingpu.net" # 灵谱自有仓库
REGISTRY_USER="liwei"
REGISTRY_PASS="li13451234"
;;
along) along)
COMPANY_DESC="阿龙" COMPANY_DESC="阿龙"
REGISTRY="registry.ymaikj.com" # 仓库实体就在阿龙测试机 8.133.166.29 上(NPM 反代 → docker-registry:5000) REGISTRY="registry.ymaikj.com" # 仓库实体就在阿龙测试机 8.133.166.29 上(NPM 反代 → docker-registry:5000)
# 正式机 47.116.104.181 也从这里拉镜像(阿龙只此一个仓库) # 正式机 47.116.104.181 也从这里拉镜像(阿龙只此一个仓库)
REGISTRY_USER="admin" # 注意:账号是 admin,与云雁/灵谱的 liwei 不同 REGISTRY_USER="admin" # 注意:账号是 admin
REGISTRY_PASS="li13451234" REGISTRY_PASS="li13451234"
;; ;;
*) *)
@ -48,14 +39,10 @@ registry_profile() {
# 生产镜像名按公司区分。prod-build.sh 与 prod-deploy.sh 都调它,避免两处各写一份映射后漂移 # 生产镜像名按公司区分。prod-build.sh 与 prod-deploy.sh 都调它,避免两处各写一份映射后漂移
# ——构建推的名字和部署拉的名字对不上,报错要到远端 docker pull 才暴露。 # ——构建推的名字和部署拉的名字对不上,报错要到远端 docker pull 才暴露。
# #
# admin/console:各公司都用 starpivot-* 新命名,与 docker-compose.yml 里写死的 image/container_name # 三个服务一律 starpivot-<服务>,与 docker-compose.yml 里的 image/container_name 一致。
# 一致。云雁的 admin/console 生产目标已于 2026-08-05 迁到上海机(那台没有历史容器), # 函数保留公司参数只为兼容调用方和将来再分叉。
# app:2026-08-10 起统一为 starpivot-app。旧名 voitrans-a11 已废弃——仓库里的旧 tag 不再更新,
# 首次用新名部署需先 ./prod-build.sh <version> 推一版(旧 tag 不会自动搬过来)。
#
# 现在三个服务、各家公司都是 starpivot-<服务>,函数保留公司参数只为兼容调用方和将来再分叉。
# #
# 用法:IMAGE_NAME="$(prod_image_name lingpu admin)" # 用法:IMAGE_NAME="$(prod_image_name along admin)"
prod_image_name() { # $1=公司 $2=服务(admin|console|app) prod_image_name() { # $1=公司 $2=服务(admin|console|app)
case "${2:-}" in case "${2:-}" in
admin|console|app) printf 'starpivot-%s' "$2" ;; admin|console|app) printf 'starpivot-%s' "$2" ;;
@ -67,7 +54,9 @@ prod_image_name() { # $1=公司 $2=服务(admin|console|app)
} }
# 交互选择公司;无 TTY 时回落到 DEFAULT_COMPANY。选中的 key 从 stdout 返回(菜单走 stderr,不污染返回值)。 # 交互选择公司;无 TTY 时回落到 DEFAULT_COMPANY。选中的 key 从 stdout 返回(菜单走 stderr,不污染返回值)。
# 只剩一家时不弹菜单——让人对着独苗菜单按回车纯属噪音。
pick_company() { pick_company() {
if [ "${#COMPANIES[@]}" -le 1 ]; then printf '%s' "${COMPANIES[0]}"; return 0; fi
if [ ! -t 0 ]; then printf '%s' "$DEFAULT_COMPANY"; return 0; fi if [ ! -t 0 ]; then printf '%s' "$DEFAULT_COMPANY"; return 0; fi
printf "\033[34m选择镜像仓库所属公司:\033[0m\n" >&2 printf "\033[34m选择镜像仓库所属公司:\033[0m\n" >&2
local i=1 c local i=1 c

Loading…
Cancel
Save