Compare commits

...

9 Commits

Author SHA1 Message Date
Rodger-Wang 98e640ecfe client(ios),docs: bundle id 修正、版本号 1.0.2+21,CLAUDE.md 记录本轮四条结论 3 weeks ago
Rodger-Wang 06c01aa858 admin: 「版本控制」与「渠道分发」合成一页「版本与分发」(/appdist) 3 weeks ago
Rodger-Wang 2cf260eb05 服务端:音乐模块与腾讯云 COS 整体下线;新增分发桶(App 安装包与录音/固件分开放) 3 weeks ago
Rodger-Wang 9f105bf7c8 会议纪要分享:链接最后一段改成服务端签发的只读票据,不再放登录 JWT 3 weeks ago
Rodger-Wang 4753d37c69 版本更新判定改口径:按平台分版本、最低版本留空=线上版本、渠道未上架不推、地址回退链 3 weeks ago
Rodger-Wang 6f1686a8fd client: EaiCar 设备设置面板(按能力出现、按设备回的 items 渲染) 3 weeks ago
Rodger-Wang 5036ab4c3f client: 修通话翻译「德语/意大利语一开就卡死、只有中英正常」——并发初始化+默认参数兜底 3 weeks ago
Rodger-Wang ace92c9ea1 client: 通话翻译耳机杂音——四个独立原因逐个修掉(Android 定性,iOS 同步) 3 weeks ago
Rodger-Wang 3e05be48d6 client: 通话翻译下行追赶参数收紧(两端同改):1s 起变速、2.5s 达 1.3x、0.5s 起裁静音 3 weeks ago
  1. 304
      CLAUDE.md
  2. 352
      apps/admin/app/components/AppReleasePanel.vue
  3. 422
      apps/admin/app/components/ChannelAppPanel.vue
  4. 59
      apps/admin/app/composables/useApi.ts
  5. 94
      apps/admin/app/pages/appdist.vue
  6. 256
      apps/admin/app/pages/appversion.vue
  7. 445
      apps/admin/app/pages/channels.vue
  8. 2
      apps/admin/app/pages/environments.vue
  9. 12
      apps/admin/app/pages/systemconfig.vue
  10. 4
      apps/admin/app/stores/auth.ts
  11. 7
      apps/admin/app/utils/menus.ts
  12. 2
      apps/client/GoogleService-Info.plist
  13. 2
      apps/client/assets/config/GoogleService-Info.plist
  14. 388
      apps/client/docs/face_to_face_ble_protocol.md
  15. 22
      apps/client/ios/Runner.xcodeproj/project.pbxproj
  16. 2
      apps/client/ios/Runner/GoogleService-Info.plist
  17. 16
      apps/client/lib/data/models/device_category.dart
  18. 13
      apps/client/lib/data/services/network/api.dart
  19. 67
      apps/client/lib/data/services/speech_impl/azure_ast_service.dart
  20. 54
      apps/client/lib/data/services/version_update_service.dart
  21. 7
      apps/client/lib/data/utils/background_keepalive.dart
  22. 170
      apps/client/lib/devices/eaicar/eaicar_device_plugin.dart
  23. 69
      apps/client/lib/devices/eaicar/eaicar_protocol.dart
  24. 2
      apps/client/lib/modules/common/widgets/version_update_dialog.dart
  25. 165
      apps/client/lib/modules/eaicar/module.dart
  26. 37
      apps/client/lib/modules/meeting/views/bottomSheet/share_bottom_sheet.dart
  27. 29
      apps/client/lib/modules/my_devices/controllers/my_devices_controller.dart
  28. 23
      apps/client/lib/modules/my_devices/views/my_devices_view.dart
  29. 584
      apps/client/lib/modules/my_devices/views/widgets/device_settings_sheet.dart
  30. 3
      apps/client/lib/modules/settings/controllers/settings_controller.dart
  31. 7
      apps/client/lib/modules/splash/controllers/splash_controller.dart
  32. 38
      apps/client/lib/modules/translation/controllers/translation_controller.dart
  33. 6
      apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AliyunBailianE2EHelper.kt
  34. 9
      apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  35. 4
      apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/BesCallPcmBridge.kt
  36. 73
      apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/Resampler24kTo16k.kt
  37. 5
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AliyunBailianE2EHelper.swift
  38. 13
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift
  39. 67
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/Resampler24kTo16k.swift
  40. 10
      apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManagerPlugin.kt
  41. 251
      apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/CallTranslationDownlink.kt
  42. 10
      apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManagerPlugin.swift
  43. 274
      apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/CallTranslationDownlink.swift
  44. 8
      apps/client/local_plugins/device_plugin_interface/lib/src/device_capability.dart
  45. 49
      apps/client/local_plugins/device_plugin_interface/lib/src/device_session.dart
  46. 2
      apps/client/pubspec.yaml
  47. 137
      apps/client/test/devices/eaicar_settings_features_test.dart
  48. 38
      apps/proto/echomeet/echomeet_msg.proto
  49. 111
      apps/services/comm/apprelease.go
  50. 34
      apps/services/comm/apprelease_test.go
  51. 46
      apps/services/comm/ossconf.go
  52. 8
      apps/services/comm/svcruntime.go
  53. 2
      apps/services/modules/agents/api_test.go
  54. 31
      apps/services/modules/api/api_getcostoken.go
  55. 2
      apps/services/modules/api/model_test.go
  56. 19
      apps/services/modules/console/api_apprelease.go
  57. 9
      apps/services/modules/console/api_config.go
  58. 11
      apps/services/modules/console/api_svctemplate.go
  59. 109
      apps/services/modules/console/api_upload.go
  60. 54
      apps/services/modules/console/api_upload_test.go
  61. 24
      apps/services/modules/console/inspect_probe.go
  62. 2
      apps/services/modules/console/model_environment.go
  63. 10
      apps/services/modules/console/model_registry.go
  64. 199
      apps/services/modules/echomeet/api_sharelink.go
  65. 107
      apps/services/modules/echomeet/api_sharelink_test.go
  66. 37
      apps/services/modules/gateway/share_page.go
  67. 316
      apps/services/modules/gateway/share_page.html
  68. 2
      apps/services/modules/gateway/wservice_comp.go
  69. 6
      apps/services/modules/mcp/module.go
  70. 115
      apps/services/modules/mcp/tool_music.go
  71. 27
      apps/services/modules/music/api.go
  72. 55
      apps/services/modules/music/api_search.go
  73. 38
      apps/services/modules/music/api_url.go
  74. 26
      apps/services/modules/music/core.go
  75. 20
      apps/services/modules/music/model.go
  76. 1
      apps/services/modules/music/model_test.go
  77. 49
      apps/services/modules/music/module.go
  78. 27
      apps/services/modules/music/options.go
  79. 36
      apps/services/modules/user/api_getchannelapp.go
  80. 47
      apps/services/modules/user/api_setting.go
  81. 46
      apps/services/modules/user/model_user.go
  82. 78
      apps/services/modules/user/module_test.go
  83. 1
      apps/services/modules/user/options.go
  84. 370
      apps/services/pb/echomeet_msg.pb.go
  85. 8
      apps/services/services/api/main.go
  86. 14
      apps/services/services/home/main.go
  87. 4
      apps/services/sys/aliyun/filetrans/sys_test.go
  88. 2
      apps/services/sys/bytedance/audomodel/sys_test.go
  89. 623
      apps/services/sys/music/core.go
  90. 187
      apps/services/sys/music/music.go
  91. 40
      apps/services/sys/music/options.go
  92. 83
      apps/services/sys/music/sys_test.go
  93. 66
      apps/services/sys/musicobj/core.go
  94. 117
      apps/services/sys/musicobj/music.go
  95. 40
      apps/services/sys/musicobj/options.go
  96. 29
      apps/services/sys/musicobj/sys_test.go
  97. 2
      apps/services/sys/pay/alipay/sys_test.go
  98. 2
      apps/services/sys/pay/appleiap/sys_test.go
  99. 2
      apps/services/sys/pay/appleiap/token_test.go
  100. 2
      apps/services/sys/pay/googleiap/sys_test.go

304
CLAUDE.md

@ -70,14 +70,14 @@ iOS 首次还需 `cd ios && pod install`。
### 构建镜像与部署
```bash
./build.sh console # 构建 registry.voitrans.net/starpivot-console:latest(PUSH=0 只构建不推)
./build.sh console # 构建 registry.ymaikj.com/starpivot-console:latest(PUSH=0 只构建不推)
./build.sh admin
# 测试环境发布(在 deploy/<服务>/ 下,服务 = app | console | admin)
cd deploy/console && ./dev-deploy.sh along # 阿龙测试环境(阿里云 8.133.166.29);目前只有这一个环境
# 生产:先构建带版本号的镜像,再部署
cd deploy/console && ./prod-build.sh 0.2.1 along # 第 2 个参数选公司(yunyan|lingpu|along),省略则交互选
cd deploy/console && ./prod-build.sh 0.2.1 along # 第 2 个参数选公司,目前只剩 along,可省略
cd deploy/console && ./prod-deploy.sh 0.2.1 along # 第 2 个参数选生产环境(目前只有 along)
cd deploy/app && ./prod-deploy.sh ym along 0.2.1 # app 按「应用 区域 版本」部署
```
@ -94,11 +94,13 @@ cd deploy/console && DEPLOY_KEY=$HOME/Documents/keys/loginscre.pem ./prod-deploy
⚠️ **2026-09-13:云雁(yunyan)/灵谱(lingpu) 的部署目标已从所有脚本移除**,`ENVS`/`PROD_ENVS`/`REGIONS`
都只剩 `along`。那 6 台(云雁测试/上海/日本/新加坡、灵谱测试/正式)的 SSH 私钥路径指向另一台开发机,
本机**一台都连不上**,留在菜单里只会误选。**IP 与私钥路径不再记在这里,需要时从 git 历史取回。**
**镜像仓库没有动**:`prod-build.sh` 仍可选 `yunyan` / `lingpu` 推镜像,三家仓库本机都能读写。
**镜像仓库按公司区分**,地址/账号统一在 [deploy/registry-profiles.sh](deploy/registry-profiles.sh)(换仓库只改这一处,各脚本 source 它):
- `yunyan` → `registry.voitrans.net`(云雁自有仓库;**只用于推镜像,本仓库已无云雁的部署目标**)
- `lingpu` → `registry.lingpu.net`(灵谱自有仓库;同上)
⚠️ **2026-09-23:云雁/灵谱的镜像仓库档案也删了**(此前刻意保留的那条已推翻)。
`deploy/registry-profiles.sh` 的 `COMPANIES` 只剩 `along`,`prod-build.sh` 不再能推到
`registry.voitrans.net` / `registry.lingpu.net`;三个 `docker-compose.yml` 与 `build.sh` 里
写死的兜底默认值也从 `registry.voitrans.net` 改成了阿龙仓库。要找回:`git log -- deploy/registry-profiles.sh`。
**镜像仓库**地址/账号统一在 [deploy/registry-profiles.sh](deploy/registry-profiles.sh)(换仓库只改这一处,各脚本 source 它):
- `along` → `registry.ymaikj.com`(仓库实体就在阿龙测试机 8.133.166.29 上,**账号是 `admin` 不是 `liwei`**);阿龙测试机与正式机 47.116.104.181 都从这里拉镜像
测试环境档案在各 `dev-deploy.sh` 的 `env_profile()`,**只有 `along` 一个**:
@ -891,7 +893,10 @@ iPhone 上每次都退出重进测就碰不到,要复现就同页连开两场
译文音频是模型**突发**产出的(每 3~6 秒一段文本,音频一次性到),播放只能 1 倍速;说话人不停顿时
译文时长 ≈ 原话时长(实测 A 路 133 秒会话产出 129 秒音频),下行队列只涨不缩——A 路积压到 **6.2 秒**,
用户说的"越说越慢"就是它。**上行没有积压**(桥峰值 8~12 帧),瓶颈只在 `CallTranslationDownlink`。
处理两层,都不丢内容:积压 >1s 裁掉超过 150ms 的静音;>2s 起 WSOLA 变速不变调,4s 达 1.3x 上限。
处理两层,都不丢内容:积压 >0.5s 裁掉超过 150ms 的静音;>1s 起 WSOLA 变速不变调,2.5s 达 1.3x 上限
(2026-09-22 晚由 1s/2s/4s 收紧而来:同一份到达轨迹回放对比,>3s 积压的累计时长减半、中位降 25%;
峰值不再随参数下降——峰值由云端单次响应的音频长度决定,一句不换气说 28 秒就会砸下 8 秒音频,
再往下只能从切句下手,如 `turn_detection`。1.5x 上限能再减 40% 的 >2s 时长,但语速发急,作为口味项未采用)。
同量压测:最大积压 6.2s → 3.2s,绝大部分时间 <1.5s,**光裁静音就省了 10%**(TTS 句间停顿),
变速最高只用到 1.11x。兜底整段丢弃(`HARD_CAP_SEC`)默认关。
- 参数与算法两端一份(`CallTranslationDownlink.kt` / `.swift`),改一端要同改另一端;
@ -901,6 +906,56 @@ iPhone 上每次都退出重进测就碰不到,要复现就同页连开两场
- ⚠️ 同一房间用两台手机测试时,B 路(对端通话音)吃到的就是机主自己的声音,`en->zh` 那条腿会把
中文原话原样吐回来并合成中文 TTS 灌回耳朵,听感像"慢了 3 秒又念了一遍",那是测试条件不是 bug。
### 通话翻译耳机里的"偶尔杂音":六轮真机定位,四个独立原因(2026-09-23,Android 定性,iOS 同步未验)
杂音不是一个原因,是四个叠在一起,每修掉一个概率就降一档。逐条都有日志证据,别再从"耳机坏了"猜起:
1. **发包定时器系统性漂移**:`scheduleWithFixedDelay` 从上一次任务**结束**起算,WSOLA + G.722 + 写 socket 的
1~3 ms 全叠进周期,"20 ms"实测 21~23 ms、只有 0.9 倍速;耳机缓冲一直往下掉,62% 的时间在要 16 ms,
两边每 80 ms 拉锯一次。判据是协议栈 `PORT_WriteData p_len=84` 行的时间间隔分布。改成 `scheduleAtFixedRate`
固定节拍且**永不重排**(iOS 的 `DispatchSourceTimer` 本来就是固定节拍)。8-29 最初版和 deepvoice 都有这个 bug。
2. **起播缓冲在句中重新生效**(杂音落在句中的真凶):队列空了就要求再攒 120 ms 才恢复,而变速追赶时消耗比
云端到达快、队列中途归零很常见,等于句中硬插 120 ms 静音。现在 `playing` 标志让起播缓冲只在句首用一次,
句中有一帧就播;真正的断粮(云端没跟上)单独计数并打 `句中空洞` 日志,修后实测 0 次。
3. **恢复发包时从空缓冲起播**(杂音落在句首):两路都空我们就停发,耳机缓冲被榨干,下一句第一包直接起播。
耳机每次都回 `bb d6 07 00 02 01 00`(data[6]=00 = 缓冲空)。现在先塞 4 包**编码静音**垫底再发真音频,
两路都空后再续 500 ms 静音才停。
4. **24k→16k 重采样**是逐块独立的线性插值、无抗混叠、每块相位从零重算。换成 64 阶低通 + 跨块连续
(`Resampler24kTo16k.kt/.swift`,Python 复刻校验 1k/3k/6k 正弦)。
顺带定性的几条:
- 耳机 `0xD6` 帧只有 data[6] 一个字节有信息(`bb d6 07 00 02 01 XX`),**01 一次只持续 20 ms 就回 02**,
它的语义是"缓冲低一包,现在补一包",不是"把速率改成 1.25x"。现在 01 立刻补一包(并按 16 ms 配额跟上持续要求)、
00 补两包、03 扣一包(`onHeadsetPace`)。三轮压测里它**一直在发**,从没要过 03——9-22 那句"耳机从未发过调速"是
grep 错了字样(日志文本是 `下行节奏调整为 Nms`)。
- **SPP 上只能 1 帧/84B**:试过 deepvoice iOS 那种 3 帧/244B,耳机完全不认、全是杂音。
- RFCOMM 是信用流控,耳机一次只放 1~3 个信用;信用停顿时协议栈把攒下的 6~11 包合成一帧突发,中间
100 多 ms 空档——这一层我们控制不了,能做的只有让耳机缓冲别贴着底走(上面第 3、5 条)。华为手机上系统的
「查找网络」/Nearby 会周期性 BLE 扫描(`scan_int=960 scan_win=96`),也会挤经典蓝牙时隙,排查时可临时关掉对照。
- 空闲那一路的静音帧改为用该路自己的编码器编零(G.722 自适应差分,固定码流会让解码器状态跳);
裁静音加回滞(300 进 / 600 出)+ 接缝 3 ms 淡入;速率每帧最多变 0.03;WSOLA 搜索窗 ±10 ms;
一句话没结束(150 ms 内有新数据)不补零收尾。`HARD_CAP_SEC` 补回 30 s 纯兜底。
- `DEEPVOICE_PARITY` 是打对照包用的开关(不裁不变速、4096 帧丢最旧、不打日志),**必须为 false 提交**。
### 通话翻译"德语/意大利语一开就卡死、只有中英正常":并发初始化 + 默认参数兜底(2026-09-23 Redmi K90 定性并修)
不是语种问题,是**竞态**。原生阿里初始化天然 ≥1.2 s(A 路 init → `delay(1200)` → B 路 init),期间只要再来一次
initialize / dispose,"代数"变了,前一次**返回 false**;Dart `initialize()` 拿到 false 把 `_isInitialized` 清掉;
接着 `recognizeCallback()` 看到"未初始化"就调**无参** `initialize()`——默认值 `provider=azure` + `[zh-CN, en-US]`,
把刚握手成功的阿里 zh→de 会话整个换成 Azure 中英三段拼装。此后原生 provider 停在 azure,Azure 识别器一直在跑,
每次 dispose 都慢几秒,下一轮两条初始化更容易撞进 1.2 s 窗口,**退出重进也不会好**。中英"正常"只是因为默认值恰好是中英。
触发源:切语言的 `stopAll`+`_reinitializeAsrService`、`startRecognition` 等 `_modeInitFuture` 3 s 超时后硬往下开桥、
退出页面 `onClose` 与重进 `onInit` 叠上再点开始。判据:日志里 `[STS] initialize: provider=azure (native=azure)` 紧跟在
阿里的 `[TRANS-INIT] 命中` 之后,或 `pushAstAudioToA: provider=azure`。
修在三层,**缺一层都会复发**:
- `azure_ast_service.dart`:`initialize()` 串行化(`_initInFlight`);记住最近一次 provider/语言表;`recognizeCallback` /
`startContinuousTranslation` 走 `_ensureInitialized`——先等在途初始化,仍没成就**按上次参数**补一次,**永远不用默认值**。
- `translation_controller.dart`:`_initializeCallModeTranslationService` 串行链 `_callInitChain`;`isPreparing` 期间忽略重复点开始;
`_attachBesCallTranslationBridge` 开桥前 `await _callInitChain`(10 s 上限)。
- `AzureSpeechPlugin.kt`:被新一代取代时打 `AST 初始化 gen=N … 被新一代取代,本次作废`,A/B 任一失败打结果——排查先找这两行。
修后 K90 连开 9 场(it/de/en/ja/fr/ko×2/en)全部阿里 3.5、初始化各 1.2 s、87 句译文、0 错误。
### 通话翻译的 PCM 已下沉到原生:bluetooth_manager ⇄ azure_speech 直连(2026-09-20)
苹果测试反馈「WiFi 下断断续续、长句基本不行」正是此前记录的技术债触发条件(Dart 链路每秒约 200 次跨界,
@ -1600,6 +1655,27 @@ google 的 aab 需要单独出)。原来靠打包时写进 `.env` 的 `Channel
- 口径偏差是已知的取舍:判断依据从"从哪家商店下载"变成了"用哪家的手机"。审核实测都在对应
品牌真机上做,能过审;反向(小米商店的包装在华为手机上)只是多弹一次。
### 版本更新判定 2026-09-23 口径:按平台分版本、最低版本留空=线上版本、渠道未上架不推、地址回退链
在下一节(8-31)的基础上改的,判定仍全在服务端 `evalChannelUpgrade`([api_getchannelapp.go](apps/services/modules/user/api_getchannelapp.go)),
客户端只拿结论。规则:
1. **Android / iOS 各一套版本号**:`app_release` 新增 `ios_version / ios_min_version`(启动 `ALTER ... ADD COLUMN IF NOT EXISTS` 幂等),
`version / min_version` 从此只管 Android。平台由渠道号推导(`comm.PlatformOfChannel`:只有 GlobalApple=101 是 iOS)。
⚠️ **iOS 没填时不回退 Android 的**——回退等于又回到共用一个版本号的老问题(Android 先发 1.0.3、iOS 商店还是 1.0.2,
iOS 用户被指向一个商店里没有的版本)。iOS 那栏留空 = iOS 不提示任何更新。
2. **最低支持版本留空 = 线上版本**(`MinVersionFor`):勾上强更不填最低版本,就是"所有低版本都强更";填了则只强更低于它的,
介于两者之间的仍是推荐。后台不再要求"开强更必填最低版本",改为"至少填一个平台的线上版本"。
3. **渠道行状态不是「已上架」(审核中/已下架)时不推送推荐更新**:商店里还是旧包。强更不受此限。
4. **下载地址回退链**(`comm.AppRelease.ResolveDownloadAddress`):Android 渠道 = 本渠道(须已上架)→ 官方渠道(100) → 默认下载地址;
**iOS / Google 只认本渠道**,没有就空——那两处如今放的是 apk 直链。
5. **算不出任何地址时两种更新都不提示**,强更也不例外:一个点了没反应又关不掉的弹窗等于把用户锁死。
客户端三处配套(`version_update_service.dart`):推荐更新的「稍后提醒」记版本号,同一版本 24 小时内冷启动不再弹(`snoozeVersion / isSnoozed`,
强更与设置页手动检查不受影响);设置页手动检查 `checkForUpdate(bypassCache: true)` 绕过 10 分钟缓存;Android 上服务端地址打不开时
回退网页下载页 `dl.ymaikj.com`(iOS 不兜底)。判定的金测试:`comm/apprelease_test.go`、`modules/user/module_test.go` 的
`TestEvalChannelUpgradePlatformStatusAddress` / `TestToPbChannelAppAddressFallbackChain`。
### 版本更新与强更判定(2026-08-31 改)
冷启动一定会查版本:splash 的 `_performParallelTasks` 顺序是
@ -1619,8 +1695,28 @@ google 的 aab 需要单独出)。原来靠打包时写进 `.env` 的 `Channel
`comm.CompareVersion` 逐段按整数比 + 取前导数字的口径**逐行对齐**
(`test/version_compare_test.dart` 与 `comm/channelapp_test.go` 同一组用例守着)。
原实现用 `int.parse` 解析,遇到 `1.0.0-beta` 直接抛,被外层 catch 吞成「无更新」。
- 服务端另有 `user_getchannelapp`(**单数**)会把 `needupdate/forceupdate/reviewing`
三个结论算好下发,客户端目前没用它,仍走复数接口自己算——所以两边口径必须一致。
- ⚠️ **客户端走的是 `user_getchannelapp`(单数)**,2026-09-23 核实:全仓唯一调用点是
[version_update_service.dart:144](apps/client/lib/data/services/version_update_service.dart#L144);
`Api.getChannelApps`(复数)**定义了但零调用**。此前这里记反了。
单数接口把 `needupdate/forceupdate/reviewing` 三个结论在服务端算好下发,客户端直接用,
不再自己比版本号。复数接口目前只有下载落地页(dl.ymaikj.com)在用。
### 后台入口合并:「版本与分发」(2026-09-23)
「服务配置 → 版本控制」与「SaaS 管理 → 渠道分发」合成了一页 `/appdist`
([appdist.vue](apps/admin/app/pages/appdist.vue)),上半区发版配置、下半区渠道分发,
顶部一个应用选择器共用。页面本体拆成 `components/AppReleasePanel.vue` 与
`components/ChannelAppPanel.vue`;`/appversion`、`/channels` 只留重定向。
⚠️ **合的是入口,不是表**。`app_release` 与 `channel_app` 维度不同(见下一节),
两块各读各的接口、各自提交。**绝不能做成一个大表单整包保存**——`app_release` 那一行
还被「游客显隐控制」页共用,后端是「读整行 → 只覆盖本组 → 整行写回」,
整包提交会在本页保存时清空游客登录配置。
⚠️ **菜单 id 仍是 `appversion`**(只改了 label 与 to)。存量后台账号的 access 清单里存的是
这个串,改 id 等于让这些账号丢入口。只勾过旧 id `channels` 的账号由
[auth.ts](apps/admin/app/stores/auth.ts) 的 `hasAccess` 兼容放行——同
「应用环境配置 → 服务与环境配置」那次的处理。
### 配置分家:channel_app 与 app_release(2026-08-31)
@ -1713,6 +1809,26 @@ google 的 aab 需要单独出)。原来靠打包时写进 `.env` 的 `Channel
- 广播里的 MAC 是厂商数据 CID 之后 6 字节**原样**(`05 85 a9 1a b3 21 e4 dd 00`),协议文档那套
26 字节/XOR 0x5A 布局这台固件没发。
### EaiCar 设备设置面板:按能力出现、按设备回的 items 渲染(2026-09-22)
「设备 → 设备管理」里 EaiCar 那一行整块可点(解绑按钮除外),弹底部面板拉 `0x15` 显示并可改:
启停 `0x0D` / 音量 `0x0B` / 亮度 `0x0A` / 行车感应 `0x11` / 摇摆 `0x14` / 随机表情频次 `0x13` / 随机播一个表情 `0x06`。
设计与指令对照见 [docs/EaiCar设备控制面板-落地文档.md](docs/EaiCar设备控制面板-落地文档.md)。
- **面板是厂商无关的**(`lib/modules/my_devices/views/widgets/device_settings_sheet.dart`):只认
`DeviceCapability.settingsPanel` 与 `DeviceFeatures.settings*` 通用键,按 `settings.query` 返回的 `items`
画控件,缺哪项不画哪项。EaiCar 插件把通用键映射成字节(`EaiCarSession.settingsCommand`,纯函数,
`test/devices/eaicar_settings_features_test.dart` 逐字节钉死)。以后耳机/支架要露设置,各自插件声明即可。
- **状态只有 `0x15` 一个来源**:六条写命令的应答只回 `0x64` 成功码,不回显新值,也没有主动上报。
插件写成功后**本地回填**并派 `settings.changed`;面板从不自己推断状态。原先 `_applySummary`
解完 14 字节只留固件和电量、其余全丢,这次整份留下(`EaiCarSession.settings`)。
- `0x11` 双向语义不同:`AA 11` 是行车感应开关,`CC 11` 是设备上报 AI 唤醒,按帧头分发,别把后者当开关状态。
- 「随机表情」设备只有**频次**(秒数原值,9 档);"现在随机播一个"没有原生命令,由 app 在 0x00~0x09 里挑一个发 `0x06`(单向)。
- 未连接的 EaiCar 行也可点(按设备名猜品类 `DeviceCategories.categoryIdForName`),进去是「未连接 / 去连接」;
其它品类的行 onTap 为 null,行为与以前一字不差。
- ⚠️ **`0x0D` 真机验证口径**:lumi 常量叫"自动开关机",第一次发之前先在 `0x15` 看当前值、发同值确认应答,再翻转,
别一上来发 0。`0x14`/`0x11` 翻转后回读 `0x15` 对应字节确认。
### iOS 在 Xcode 里构建报「requires minimum platform version 16.0 ... but this target supports 13.0」
**不是代码问题,是 Flutter 的 SPM 集成和 Xcode 的构建顺序对不上。跑
@ -1769,20 +1885,55 @@ Flutter 自己有补救 —— `SwiftPackageManager.updateMinimumDeployment()`
自有服务器域名**一律 `ymaikj.com`**,上一代的 `voitrans.net` / `voitrans.com` / `ideapsound.com` /
`deapsound.com` 已全部清除(含 41 个语言文件里用户可见的 `web.voitrans.com`)。现役清单:
| 用途 | 域名 | 位置 |
**已通的服务器域名**(NPM 反代,两台阿龙机):
| 用途 | 测试机 8.133.166.29 | 正式机 47.116.104.181 |
|---|---|---|
| 业务后端 | `ym-dev.ymaikj.com` | `.env` 的 `SERVER_URL`/`HW_SERVER_URL` |
| 支付回调 | `pay.ymaikj.com` | `pay_service.dart`、`goods_base_controller.dart` |
| 会议分享 | `share.ymaikj.com` | `share_bottom_sheet.dart` |
| Web 站点 | `web.ymaikj.com` | `member_bottom_sheet.dart`、`meeting_mine_view.dart`、各语言文案 |
| 微信 Universal Link | `ymaikj.com` | `login_service.dart`、`pay_service.dart`、`ios/Runner/Runner.entitlements` |
| MCP(死配置) | `mcp.ymaikj.com`、`api.ymaikj.com` | `.mcp.json` |
⚠️ **微信 Universal Link 改了代码还不够**,三处必须完全一致,否则微信登录与微信支付**静默失败**:
① `universalLink: 'https://ymaikj.com/app/'`(两个 dart 文件)
② `Runner.entitlements` 的 `applinks:ymaikj.com`
③ **微信开放平台后台登记的 Universal Link** + `https://ymaikj.com/.well-known/apple-app-site-association`
代码侧(①②)已改完,**③ 属于外部系统,发版前务必确认已同步**。
| 业务网关 gateway HTTP(7100) | `ym-dev.ymaikj.com` | `ym.ymaikj.com` |
| 管理后台 admin(3000) | `console-dev.ymaikj.com` | `console.ymaikj.com` |
| MCP HTTP/SSE(7300) | `mcp-dev.ymaikj.com` | `mcp.ymaikj.com` —— DNS 就绪,**反代与证书仍未建** |
| 镜像仓库 | `registry.ymaikj.com`(实体在测试机上) | 同一个 |
**客户端辅助域名**——⚠️ 四个全部解析到测试机,且测试机 NPM 上**没有它们的反代规则**,
也就是现在一个都打不开(2026-09-11 实测,见 [上线落地文档 §8.4](docs/2026-09-11-阿龙测试服上线正式服-落地文档.md)):
| 域名 | 用途 | 什么时候必须处理 |
|---|---|---|
| `pay.ymaikj.com` | 支付回调。硬编码在 `pay_service.dart`(3 处)、`goods_base_controller.dart`(2 处)。⚠️ `notify_url` 是**客户端下单时传给服务端的**,不是服务端配置 | 正式收款前 |
| `share.ymaikj.com` | 会议分享落地页。**2026-09-23 已落地**(`starpivot-app:0.2.5`):页面 `go:embed` 在 gateway 里,路由 `/share/*any`。DNS 已指向正式机、白名单已加,**只差 NPM 反代那一条规则** | 配完 NPM 即可用 |
| `web.ymaikj.com` | EAIMAR 用户侧 Web。`member_bottom_sheet.dart` 有真实入口,41 个语言文案里都印了 | 做 Web 版时 |
| `dl.ymaikj.com` | 下载落地页。页面已写好([deploy/dl/](deploy/dl/),含 OSS 部署教程),**桶与域名尚未建**。版本号/更新日志/各商店链接实时取自后台 `user_getchannelapps`,图标与 App 同一份素材 | 对外发链接前 |
**非服务器域名**:`oss.ymaikj.com` → 阿里云 OSS 自定义域名(桶 `ymaioss`,上海,测试/正式共用,
不指向任何业务机);`mcp.ymaikj.com:8000` / `api.ymaikj.com` 在 `.mcp.json` 里是死配置。
⚠️ **微信 Universal Link 用的是 `yimai.games`,不是 `ymaikj.com`**(这一行以前记错过)。
三处必须完全一致,否则微信登录与微信支付**静默失败**:
① `universalLink: 'https://yimai.games/app/'`(`login_service.dart`、`pay_service.dart`)
② `Runner.entitlements` 的 `applinks:yimai.games`
③ **微信开放平台后台登记的 Universal Link** + `https://yimai.games/.well-known/apple-app-site-association`
代码侧(①②)是对的,**③ 属于外部系统,发版前务必确认已同步**。
### `com.saitong.voitrans` 是错值,不只是遗留串(2026-09-23 修)
iOS 工程的真实 bundle id 是 **`com.yimai.eaimar`**(`Runner.xcodeproj` 的
`PRODUCT_BUNDLE_IDENTIFIER`),而 `home.yaml` 的 `appleiap.AppStoreBundleID` 一直写着
上一代的 `com.saitong.voitrans`。[appleiap.go](apps/services/sys/pay/appleiap/appleiap.go)
拿它做两件事:签 App Store Server API 的 JWT(载荷 `bid`)、校验交易归属
(`info.BundleID` 不相等就拒)。**对不上 = 所有 iOS 内购校验都失败**,报
「交易归属的 bundleId 不匹配」。模板与三处测试已改,⚠️ **服务器上的实际值要单独确认**——
这个字段是 `comm.ModuleCatalog` 托管的(`Required: true`),后台「支付 → Apple 内购」可改,
库里那行很可能是当初从模板 seed 进去的错值。
⚠️ **`GoogleService-Info.plist` 里的 `BUNDLE_ID` 同样是 `com.saitong.voitrans`**(三份副本:
`apps/client/`、`ios/Runner/`、`assets/config/`,内容完全一致)。**这个不能直接改**——
plist 是从 Firebase 控制台下载的,BUNDLE_ID 要与那边登记的 iOS 应用一致;本地改字符串而
不在 Firebase 里注册 `com.yimai.eaimar`,只会从「登记的是旧 id」变成「根本对不上」。
要正的话得在 Firebase 控制台加一个 iOS 应用再重新下载三份。`PROJECT_ID: voitrans` 同理。
⚠️ Redis 的 `KeyPrefix` 兜底默认值已从 `Voitrans` 改成 `EAIMAR`(与 `env.example` 一致)。
**不能删成 `${REDIS_KEY_PREFIX:-}`**:`lego/sys/redis` 的 `RKey` 在前缀为空时**直接返回裸 key**,
而阿龙两台机上同一个 Redis 跑着多个应用,无前缀 = 互相踩。
⚠️ 另外两处含 `voitrans` 但**不是域名、不要改**:
- `lib/firebase_options.dart` 的 `projectId: 'voitrans'` / `'voitrans-e0e3d'` —— Firebase 项目标识,
@ -1790,8 +1941,30 @@ Flutter 自己有补救 —— `SwiftPackageManager.updateMinimumDeployment()`
- `lib/core/translations/` 里的 key 名(`copyrightVoitrans`、`voitransPrivateCloud`…)—— 是标识符不是文案,
用户不可见,改了要 41 个语言文件连同引用处一起动。
`share_bottom_sheet.dart` 的分享链接格式是 `share.ymaikj.com/share/{serverHost}/ymaikj.com/{id}/{token}`,
**路径第二段是品牌标识、不是要请求的域名**,由 share 端解析,别当成域名误改。
### 会议纪要分享(2026-09-23 落地,详见 [上线步骤](docs/会议纪要分享-落地与上线步骤.md))
链接格式 `share.ymaikj.com/share/{serverHost}/ymaikj.com/{id}/{ticket}`。
`serverHost` 取自 `DioManager().baseUrl`,告诉落地页去哪个后端取数据;
**第二段 `ymaikj.com` 是品牌标识、不是要请求的域名**,别当成域名误改。
⚠️ **最后一段以前放的是 `logintoken`(完整登录 JWT)**,谁拿到链接就拿到了整个账号且无法失效。
现在是服务端签发的分享票据([api_sharelink.go](apps/services/modules/echomeet/api_sharelink.go)):
HMAC 签名、绑定单条记录、只读、默认 7 天过期,密钥由 `FIELD_ENCRYPT_KEY` 派生(上线不需要新配置项)。
**别改回去。**
三处各自的坑:
- `echomeet_getsharedrecord` **必须在 gateway `WhiteList` 里**——打开链接的人本来就没登录。
漏了是 `code:18`,表现为「分享链接打开一片空白」。签发端 `echomeet_createsharelink` 反之,
**不要**加进白名单。
- 落地页嵌在 gateway 二进制里(`modules/gateway/share_page.html` + `go:embed`),
路由 `GET /share/*any` **必须是 catch-all**:路径里的段是数据不是文件路径,
`gin.Static` 会去磁盘找同名文件、一律 404。
- 落地页里的 `ALLOWED_HOSTS` 白名单不能删:`serverHost` 来自 URL,不校验就等于
「构造一条链接让别人的浏览器带着票据去请求任意域名」。新增环境要加一行。
⚠️ 落地页那个极简 Markdown 渲染器**先 `esc()` 再套正则**,所以块级语法要匹配转义后的形态——
引用块匹配的是 `&gt;` 不是 `>`。写成 `>` 不会报错,只会让引用块静默退化成普通段落(踩过)。
### 其他
@ -2137,6 +2310,57 @@ console 启动跑、幂等、排在 `migrateGlobalScopeToApps` 之后):
⚠️ `rootDir` 参数现在是 scene,**不要再自己拼 `User/<uid>/` 前缀**(agent 图片那处
原来拼了),前缀由服务端加,客户端再拼一层就是重复路径。
### 更新入口:一个「网页下载页」兜住所有渠道(2026-09-23)
**App 自己不做品牌路由,下载页做。** 链路:
```
客户端按品牌推导渠道(getAndroidStoreInfoByBrand)
→ user_getchannelapp{channel, version}
→ 服务端:更新判定只看 app_release(与渠道无关);地址 = 渠道填了用渠道的,
没填回退 app_release.address(AppRelease.DownloadAddress)
→ performUpdate 打开这个地址
```
所以后台「版本控制 → 默认下载地址」填 `https://dl.ymaikj.com` 之后,**任何渠道没单独配地址
都会落到网页下载页**,再由那个页面按品牌跳商店 / 认不出就给安装包。要让某家商店直达,
才去「渠道分发」给那条填商店链接。
- 这套**不需要新增字段**:`app_release.address` 的语义本来就是「渠道没配时的兜底」,
而下载页正是兜底入口。
- 渠道行不存在**不影响更新提示**:`evalChannelUpgrade` 只读 `app_release`。
⚠️ 客户端只有在 `app_release` 也没配时才会拿到 `app:null` 并按「无更新」处理。
- 后台「上传 APK」传完写进**渠道分发 → 官方渠道(100)** 的 address(网页下载页的安装包直链取它),
**同时把直链填进「默认下载地址」输入框**(2026-09-23 用户要求:传完要在框里看到链接,且这个地址
就作为官方包的下载地址;填进表单后仍要点「保存」才落库)。要让默认地址回到网页下载页,手动改回即可。
⚠️ `savechannelapp` 是**整行覆盖**,所以那段必须「读整行 → 只改 address → 写回」;
而且 `getchannelapps` 回的是 `{items:[...]}` **不是裸数组**,当成数组取会永远走新建分支,
把官方渠道已有的上架状态/支付渠道清零,且不报任何错。
- ⚠️ **超管传安装包报「必须先选择应用(请求缺少 X-App-Id)」,而页面上明明选了**(2026-09-23 测试服踩到):
`X-App-Id` 头来自登录态的 `currentAppId`,只有绑定了应用的运营/代理账号才有;超管在「版本与分发」页顶部
选的应用只是页面里的一个 `app_name` 字符串,从不进请求头。现在 `uploadFile` 把页面选的应用名放进
`getcostoken` 请求体的 `app_name`,服务端没有 `X-App-Id` 时按 name / app_name 反查注册表补作用域
(`getAppByNameOrAppName`)。主桶那条(固件/产品图)不受影响,它不按应用分目录。
### 分发桶:App 安装包与录音/固件分开放(2026-09-23)
`comm.AliyunOSSConf` 多了 `DistBucket` / `DistDomain`(对应【第三方服务配置 → 存储】的
`dist_bucket` / `dist_domain`,留空用 `comm.DefaultDistBucket`/`DefaultDistDomain`
= `ymai-dl` / `https://dl.ymaikj.com`)。凭据与主桶共用,`conf.ForDist()` 取副本。
后台「版本控制」页的 **上传 APK** 按钮走 `api_getcostoken` 带 `target:"dist"`:
- key **不加 `EAIMAR/` 根前缀**(分发桶直接挂在 `dl.ymaikj.com` 上,加了会让下载地址多一段),
目录只允许 `comm.DistUploadDirs` 白名单里的(当前只有 `android`)。
边界由 `TestBuildDistObjectKey` 钉死——这个桶是公共读的,key 拼错等于公开网盘。
- ⚠️ **Content-Type 由服务端按扩展名强制**(`distContentTypes`),并**回带给前端**:
浏览器对 `.apk` 给出的 `File.type` 往往是空字符串,前端照它发 PUT 会与预签名不符,
OSS 回 403 SignatureDoesNotMatch,而报错里完全看不出是类型对不上。
- ⚠️ **分发桶必须单独配 CORS**(来源 `https://console.ymaikj.com`、方法 PUT)。
浏览器直传打的是桶的 OSS endpoint 而不是自定义域名。没配时 XHR 只报一句没有细节的
「网络错误」(浏览器刻意不暴露跨域失败详情),所以 `uploadFile` 的 onerror 文案
直接点名了这个原因。主桶 `ymaioss` 早已配好,`ymai-dl` 2026-09-23 实测还是 403。
⚠️ **新老客户端并存**:老包仍读 `COS_*` 直传腾讯云,所以业务库 `config` 表里那 5 个
`COS_*` 键**先别删**。历史录音的 `audiourl` 是 COS 的绝对地址,不会自动搬家。
@ -2145,6 +2369,38 @@ console 启动跑、幂等、排在 `migrateGlobalScopeToApps` 之后):
所以随机名用的是 `crypto/rand` 不是 `math/rand`。要真正锁住得把 bucket 转私有 +
播放时也签临时读 URL,客户端所有播放入口都要改,还没做。
### 腾讯云 COS 服务端整体下线(2026-09-23)
服务端**不再有任何腾讯云 COS 代码**:`sys/tencentyun/` 整个删除,home / api 两个服务的
`cos.OnInit` 与 `Sys["cos"]` 配置段一并去掉,`comm.svcRuntimeSections` 也不再托管 `cos`。
- **头像上传改走 OSS**:`modules/user/api_setting.go` 的 `cos.Put` → 本文件里的 `putAvatar`,
配置复用 `comm.LoadAliyunOSSConf`(与 user_getuploadurl / console 后台直传同一个桶)。
object key 是 `EAIMAR/User/<uid>/Avatar/<uid><ext>`,**不带随机段**,即换头像覆盖同一对象
(与 COS 时期行为一致,代价是 URL 不变、客户端可能吃缓存)。扩展名过 `uploadExtWhitelist`
且必须是 `image/*`。
- `modules/api/api_getcostoken.go`(腾讯云 STS)**已删**——它是孤儿:后台 admin 调的
`getcostoken` 其实落在 console 的 [api_upload.go](apps/services/modules/console/api_upload.go)
(签阿里云 OSS 预签名),路由名只是沿用;客户端从不调它。
- 后台「系统配置 → 对象存储」与「第三方服务配置 → 存储」里的**腾讯云 COS 卡片/模板已删**,
巡检的 `probeTencentCOS` 也删了。理由:服务端读存储配置的唯一入口 `comm.LoadAliyunOSSConf`
只认 `provider=aliyun_oss`,留着 COS 这一项是「配得上、却不生效」的陷阱。
**存量 `tencent_cos` 行不会被自动删除**,只是不再有读取方。
⚠️ **老客户端仍读业务库 `config` 表里的 `COS_*` 直传腾讯云,那 5 个键还是别删**——
服务端下线的是自己这一侧,桶还在、老包的上传路径没断。历史录音的 `audiourl` 是 COS
绝对地址,同样不会自动搬家。
### 音乐服务整体下线(2026-09-23)
`sys/music`、`sys/musicobj`、`modules/music`、`modules/mcp/tool_music.go` 全部删除,
home 的模块列表与 `musicobj` 初始化、home/mcp 两份 yaml 模板里的 `musicobj` 段一并去掉。
起因是它的 `ApiBaseUrl` 指向上一代品牌域名(`hw.music.voitrans.net` / `hw.music.ideapsound.com`)。
客户端的音乐模块本来就早已删除。
⚠️ **咪咕音乐(`tool_migu_music` + `migu` 服务)没有动**,别一起当成音乐链路删了。
`pb` 里的 Music* 消息保留(生成物,改要动 .proto 重生成),现在没有读取方。
## 配置与密钥约定(重要)
- 各 `conf/*.yaml` 与 `deploy/<svc>/deploy.sh` 中**写死了真实的 DSN / 密钥 / 服务器 IP / 仓库密码,且纳入 git**——这是用户在私有仓库下**明确接受**的取舍。修改这些文件时注意它们会随仓库提交。

352
apps/admin/app/components/AppReleasePanel.vue

@ -0,0 +1,352 @@
<template>
<div>
<div class="mb-4">
<div class="text-sm font-semibold text-slate-700">发版配置</div>
<div class="text-xs text-slate-400 mt-1 max-w-3xl">
线上版本、强制更新、送审版本与更新日志。<b>Android / iOS 各一套版本号</b>(iOS 审核慢,两端线上版本经常不同步),
其余整个应用一套、不分渠道;下面的「渠道分发」只覆盖各商店各自不同的那部分。
</div>
</div>
<div class="ver-wrap">
<!-- 左:表单 -->
<div class="card">
<section class="ed-section">
<div class="ed-section-title">线上版本(按平台)</div>
<div class="grid grid-cols-2 gap-3">
<div class="plat-col">
<div class="plat-title">Android</div>
<span class="ed-label">当前线上版本</span>
<input v-model="form.version" class="input input-bordered input-sm w-full" placeholder="如 1.0.3" />
<span class="ed-label mt-2">最低支持版本(可留空)</span>
<input v-model="form.min_version" class="input input-bordered input-sm w-full" placeholder="留空 = 线上版本" />
</div>
<div class="plat-col">
<div class="plat-title">iOS</div>
<span class="ed-label">当前线上版本</span>
<input v-model="form.ios_version" class="input input-bordered input-sm w-full" placeholder="如 1.0.2" />
<span class="ed-label mt-2">最低支持版本(可留空)</span>
<input v-model="form.ios_min_version" class="input input-bordered input-sm w-full" placeholder="留空 = 线上版本" />
</div>
</div>
<p class="ed-hint">
客户端低于本平台「线上版本」时提示<b>推荐更新</b>(可点「稍后提醒」,同一版本 24 小时内不再弹)。
填客户端上报的净版本号,<b>不含构建号</b>(<code>1.0.1</code> 而不是 <code>1.0.1+5</code>)。
iOS 那一栏留空 = iOS 不提示任何更新,<b>不会拿 Android 的版本去判 iOS</b>。
</p>
<div class="mt-3">
<span class="ed-label">送审版本号</span>
<input v-model="form.review_version" class="input input-bordered input-sm w-full max-w-xs" placeholder="过审后清空" />
<p class="ed-hint">客户端上报的版本正好等于它时,会隐藏未过审的功能。</p>
</div>
</section>
<section class="ed-section">
<div class="ed-section-title">强制更新</div>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input v-model="form.force_update" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
<span>开启强制更新(两个平台共用这一个开关)</span>
</label>
<p class="ed-hint pl-7">
开启后,低于本平台「最低支持版本」的客户端会弹出<b>不可关闭</b>的升级框,无法继续使用。
最低支持版本<b>留空 = 线上版本</b>,即所有低版本都强更;填了则只强更低于它的,介于两者之间的仍是推荐更新。
强更不受渠道上架状态限制:渠道商店里还没有新包时,下载地址会自动换成官方渠道的包。
</p>
<p v-if="versionWarn" class="ed-warn">⚠ {{ versionWarn }}</p>
</section>
<section class="ed-section">
<div class="ed-section-title">下载与说明</div>
<span class="ed-label">默认下载地址(网页下载页)</span>
<div class="flex gap-2">
<input v-model="form.address" class="input input-bordered input-sm w-full" :placeholder="DIST_DOMAIN" />
<button class="btn btn-outline btn-sm whitespace-nowrap" :disabled="apkUploading" @click="pickApk">
{{ apkUploading ? `上传中 ${apkPercent}%` : '上传 APK' }}
</button>
<input ref="apkInput" type="file" accept=".apk,application/vnd.android.package-archive" class="hidden" @change="onApkPicked" />
</div>
<progress v-if="apkUploading" class="progress progress-primary w-full mt-2 h-1.5" :value="apkPercent" max="100" />
<p class="ed-hint">
<b>推荐填网页下载页 <code>{{ DIST_DOMAIN }}</code></b>:那个页面会按手机品牌自动跳对应商店,
认不出品牌就直接给安装包。客户端点「立即更新」时,本渠道在<b>下方「渠道分发」</b>
里填了地址就用渠道的,<b>没填就回退到这里</b>——所以这一个地址能兜住所有渠道。
</p>
<p class="ed-hint">
「上传 APK」把安装包传到分发桶的 <code>android/{{ scopeApp || '应用名' }}/</code> 目录
(按应用分目录,不同应用的包不会互相覆盖)。传完后安装包的<b>下载直链会填进上面的输入框</b>,
可直接作为官方包的下载地址;同时<b>自动写进下方「渠道分发 → 官方渠道(100)」</b>——
网页下载页的安装包直链取的就是那一条。<b>输入框里的地址要点「保存」才会生效。</b>
</p>
<div class="mt-3">
<span class="ed-label">更新日志</span>
<textarea v-model="form.changelog" class="textarea textarea-bordered w-full text-xs" rows="4" placeholder="本次更新内容,会下发给客户端在升级框里展示" />
</div>
</section>
<div class="ed-footer">
<button class="btn btn-ghost btn-sm" :disabled="submitting" @click="load">重置</button>
<button class="btn btn-primary btn-sm text-white" :disabled="submitting" @click="save">保存</button>
</div>
</div>
<!-- 右:判定预览。版本比较是这块最容易出错的地方,直接把结论摆出来 -->
<div class="card preview">
<div class="ed-section-title">判定预览</div>
<p class="text-xs text-slate-400 mb-3">输入一个客户端版本号,看它会被判成什么。与服务端 comm.CompareVersion 同一套逐段整数比较。</p>
<div class="flex gap-2 mb-2">
<button class="btn btn-xs" :class="probePlatform === 'android' ? 'btn-primary text-white' : 'btn-ghost'" @click="probePlatform = 'android'">Android</button>
<button class="btn btn-xs" :class="probePlatform === 'ios' ? 'btn-primary text-white' : 'btn-ghost'" @click="probePlatform = 'ios'">iOS</button>
</div>
<input v-model="probe" class="input input-bordered input-sm w-full" placeholder="如 1.0.1" />
<div class="probe-list">
<div class="probe-row">
<span>有更新可用</span>
<span class="badge" :class="probeResult.need ? 'on' : 'off'">{{ probeResult.need ? '是' : '否' }}</span>
</div>
<div class="probe-row">
<span>必须更新(阻断使用)</span>
<span class="badge" :class="probeResult.force ? 'warn' : 'off'">{{ probeResult.force ? '是' : '否' }}</span>
</div>
<div class="probe-row">
<span>处于审核期</span>
<span class="badge" :class="probeResult.reviewing ? 'on' : 'off'">{{ probeResult.reviewing ? '是' : '否' }}</span>
</div>
</div>
<p v-if="!probe.trim()" class="ed-hint mt-3">
客户端没上报版本号时三项<b>一律为否</b>——拿不到版本就判强更会把所有老客户端挡在门外。
</p>
<p class="ed-hint mt-3">
这里不含两条与渠道有关的规则:渠道<b>未上架</b>(审核中/已下架)时不推送推荐更新;
算不出任何下载地址时两种更新都不提示。
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
interface AppRelease {
version: string
min_version: string
force_update: boolean
changelog: string
address: string
review_version: string
ios_version: string
ios_min_version: string
}
const { webApi, consoleApi, uploadFile } = useApi()
const { success, error } = useToast()
// 分发桶的公开域名,仅用于界面提示;真实地址以后端返回的为准
// (后端读【第三方服务配置 → 存储】的 dist_domain,留空时用这个默认值)。
const DIST_DOMAIN = 'https://dl.ymaikj.com'
const apkInput = ref<HTMLInputElement | null>(null)
const apkUploading = ref(false)
const apkPercent = ref(0)
function pickApk() {
apkInput.value?.click()
}
async function onApkPicked(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
// ⚠️ 先把 input 清空再做别的:不清的话选同一个文件第二次不会触发 change,
// 表现为「上传失败后重选同一个包没反应」。
input.value = ''
if (!file) return
if (!file.name.toLowerCase().endsWith('.apk')) {
error('请选择 .apk 文件')
return
}
// 安装包按应用分目录存,没选应用就不知道往哪放。后端也会拦(缺 X-App-Id 直接拒),
// 这里先挡一次是为了给一句人话,而不是让用户对着「缺少 X-App-Id」发懵。
if (!scopeApp.value) {
error('请先在页面顶部选择应用,再上传安装包')
return
}
apkUploading.value = true
apkPercent.value = 0
try {
const url = await uploadFile(file, 'android', {
target: 'dist',
appName: scopeApp.value,
onProgress: (p) => { apkPercent.value = p },
})
// 传完的直链要让人看得见、拿得走:填进「默认下载地址」框(2026-09-23 用户要求),
// 这个地址就是官方包的下载地址。填进表单不等于已保存,所以提示里点明要点「保存」。
form.address = url
await writeOfficialChannelAddress(url)
emit('uploaded', url)
success('安装包已上传:地址已填入「默认下载地址」并写入「官方渠道」,记得点「保存」')
} catch (err: any) {
error(err?.message || '上传失败')
} finally {
apkUploading.value = false
}
}
// 官方渠道的渠道号,与客户端 UserChannel.OFFICIAL 一致。
// 网页下载页的安装包直链取的就是这一条,所以传完包要落在这里。
const CHANNEL_OFFICIAL = 100
// 把新包地址写进「渠道分发 → 官方渠道」。
//
// ⚠️ savechannelapp 是**整行覆盖**(后端 ShouldBindJSON 到整个 comm.ChannelApp 再 Save),
// 所以必须先读回整行、只改 address 再写回。只发 {channel, address} 会把上架状态、
// 支付渠道、是否允许跳过设备绑定这些字段统统清零 —— 而且不会报任何错。
async function writeOfficialChannelAddress(address: string) {
// ⚠️ 这个接口回的是 { items: [...] } 而不是裸数组。当成数组取会永远找不到已有行,
// 于是每次都走「新建」分支,把官方渠道已有的上架状态/支付渠道/描述统统覆盖成默认值。
const d = await webApi<{ items?: any[] }>('getchannelapps', { app_name: scopeApp.value })
const old = (d?.items ?? []).find((r: any) => Number(r?.channel) === CHANNEL_OFFICIAL)
const row = old
? { ...old, address }
: {
// 这个渠道还不存在就建一条。enable 必须为 true、status 不能是草稿(0),
// 否则服务端 getChannelApps 会整条跳过,等于传了包却没人取得到。
app_name: scopeApp.value,
channel: CHANNEL_OFFICIAL,
address,
status: 1, // 已上架
enable: true,
description: '官方渠道(网页下载页的安装包直链)',
paychannels: '',
allowskipdevicebinding: false,
}
await webApi('savechannelapp', { ...row, app_name: scopeApp.value })
}
// 作用域由外层「版本与分发」页统一给:两块配置共用一个应用选择器。
// 用 computed 顶替原来的 scopeApp ref,正文里所有 scopeApp.value 的写法原样保留 ——
// 逐个改成 props.appName 只会在这次合并里多制造几十处可能改漏的地方。
const props = defineProps<{ appName: string }>()
// 传完安装包通知外层:下方「渠道分发」面板要重新拉一次,才能看到刚写进官方渠道的地址。
const emit = defineEmits<{ (e: 'uploaded', url: string): void }>()
const scopeApp = computed(() => props.appName)
const submitting = ref(false)
const probe = ref('')
function emptyForm(): AppRelease {
return { version: '', min_version: '', force_update: false, changelog: '', address: '', review_version: '', ios_version: '', ios_min_version: '' }
}
const form = reactive<AppRelease>(emptyForm())
// 与后端 comm.CompareVersion 同语义:切掉构建号、逐段按整数比、短的补 0、段内取前导数字。
// 后台这份只用于即时提示,真正的判定在服务端——两边不一致时以服务端为准。
function cmpVer(a: string, b: string): number {
const as = a.split('+')[0]!.trim().split('.')
const bs = b.split('+')[0]!.trim().split('.')
const n = Math.max(as.length, bs.length)
for (let i = 0; i < n; i++) {
const x = parseInt(as[i] ?? '0') || 0
const y = parseInt(bs[i] ?? '0') || 0
if (x !== y) return x < y ? -1 : 1
}
return 0
}
const versionWarn = computed(() => {
if (form.force_update && !form.version.trim() && !form.ios_version.trim()) {
return '开启强制更新时至少要填一个平台的「当前线上版本」,否则强更没有任何效果'
}
if (form.min_version.trim() && form.version.trim() && cmpVer(form.min_version, form.version) > 0) {
return 'Android「最低支持版本」高于「当前线上版本」,会把所有线上用户判定为需强更却无更高版本可更'
}
if (form.ios_min_version.trim() && form.ios_version.trim() && cmpVer(form.ios_min_version, form.ios_version) > 0) {
return 'iOS「最低支持版本」高于「当前线上版本」,会把所有线上用户判定为需强更却无更高版本可更'
}
return ''
})
// 判定预览按平台看:与服务端 comm.AppRelease.VersionFor / MinVersionFor 同口径(最低版本留空 = 线上版本)。
const probePlatform = ref<'android' | 'ios'>('android')
// 与服务端 evalChannelUpgrade 逐条对齐(含「强更必然意味着有更新」这条兜底);
// 渠道上架状态与下载地址那两条规则依赖渠道行,这里不模拟。
const probeResult = computed(() => {
const v = probe.value.trim()
if (!v) return { need: false, force: false, reviewing: false }
const ver = (probePlatform.value === 'ios' ? form.ios_version : form.version).trim()
const minRaw = (probePlatform.value === 'ios' ? form.ios_min_version : form.min_version).trim()
const min = minRaw || ver
const need = !!ver && cmpVer(v, ver) < 0
const force = form.force_update && !!min && cmpVer(v, min) < 0
const reviewing = !!form.review_version.trim() && cmpVer(v, form.review_version) === 0
return { need: need || force, force, reviewing }
})
async function load() {
try {
const d = await webApi<{ item: AppRelease }>('getapprelease', { app_name: scopeApp.value })
Object.assign(form, emptyForm(), d?.item ?? {})
} catch (e: any) {
error(e?.message ?? '加载失败')
Object.assign(form, emptyForm())
}
}
async function save() {
if (versionWarn.value) { error(versionWarn.value); return }
submitting.value = true
try {
// 只提交版本这一组字段:游客登录配置和它同一行,整行提交会把对方清空。
await webApi('saveappversion', {
app_name: scopeApp.value,
version: form.version,
min_version: form.min_version,
force_update: form.force_update,
changelog: form.changelog,
address: form.address,
review_version: form.review_version,
ios_version: form.ios_version,
ios_min_version: form.ios_min_version,
})
success('保存成功')
await load()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
submitting.value = false
}
}
// ⚠️ 初次加载必须放 onMounted,不能用 watch 的 immediate:immediate 会在 **SSR 阶段**
// 就执行,而这些接口要登录态,服务端渲染时必然失败 —— 表现是整页 500(踩过)。
// watch 只负责「外层换了应用」时重新拉。
onMounted(load)
watch(() => props.appName, () => { load() })
</script>
<style scoped>
.scope-tip { background: #f8fafc; border: 1px solid #e2e8f0; color: #475569; font-size: 12px; padding: 8px 12px; border-radius: 10px; margin-bottom: 16px; }
.ver-wrap { display: grid; gap: 16px; grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr); align-items: start; }
@media (max-width: 900px) { .ver-wrap { grid-template-columns: 1fr; } }
.card { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; padding: 18px 20px; }
.plat-col { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 10px 12px; }
.plat-title { font-size: 12px; font-weight: 600; color: #334155; margin-bottom: 6px; }
.ed-section { padding-bottom: 16px; margin-bottom: 16px; border-bottom: 1px solid #f1f5f9; }
.ed-section:last-of-type { border-bottom: 0; margin-bottom: 0; }
.ed-section-title { font-size: 13px; font-weight: 700; color: #334155; margin-bottom: 10px; }
.ed-label { display: block; font-size: 12px; color: #64748b; margin-bottom: 4px; }
.ed-hint { font-size: 11px; color: #94a3b8; margin-top: 4px; line-height: 1.6; }
.ed-warn { font-size: 12px; color: #b45309; background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; padding: 6px 10px; margin-top: 10px; }
.ed-footer { display: flex; justify-content: flex-end; gap: 8px; padding-top: 14px; border-top: 1px solid #f1f5f9; }
.preview { position: sticky; top: 16px; }
.probe-list { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
.probe-row { display: flex; align-items: center; justify-content: space-between; font-size: 12px; color: #475569; }
.badge { font-size: 11px; font-weight: 700; padding: 2px 10px; border-radius: 8px; }
.badge.on { background: #dcfce7; color: #059669; }
.badge.warn { background: #ffe4e6; color: #be123c; }
.badge.off { background: #f1f5f9; color: #94a3b8; }
.link { color: #4f46e5; text-decoration: underline; }
code { background: #f1f5f9; padding: 0 4px; border-radius: 4px; }
</style>

422
apps/admin/app/components/ChannelAppPanel.vue

@ -0,0 +1,422 @@
<template>
<div>
<div class="flex flex-wrap items-start justify-between gap-3 mb-4">
<div>
<div class="text-sm font-semibold text-slate-700">渠道分发</div>
<div class="text-xs text-slate-400 mt-1 max-w-3xl">
<b>因渠道而异</b>的东西:本渠道下载地址、上架状态、支付渠道、是否允许跳过设备绑定。
下载地址<b>留空就回退上面的「默认下载地址」</b>,所以只有需要直达某家商店时才填。
支付渠道在这里控制「开放哪几种支付入口」,商户凭据在应用的业务功能配置里填。
</div>
</div>
<button class="btn btn-primary btn-sm text-white shrink-0" @click="startAdd()">+ 添加渠道</button>
</div>
<!-- 支付凭据体检:开了支付入口却没配凭据,是这个页面最容易踩的坑 -->
<div v-if="payWarnings.length" class="pay-warn">
<div class="flex items-start gap-2">
<span>⚠</span>
<div class="flex-1">
<div class="text-sm font-semibold">以下支付方式已在渠道里开放,但商户凭据还没配:</div>
<div class="text-xs mt-1">
<span v-for="w in payWarnings" :key="w.key" class="warn-pill">{{ w.label }}({{ w.channels.join('、') }})</span>
</div>
</div>
<button class="btn btn-xs btn-ghost" @click="gotoPayConfig">去配置 →</button>
</div>
</div>
<!-- 按渠道大类分组 -->
<div v-for="g in groups" :key="g.label" class="mb-6">
<div class="flex items-center gap-2 mb-3">
<span class="group-tag" :style="{ background: g.bg, color: g.color }">{{ g.icon }} {{ g.label }}</span>
<span class="text-xs text-slate-400">{{ g.items.length }} / {{ g.channels.length }} 已配置</span>
</div>
<div class="ch-grid">
<div v-for="c in g.items" :key="c.channel" class="ch-card" :class="{ off: !c.enable }" @click="startEdit(c)">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="font-semibold text-sm text-slate-800">{{ channelName(c.channel) }}</div>
<div class="text-[11px] text-slate-400 mt-0.5">渠道号 {{ c.channel }}</div>
</div>
<span class="status-badge" :style="statusStyle(c.status)">{{ statusLabel(c.status) }}</span>
</div>
<div class="flex flex-wrap items-center gap-1.5 mt-2">
<span v-if="c.allowskipdevicebinding" class="flag">可跳过绑定</span>
<span v-if="!c.enable" class="flag flag-off">已停用</span>
</div>
<div v-if="payList(c).length" class="flex flex-wrap items-center gap-1.5 mt-2">
<span
v-for="p in payList(c)"
:key="p"
class="pay-pill"
:class="{ missing: payMissing(p) }"
:title="payMissing(p) ? '该支付方式的商户凭据尚未配置' : ''"
>{{ payLabel(p) }}{{ payMissing(p) ? ' ⚠' : '' }}</span>
</div>
<div class="text-[11px] mt-2 truncate" :class="c.address ? 'text-slate-400' : 'text-slate-300'">
{{ c.address || '未填本渠道地址 · 用应用默认' }}
</div>
</div>
<!-- 该组里尚未配置的渠道,点一下直接建 -->
<div
v-for="ch in g.missing"
:key="'m' + ch"
class="ch-card ch-empty"
@click="startAdd(ch)"
>
<div class="text-sm text-slate-400">{{ channelName(ch) }}</div>
<div class="text-[11px] text-slate-300 mt-1">未配置 · 点击添加</div>
</div>
</div>
</div>
<!-- 编辑弹窗 -->
<div v-show="modalOpen" class="ch-overlay">
<div class="ch-box">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<h3 class="text-base font-semibold text-slate-800">{{ isEditing ? '编辑渠道' : '添加渠道' }}</h3>
<span class="scope-chip">{{ scopeApp || '全局默认' }}</span>
</div>
<button class="btn btn-ghost btn-xs" @click="closeModal">✕ 关闭</button>
</div>
<section class="ed-section">
<div class="ed-section-title">渠道</div>
<div class="grid grid-cols-2 gap-3">
<div>
<span class="ed-label">分发渠道 <i class="req">*</i></span>
<select v-model.number="form.channel" :disabled="isEditing" class="select select-bordered select-sm w-full">
<option :value="0" disabled>请选择渠道</option>
<optgroup v-for="g in CHANNEL_GROUPS" :key="g.label" :label="g.label">
<option v-for="ch in g.channels" :key="ch" :value="ch">{{ channelName(ch) }}({{ ch }})</option>
</optgroup>
</select>
</div>
<div>
<span class="ed-label">上架状态</span>
<select v-model.number="form.status" class="select select-bordered select-sm w-full">
<option v-for="s in STATUS" :key="s.v" :value="s.v">{{ s.label }}</option>
</select>
</div>
</div>
<div class="mt-3">
<span class="ed-label">描述</span>
<input v-model="form.description" class="input input-bordered input-sm w-full" placeholder="这个渠道包的说明" />
</div>
</section>
<section class="ed-section">
<div class="ed-section-title">本渠道下载地址</div>
<input v-model="form.address" class="input input-bordered input-sm w-full" placeholder="本商店的下载链接,留空则用应用默认地址" />
<p class="ed-tip">
客户端点「立即更新」时直接打开这个地址,所以<b>必须是本渠道自己的商店链接</b>——
华为、小米、App Store 各不相同,填错会把用户引到别家商店。
留空则回退到<b>上方「发版配置」</b>里的「默认下载地址」——
那里填网页下载页的话,这里留空就等于交给它按品牌自动分流。
</p>
</section>
<section class="ed-section">
<div class="ed-section-title">能力开关</div>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input v-model="form.allowskipdevicebinding" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
<span>允许跳过设备绑定</span>
</label>
<p class="ed-tip">
游客登录开关已移到
<NuxtLink class="link" to="/guestaccess">服务配置 → 游客显隐控制</NuxtLink>
(按应用配,不分渠道)。
</p>
</section>
<section class="ed-section">
<div class="ed-section-title">支付渠道</div>
<div class="pay-grid">
<label v-for="p in PAY_CHANNELS" :key="p.key" class="pay-item" :class="{ checked: payChecked(p.key) }">
<input type="checkbox" class="checkbox checkbox-xs checkbox-primary" :checked="payChecked(p.key)" @change="togglePay(p.key)" />
<span>{{ p.label }}</span>
<span v-if="payChecked(p.key) && payMissing(p.key)" class="pay-miss">未配凭据</span>
</label>
</div>
<p class="ed-tip">
这里只决定「这个渠道开放哪几种支付入口」;商户号、密钥等凭据在
<a v-if="scopeApp" class="link" @click.prevent="gotoPayConfig">应用「{{ scopeApp }}」→ 业务功能配置 → 支付</a>
<span v-else>各应用的「业务功能配置 → 支付」</span>
里填。
</p>
</section>
<div class="ed-footer">
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input v-model="form.enable" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
<span>启用</span>
<span class="text-xs text-slate-400">停用后该渠道配置不下发客户端</span>
</label>
<div class="flex gap-2">
<button v-if="isEditing" class="btn btn-ghost btn-sm text-error mr-auto" @click="del">删除</button>
<button class="btn btn-ghost btn-sm" @click="closeModal">取消</button>
<button class="btn btn-primary btn-sm text-white" :disabled="submitting" @click="save">保存</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
// 与后端 comm.ChannelApp 一一对应。
// ⚠️ 版本号 / 最低支持版本 / 强制更新 / 更新日志 / 送审版本 / 游客登录开关**不在这里**:
// 它们是「整个应用一套」的,已搬到 app_release(服务配置 → 版本控制 / 游客显隐控制)。
// 别再加回来——按渠道各存一份意味着同一个开关有 8 处能改而客户端只读一处。
interface ChannelApp {
row_id: number
app_name: string
channel: number
address: string
status: number
description: string
allowskipdevicebinding: boolean
paychannels: string
enable: boolean
}
// 渠道枚举(与后端 pb.UserChannel / comm.Channel* 一致)
const CHANNELS: Record<number, string> = {
1: '华为', 2: '小米', 3: 'OPPO', 4: 'VIVO', 5: '荣耀',
100: '官方包', 101: 'App Store', 102: 'Google Play',
}
function channelName(v: number): string {
return CHANNELS[v] ?? '渠道' + v
}
const CHANNEL_GROUPS = [
{ label: '中国厂商', icon: '📱', color: '#0369a1', bg: '#dbeafe', channels: [1, 2, 3, 4, 5] },
{ label: '全球商店', icon: '🌍', color: '#c2410c', bg: '#fff7ed', channels: [101, 102] },
{ label: '官方渠道', icon: '🏠', color: '#059669', bg: '#dcfce7', channels: [100] },
]
// 上架状态(与 comm.ChannelStatus* 一致)
const STATUS = [
{ v: 0, label: '草稿(不下发)', color: '#64748b', bg: '#f1f5f9' },
{ v: 1, label: '已上架', color: '#059669', bg: '#dcfce7' },
{ v: 2, label: '审核中', color: '#b45309', bg: '#fef9c3' },
{ v: 3, label: '已下架', color: '#be123c', bg: '#ffe4e6' },
]
function statusLabel(v: number): string {
return STATUS.find((s) => s.v === v)?.label ?? '未知'
}
function statusStyle(v: number) {
const s = STATUS.find((x) => x.v === v)
return s ? { background: s.bg, color: s.color } : {}
}
// 支付渠道 → 业务功能配置里的模块键(与后端 comm.PayChannelModule 一致)
const PAY_CHANNELS = [
{ key: 'wxpay', label: '微信支付', module: 'wechatpay' },
{ key: 'alipay', label: '支付宝', module: 'alipay' },
{ key: 'paypal', label: 'PayPal', module: 'paypal' },
{ key: 'apple', label: 'Apple 内购', module: 'appleiap' },
{ key: 'google', label: 'Google 内购', module: 'googleiap' },
]
function payLabel(k: string): string {
return PAY_CHANNELS.find((p) => p.key === k)?.label ?? k
}
const { webApi, consoleApi } = useApi()
const { success, error } = useToast()
const router = useRouter()
// 作用域由外层「版本与分发」页统一给:两块配置共用一个应用选择器。
// 用 computed 顶替原来的 scopeApp ref,正文里所有 scopeApp.value 的写法原样保留 ——
// 逐个改成 props.appName 只会在这次合并里多制造几十处可能改漏的地方。
const props = defineProps<{ appName: string }>()
const scopeApp = computed(() => props.appName)
const items = ref<ChannelApp[]>([])
const payConfigured = ref<string[]>([]) // 已配凭据的模块键
const payChecked_ = ref(false) // 是否成功查到(全局作用域查不了)
const modalOpen = ref(false)
const isEditing = ref(false)
const submitting = ref(false)
function emptyForm(): ChannelApp {
return {
row_id: 0, app_name: '', channel: 0, address: '', status: 1, description: '',
allowskipdevicebinding: false, paychannels: '', enable: true,
}
}
const form = reactive<ChannelApp>(emptyForm())
const groups = computed(() =>
CHANNEL_GROUPS.map((g) => ({
...g,
items: items.value.filter((c) => g.channels.includes(c.channel)).sort((a, b) => a.channel - b.channel),
missing: g.channels.filter((ch) => !items.value.some((c) => c.channel === ch)),
})),
)
function payList(c: ChannelApp): string[] {
return (c.paychannels || '').split(',').map((x) => x.trim()).filter(Boolean)
}
// 某支付方式是否「开了但没配凭据」。查不到配置状态时一律不报警,避免误伤。
function payMissing(key: string): boolean {
if (!payChecked_.value) return false
const mod = PAY_CHANNELS.find((p) => p.key === key)?.module
return !!mod && !payConfigured.value.includes(mod)
}
// 汇总所有渠道里「开了但没配」的支付方式,连带列出是哪几个渠道开的
const payWarnings = computed(() => {
if (!payChecked_.value) return []
const map: Record<string, string[]> = {}
for (const c of items.value) {
if (!c.enable) continue
for (const p of payList(c)) {
if (!payMissing(p)) continue
;(map[p] = map[p] || []).push(channelName(c.channel))
}
}
return Object.keys(map).map((k) => ({ key: k, label: payLabel(k), channels: map[k]! }))
})
// ===== 加载 =====
// ===== 加载 =====
async function loadItems() {
try {
const d = await webApi<{ items: ChannelApp[] }>('getchannelapps', { app_name: scopeApp.value })
items.value = d?.items ?? []
} catch (e: any) {
error(e?.message ?? '加载失败')
items.value = []
}
}
async function loadPayStatus() {
try {
const d = await webApi<{ configured: string[]; checked: boolean }>('getchannelpaystatus', { app_name: scopeApp.value })
payConfigured.value = d?.configured ?? []
payChecked_.value = !!d?.checked
} catch {
payConfigured.value = []
payChecked_.value = false
}
}
async function loadAll() {
await Promise.all([loadItems(), loadPayStatus()])
}
// ===== 编辑 =====
function startAdd(channel?: number) {
Object.assign(form, emptyForm())
if (typeof channel === 'number') form.channel = channel
isEditing.value = false
modalOpen.value = true
}
function startEdit(c: ChannelApp) {
Object.assign(form, emptyForm(), c)
isEditing.value = true
modalOpen.value = true
}
function closeModal() {
modalOpen.value = false
}
function payChecked(key: string): boolean {
return payList(form).includes(key)
}
function togglePay(key: string) {
const cur = payList(form)
const i = cur.indexOf(key)
if (i >= 0) cur.splice(i, 1)
else cur.push(key)
form.paychannels = cur.join(',')
}
async function save() {
if (!form.channel) { error('请选择分发渠道'); return }
submitting.value = true
try {
await webApi('savechannelapp', { ...form, app_name: scopeApp.value })
success('保存成功')
closeModal()
await loadItems()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
submitting.value = false
}
}
function del() {
if (!window.confirm(`确定删除「${channelName(form.channel)}」这个渠道的配置吗?`)) return
webApi('delchannelapp', { app_name: scopeApp.value, channel: form.channel })
.then(() => { success('已删除'); closeModal(); loadItems() })
.catch((e: any) => error(e?.message ?? '删除失败'))
}
function gotoPayConfig() {
if (!scopeApp.value) { error('请先在上方选择一个应用'); return }
router.push(`/apps/${encodeURIComponent(scopeApp.value)}?tab=modules`)
}
// ⚠️ 初次加载必须放 onMounted,不能用 watch 的 immediate:immediate 会在 **SSR 阶段**
// 就执行,而这些接口要登录态,服务端渲染时必然失败 —— 表现是整页 500(踩过)。
// watch 只负责「外层换了应用」时重新拉。
onMounted(loadAll)
watch(() => props.appName, () => { loadAll() })
// 给外层「版本与分发」页用:上面的面板传完安装包会改写官方渠道的地址,这边要重拉才看得到。
defineExpose({ reload: loadAll })
</script>
<style scoped>
.group-tag { font-size: 12px; font-weight: 700; padding: 3px 10px; border-radius: 8px; }
.ch-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
.ch-card { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; padding: 14px 16px; cursor: pointer; transition: all .15s; }
.ch-card:hover { border-color: #c7d2fe; box-shadow: 0 4px 14px rgba(99, 102, 241, .08); }
.ch-card.off { opacity: .55; }
.ch-empty { border-style: dashed; background: #fafbfc; display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 92px; }
.ch-empty:hover { border-color: #c7d2fe; background: #f8fafc; }
.status-badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 6px; white-space: nowrap; }
.flag { font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 5px; background: #f1f5f9; color: #475569; }
.flag-off { background: #fee2e2; color: #b91c1c; }
.pay-pill { font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 6px; background: #eef2ff; color: #4338ca; }
.pay-pill.missing { background: #fef3c7; color: #b45309; }
.pay-warn { background: #fffbeb; border: 1px solid #fde68a; color: #92400e; border-radius: 12px; padding: 12px 14px; margin-bottom: 16px; }
.warn-pill { display: inline-block; margin-right: 8px; padding: 1px 8px; border-radius: 6px; background: #fef3c7; }
.scope-tip { background: #f8fafc; border: 1px solid #eef2f7; color: #64748b; font-size: 12px; border-radius: 10px; padding: 9px 13px; margin-bottom: 16px; }
/* 弹窗 */
.ch-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, .45); z-index: 100; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(2px); padding: 24px; }
.ch-box { background: #fff; border-radius: 18px; padding: 24px; width: 700px; max-width: 96vw; max-height: 90vh; overflow-y: auto; box-shadow: 0 24px 80px rgba(0, 0, 0, .18); }
.scope-chip { font-size: 11px; font-weight: 600; padding: 2px 9px; border-radius: 6px; background: #eef2ff; color: #4338ca; }
.ed-section { padding: 14px 0; border-top: 1px dashed #eef2f7; }
.ed-section:first-of-type { padding-top: 0; border-top: none; }
.ed-section-title { font-size: 11px; font-weight: 700; letter-spacing: .04em; color: #94a3b8; margin-bottom: 10px; }
.ed-label { display: block; font-size: 11px; color: #64748b; margin-bottom: 4px; }
.ed-warn { font-size: 11px; color: #b45309; margin-top: 8px; }
.ed-tip { font-size: 11px; color: #94a3b8; margin-top: 8px; }
.req { color: #dc2626; font-style: normal; }
.link { color: #4f46e5; cursor: pointer; text-decoration: underline; }
.pay-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.pay-item { display: flex; align-items: center; gap: 7px; padding: 8px 10px; border-radius: 9px; border: 1.5px solid #e2e8f0; cursor: pointer; font-size: 13px; color: #475569; transition: all .15s; }
.pay-item:hover { border-color: #c7d2fe; background: #f8fafc; }
.pay-item.checked { border-color: #6366f1; background: #eef2ff; color: #4338ca; }
.pay-miss { font-size: 10px; color: #b45309; background: #fef3c7; padding: 1px 5px; border-radius: 4px; margin-left: auto; }
.ed-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 14px; border-top: 1px solid #eef2f7; }
@media (max-width: 860px) {
.pay-grid { grid-template-columns: repeat(2, 1fr); }
}
</style>

59
apps/admin/app/composables/useApi.ts

@ -89,30 +89,65 @@ export function useApi() {
// 浏览器用同样的 Content-Type PUT 直传,最终 URL(去签名 query)作为文件地址返回。
// 注意:OSS 预签名 PUT 把 Content-Type 计入签名,故这里 PUT 的 Content-Type 必须与提交给后端
// 签名的 content_type 完全一致;filename 也要一并提交,后端才能拼出完整 object key。
async function uploadFile(file: File, path: string): Promise<string> {
//
// opts.target='dist' 走「分发桶」(dl.ymaikj.com),用于 App 安装包;缺省走主桶(固件/产品图)。
// opts.onProgress 给大文件用:安装包几十 MB,没有进度的话用户只看到按钮转圈转两分钟,
// 分不清是在传还是卡死了。
async function uploadFile(
file: File,
path: string,
opts: { target?: string; appName?: string; onProgress?: (percent: number) => void } = {},
): Promise<string> {
const contentType = file.type || 'application/octet-stream'
// ⚠️ opts.appName 是页面上选中的应用名,安装包上传必须带:
// X-App-Id 头来自登录态的 currentAppId,只有绑定了应用的运营/代理账号才有;
// 超管在页面顶部选的应用只是个 app_name 字符串,不会进请求头,
// 不带 app_name 的话超管传包必然被后端拒成「必须先选择应用」。
const token = await webApi<{
upload_url?: string
url?: string
}>('getcostoken', { path, filename: file.name, content_type: contentType })
content_type?: string
}>('getcostoken', {
path,
filename: file.name,
content_type: contentType,
target: opts.target || '',
app_name: opts.appName || '',
})
// ⚠️ 以后端回带的为准:服务端会按扩展名强制改写 Content-Type(安装包就是这样),
// 而浏览器对 .apk 给出的 File.type 往往是**空字符串**。照 file.type 发 PUT 会与签名不符,
// OSS 回 403 SignatureDoesNotMatch,报错里完全看不出是类型对不上。
const putType = token.content_type || contentType
const uploadUrl = token.upload_url || token.url || ''
if (!uploadUrl) {
throw new Error('未获取到上传地址:请确认已在【第三方服务配置→存储】配置并启用「阿里云 OSS」')
}
const putResponse = await fetch(uploadUrl, {
method: 'PUT',
headers: {
'Content-Type': contentType,
},
body: file,
// ⚠️ 用 XHR 不用 fetch:fetch 没有上传进度事件(只有下载方向的 stream),
// 而这个入口就是给几十 MB 的安装包用的。
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('PUT', uploadUrl, true)
// 必须与后端签名时用的 Content-Type 完全一致,理由见上面 putType
xhr.setRequestHeader('Content-Type', putType)
if (opts.onProgress) {
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) opts.onProgress!(Math.round((e.loaded / e.total) * 100))
}
}
xhr.onload = () =>
xhr.status >= 200 && xhr.status < 300
? resolve()
: reject(new Error(`上传失败:HTTP ${xhr.status}`))
// ⚠️ 跨域被拒时 XHR 只会走到 onerror 且拿不到任何细节(浏览器刻意不暴露),
// 所以这里必须把最可能的原因写进文案,否则排查时只看到一句「网络错误」。
xhr.onerror = () =>
reject(new Error('上传失败:可能是目标存储桶没有配置跨域(CORS)规则,需允许来源 ' + location.origin + ' 的 PUT'))
xhr.send(file)
})
if (!putResponse.ok) {
throw new Error(`Upload failed: ${putResponse.status}`)
}
// 优先用后端给的裸 URL;兜底自己去掉签名 query。
return token.url || uploadUrl.split('?')[0]
}

94
apps/admin/app/pages/appdist.vue

@ -0,0 +1,94 @@
<template>
<div>
<!-- 页头 + 应用选择:上下两块配置共用一个作用域 -->
<div class="flex flex-wrap items-start justify-between gap-3 mb-4">
<div>
<h2 class="text-lg font-semibold text-slate-800">版本与分发</h2>
<p class="text-xs text-slate-400 mt-1 max-w-3xl">
发一次版要配的东西都在这一页:上半区是<b>整个应用一套</b>的版本号 / 强更 / 更新日志 / 默认下载地址,
下半区是<b>因渠道而异</b>的各商店链接与上架状态。两者存在不同的表里,
<b>提交也各管各的</b>——原因见下方说明。
</p>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-slate-500">应用</span>
<select v-model="scopeApp" class="select select-bordered select-sm min-w-40">
<option value="">全局默认(所有应用兜底)</option>
<option v-for="a in scopeApps" :key="a" :value="a">{{ a }}</option>
</select>
</div>
</div>
<div v-if="!scopeApp" class="scope-tip">
当前是<b>全局默认</b>作用域:这里配的对所有应用生效,某个应用单独配了则以应用的为准。
<b>上传安装包必须先选具体应用</b>——安装包按应用分目录存放。
</div>
<section class="panel p-5 mb-4">
<AppReleasePanel :app-name="scopeApp" @uploaded="channelPanel?.reload()" />
</section>
<section class="panel p-5">
<ChannelAppPanel ref="channelPanel" :app-name="scopeApp" />
</section>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import AppReleasePanel from '~/components/AppReleasePanel.vue'
import ChannelAppPanel from '~/components/ChannelAppPanel.vue'
definePageMeta({ title: '版本与分发' })
/*
「版本控制」与「渠道分发」2026-09-23 合并成本页。
⚠️ **合的是入口,不是表。** app_release(一应用一行) 与 channel_app((应用,渠道) 一行)
维度不同,2026-08-31 正是从一张表拆开的:版本号和游客开关本来就整个应用一套,
按渠道各存一份意味着同一个开关有 8 处能改而客户端只读一处,出过
「后台明明开了游客登录、App 就是不显示」的事故。合回去等于把那个坑重新挖开。
⚠️ **两块各自提交,不要做成一个大表单整包保存。** app_release 那一行还被
「游客显隐控制」页共用,后端是「读整行 → 只覆盖本组字段 → 整行写回」;
做成整行提交的话,在本页点一次保存就会把游客登录配置清空。
*/
const { webApi, consoleApi } = useApi()
const scopeApp = ref('')
// 上面的面板传完安装包后会改写官方渠道地址,这里拿到下方面板的句柄让它重拉。
const channelPanel = ref<{ reload: () => Promise<void> } | null>(null)
const scopeApps = ref<string[]>([])
// 应用名来源:应用中心的应用表 + 部署注册表(兜底老数据);无权读这两张表时改走 api_getmyapps。
async function loadScopeApps() {
const names = new Set<string>()
const [products, deploys] = await Promise.all([
consoleApi<Array<{ name: string }>>('products/list', {}).catch(() => [] as Array<{ name: string }>),
consoleApi<Array<{ app_name?: string; name: string }>>('apps/list', {}).catch(() => [] as Array<{ app_name?: string; name: string }>),
])
;(products ?? []).forEach((p) => p.name && names.add(p.name))
;(deploys ?? []).forEach((d) => (d.app_name || d.name) && names.add(d.app_name || d.name))
if (!names.size) {
try {
const d = await webApi<{ apps: Array<{ name: string; app_name?: string }> }>('getmyapps', {})
;(d?.apps ?? []).forEach((a) => {
const n = a.app_name || a.name
if (n) names.add(n)
})
} catch {
// 拿不到就只剩「全局默认」,不阻断页面
}
}
scopeApps.value = [...names].sort()
}
onMounted(loadScopeApps)
</script>
<style scoped>
.panel { background: #fff; border: 1px solid #e2e8f0; border-radius: 14px; }
.scope-tip { background: #f8fafc; border: 1px solid #e2e8f0; color: #475569; font-size: 12px; padding: 8px 12px; border-radius: 10px; margin-bottom: 16px; }
</style>

256
apps/admin/app/pages/appversion.vue

@ -1,254 +1,12 @@
<template>
<div>
<!-- 顶部 -->
<div class="flex items-center justify-between mb-5 flex-wrap gap-3">
<div>
<h2 class="text-lg font-semibold text-slate-800">版本控制</h2>
<p class="text-xs text-slate-400 mt-1">
按应用配置线上版本、强制更新与送审版本。<b>整个应用一套,不分渠道</b>——各商店的下载地址可在「渠道分发」里单独覆盖。
</p>
</div>
<div class="flex items-center gap-3">
<span class="text-sm text-slate-500">应用</span>
<select v-model="scopeApp" class="select select-bordered select-sm" @change="load">
<option value="">全局默认(所有应用兜底)</option>
<option v-for="a in scopeApps" :key="a" :value="a">{{ a }}</option>
</select>
</div>
</div>
<div v-if="!scopeApp" class="scope-tip">
当前是<b>全局默认</b>作用域:这里配的对所有应用生效;某个应用单独配了,则以应用的为准。
</div>
<div class="ver-wrap">
<!-- 左:表单 -->
<div class="card">
<section class="ed-section">
<div class="ed-section-title">线上版本</div>
<div class="grid grid-cols-2 gap-3">
<div>
<span class="ed-label">当前线上版本</span>
<input v-model="form.version" class="input input-bordered input-sm w-full" placeholder="如 1.0.1" />
<p class="ed-hint">高于客户端本机版本时提示「有新版本」。填客户端上报的净版本号,<b>不含构建号</b>(<code>1.0.1</code> 而不是 <code>1.0.1+5</code>)。</p>
</div>
<div>
<span class="ed-label">送审版本号</span>
<input v-model="form.review_version" class="input input-bordered input-sm w-full" placeholder="过审后清空" />
<p class="ed-hint">客户端上报的版本正好等于它时,会隐藏未过审的功能。</p>
</div>
</div>
</section>
<section class="ed-section">
<div class="ed-section-title">强制更新</div>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input v-model="form.force_update" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
<span>开启强制更新</span>
</label>
<div v-if="form.force_update" class="pl-7 mt-2">
<span class="ed-label">最低支持版本(必填)</span>
<input v-model="form.min_version" class="input input-bordered input-sm w-full" placeholder="如 1.0.0" />
<p class="ed-hint">
低于这个版本的客户端会弹出<b>不可关闭</b>的升级框,无法继续使用。
不填等于开了强更却挡不住任何人。
</p>
</div>
<p v-if="versionWarn" class="ed-warn">⚠ {{ versionWarn }}</p>
</section>
<section class="ed-section">
<div class="ed-section-title">下载与说明</div>
<span class="ed-label">默认下载地址</span>
<input v-model="form.address" class="input input-bordered input-sm w-full" placeholder="应用商店链接或 apk 直链" />
<p class="ed-hint">
<b>各商店链接不一样</b>,这里填的是兜底值;华为/小米/App Store 等请在
<NuxtLink class="link" to="/channels">渠道分发</NuxtLink>
里给对应渠道填「本渠道下载地址」,渠道填了就以渠道的为准。
</p>
<div class="mt-3">
<span class="ed-label">更新日志</span>
<textarea v-model="form.changelog" class="textarea textarea-bordered w-full text-xs" rows="4" placeholder="本次更新内容,会下发给客户端在升级框里展示" />
</div>
</section>
<div class="ed-footer">
<button class="btn btn-ghost btn-sm" :disabled="submitting" @click="load">重置</button>
<button class="btn btn-primary btn-sm text-white" :disabled="submitting" @click="save">保存</button>
</div>
</div>
<!-- 右:判定预览。版本比较是这块最容易出错的地方,直接把结论摆出来 -->
<div class="card preview">
<div class="ed-section-title">判定预览</div>
<p class="text-xs text-slate-400 mb-3">输入一个客户端版本号,看它会被判成什么。与服务端 comm.CompareVersion 同一套逐段整数比较。</p>
<input v-model="probe" class="input input-bordered input-sm w-full" placeholder="如 1.0.1" />
<div class="probe-list">
<div class="probe-row">
<span>有更新可用</span>
<span class="badge" :class="probeResult.need ? 'on' : 'off'">{{ probeResult.need ? '是' : '否' }}</span>
</div>
<div class="probe-row">
<span>必须更新(阻断使用)</span>
<span class="badge" :class="probeResult.force ? 'warn' : 'off'">{{ probeResult.force ? '是' : '否' }}</span>
</div>
<div class="probe-row">
<span>处于审核期</span>
<span class="badge" :class="probeResult.reviewing ? 'on' : 'off'">{{ probeResult.reviewing ? '是' : '否' }}</span>
</div>
</div>
<p v-if="!probe.trim()" class="ed-hint mt-3">
客户端没上报版本号时三项<b>一律为否</b>——拿不到版本就判强更会把所有老客户端挡在门外。
</p>
</div>
</div>
</div>
<div class="py-16 text-center text-slate-400 text-sm">正在跳转到「版本与分发」…</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
definePageMeta({ title: '版本控制' })
interface AppRelease {
version: string
min_version: string
force_update: boolean
changelog: string
address: string
review_version: string
}
const { webApi, consoleApi } = useApi()
const { success, error } = useToast()
const scopeApp = ref('')
const scopeApps = ref<string[]>([])
const submitting = ref(false)
const probe = ref('')
function emptyForm(): AppRelease {
return { version: '', min_version: '', force_update: false, changelog: '', address: '', review_version: '' }
}
const form = reactive<AppRelease>(emptyForm())
// 与后端 comm.CompareVersion 同语义:切掉构建号、逐段按整数比、短的补 0、段内取前导数字。
// 后台这份只用于即时提示,真正的判定在服务端——两边不一致时以服务端为准。
function cmpVer(a: string, b: string): number {
const as = a.split('+')[0]!.trim().split('.')
const bs = b.split('+')[0]!.trim().split('.')
const n = Math.max(as.length, bs.length)
for (let i = 0; i < n; i++) {
const x = parseInt(as[i] ?? '0') || 0
const y = parseInt(bs[i] ?? '0') || 0
if (x !== y) return x < y ? -1 : 1
}
return 0
}
const versionWarn = computed(() => {
if (form.force_update && !form.min_version.trim()) {
return '开启强制更新时必须填「最低支持版本」,否则挡不住任何人'
}
if (form.min_version.trim() && form.version.trim() && cmpVer(form.min_version, form.version) > 0) {
return '「最低支持版本」高于「当前线上版本」,会把所有线上用户判定为需强更却无更高版本可更'
}
return ''
})
// 与服务端 evalChannelUpgrade 逐条对齐(含「强更必然意味着有更新」这条兜底)
const probeResult = computed(() => {
const v = probe.value.trim()
if (!v) return { need: false, force: false, reviewing: false }
const need = !!form.version.trim() && cmpVer(v, form.version) < 0
const force = form.force_update && !!form.min_version.trim() && cmpVer(v, form.min_version) < 0
const reviewing = !!form.review_version.trim() && cmpVer(v, form.review_version) === 0
return { need: need || force, force, reviewing }
})
// 应用名来源:应用中心的应用表 + 部署注册表(兜底老数据);无权读这两张表时改走 api_getmyapps。
async function loadScopeApps() {
const names = new Set<string>()
const [products, deploys] = await Promise.all([
consoleApi<Array<{ name: string }>>('products/list', {}).catch(() => [] as Array<{ name: string }>),
consoleApi<Array<{ app_name?: string; name: string }>>('apps/list', {}).catch(() => [] as Array<{ app_name?: string; name: string }>),
])
;(products ?? []).forEach((p) => p.name && names.add(p.name))
;(deploys ?? []).forEach((d) => (d.app_name || d.name) && names.add(d.app_name || d.name))
if (!names.size) {
try {
const d = await webApi<{ apps: Array<{ name: string; app_name?: string }> }>('getmyapps', {})
;(d?.apps ?? []).forEach((a) => {
const n = a.app_name || a.name
if (n) names.add(n)
})
} catch {
// 拿不到就只剩「全局默认」,不阻断页面
}
}
scopeApps.value = [...names].sort()
}
async function load() {
try {
const d = await webApi<{ item: AppRelease }>('getapprelease', { app_name: scopeApp.value })
Object.assign(form, emptyForm(), d?.item ?? {})
} catch (e: any) {
error(e?.message ?? '加载失败')
Object.assign(form, emptyForm())
}
}
async function save() {
if (versionWarn.value) { error(versionWarn.value); return }
submitting.value = true
try {
// 只提交版本这一组字段:游客登录配置和它同一行,整行提交会把对方清空。
await webApi('saveappversion', {
app_name: scopeApp.value,
version: form.version,
min_version: form.min_version,
force_update: form.force_update,
changelog: form.changelog,
address: form.address,
review_version: form.review_version,
})
success('保存成功')
await load()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
submitting.value = false
}
}
onMounted(async () => {
await loadScopeApps()
await load()
})
// 2026-09-23:「版本控制」并入「版本与分发」(/appdist),页面本体已拆成
// components/AppReleasePanel.vue 与 components/ChannelAppPanel.vue。
// 这里只留重定向——后台里这个地址被收藏/直接输入的不在少数,直接删文件会变成 404
// (同「会议模板」并入「会议配置」时的处理)。
definePageMeta({ title: '版本与分发' })
onMounted(() => { navigateTo('/appdist', { replace: true }) })
</script>
<style scoped>
.scope-tip { background: #f8fafc; border: 1px solid #e2e8f0; color: #475569; font-size: 12px; padding: 8px 12px; border-radius: 10px; margin-bottom: 16px; }
.ver-wrap { display: grid; gap: 16px; grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr); align-items: start; }
@media (max-width: 900px) { .ver-wrap { grid-template-columns: 1fr; } }
.card { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; padding: 18px 20px; }
.ed-section { padding-bottom: 16px; margin-bottom: 16px; border-bottom: 1px solid #f1f5f9; }
.ed-section:last-of-type { border-bottom: 0; margin-bottom: 0; }
.ed-section-title { font-size: 13px; font-weight: 700; color: #334155; margin-bottom: 10px; }
.ed-label { display: block; font-size: 12px; color: #64748b; margin-bottom: 4px; }
.ed-hint { font-size: 11px; color: #94a3b8; margin-top: 4px; line-height: 1.6; }
.ed-warn { font-size: 12px; color: #b45309; background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; padding: 6px 10px; margin-top: 10px; }
.ed-footer { display: flex; justify-content: flex-end; gap: 8px; padding-top: 14px; border-top: 1px solid #f1f5f9; }
.preview { position: sticky; top: 16px; }
.probe-list { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
.probe-row { display: flex; align-items: center; justify-content: space-between; font-size: 12px; color: #475569; }
.badge { font-size: 11px; font-weight: 700; padding: 2px 10px; border-radius: 8px; }
.badge.on { background: #dcfce7; color: #059669; }
.badge.warn { background: #ffe4e6; color: #be123c; }
.badge.off { background: #f1f5f9; color: #94a3b8; }
.link { color: #4f46e5; text-decoration: underline; }
code { background: #f1f5f9; padding: 0 4px; border-radius: 4px; }
</style>

445
apps/admin/app/pages/channels.vue

@ -1,443 +1,12 @@
<template>
<div>
<!-- 顶部 -->
<div class="flex items-center justify-between mb-5 flex-wrap gap-3">
<div>
<h2 class="text-lg font-semibold text-slate-800">渠道分发</h2>
<p class="text-xs text-slate-400 mt-1">
管理各应用在各分发渠道上<b>因渠道而异</b>的东西:本渠道下载地址、上架状态、支付渠道、是否允许跳过设备绑定。
支付渠道在这里控制「开放哪几种支付入口」,商户凭据在应用的业务功能配置里填。
</p>
<p class="text-xs text-slate-400 mt-1">
版本号 / 强制更新 / 送审版本去
<NuxtLink class="link" to="/appversion">服务配置 → 版本控制</NuxtLink>,
游客登录开关去
<NuxtLink class="link" to="/guestaccess">服务配置 → 游客显隐控制</NuxtLink>
—— 那两组是整个应用一套的,不分渠道。
</p>
</div>
<div class="flex items-center gap-3">
<span class="text-sm text-slate-500">应用</span>
<select v-model="scopeApp" class="select select-bordered select-sm" @change="loadAll">
<option value="">全局默认(所有应用兜底)</option>
<option v-for="a in scopeApps" :key="a" :value="a">{{ a }}</option>
</select>
<button class="btn btn-primary btn-sm text-white" @click="startAdd">+ 添加渠道</button>
</div>
</div>
<!-- 支付凭据体检:开了支付入口却没配凭据,是这个页面最容易踩的坑 -->
<div v-if="payWarnings.length" class="pay-warn">
<div class="flex items-start gap-2">
<span>⚠</span>
<div class="flex-1">
<div class="text-sm font-semibold">以下支付方式已在渠道里开放,但商户凭据还没配:</div>
<div class="text-xs mt-1">
<span v-for="w in payWarnings" :key="w.key" class="warn-pill">{{ w.label }}({{ w.channels.join('、') }})</span>
</div>
</div>
<button class="btn btn-xs btn-ghost" @click="gotoPayConfig">去配置 →</button>
</div>
</div>
<div v-if="!scopeApp" class="scope-tip">
当前是<b>全局默认</b>作用域:这里配的渠道对所有应用生效,应用自己配了同渠道则以应用的为准。
</div>
<!-- 按渠道大类分组 -->
<div v-for="g in groups" :key="g.label" class="mb-6">
<div class="flex items-center gap-2 mb-3">
<span class="group-tag" :style="{ background: g.bg, color: g.color }">{{ g.icon }} {{ g.label }}</span>
<span class="text-xs text-slate-400">{{ g.items.length }} / {{ g.channels.length }} 已配置</span>
</div>
<div class="ch-grid">
<div v-for="c in g.items" :key="c.channel" class="ch-card" :class="{ off: !c.enable }" @click="startEdit(c)">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="font-semibold text-sm text-slate-800">{{ channelName(c.channel) }}</div>
<div class="text-[11px] text-slate-400 mt-0.5">渠道号 {{ c.channel }}</div>
</div>
<span class="status-badge" :style="statusStyle(c.status)">{{ statusLabel(c.status) }}</span>
</div>
<div class="flex flex-wrap items-center gap-1.5 mt-2">
<span v-if="c.allowskipdevicebinding" class="flag">可跳过绑定</span>
<span v-if="!c.enable" class="flag flag-off">已停用</span>
</div>
<div v-if="payList(c).length" class="flex flex-wrap items-center gap-1.5 mt-2">
<span
v-for="p in payList(c)"
:key="p"
class="pay-pill"
:class="{ missing: payMissing(p) }"
:title="payMissing(p) ? '该支付方式的商户凭据尚未配置' : ''"
>{{ payLabel(p) }}{{ payMissing(p) ? ' ⚠' : '' }}</span>
</div>
<div class="text-[11px] mt-2 truncate" :class="c.address ? 'text-slate-400' : 'text-slate-300'">
{{ c.address || '未填本渠道地址 · 用应用默认' }}
</div>
</div>
<!-- 该组里尚未配置的渠道,点一下直接建 -->
<div
v-for="ch in g.missing"
:key="'m' + ch"
class="ch-card ch-empty"
@click="startAdd(ch)"
>
<div class="text-sm text-slate-400">{{ channelName(ch) }}</div>
<div class="text-[11px] text-slate-300 mt-1">未配置 · 点击添加</div>
</div>
</div>
</div>
<!-- 编辑弹窗 -->
<div v-show="modalOpen" class="ch-overlay">
<div class="ch-box">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<h3 class="text-base font-semibold text-slate-800">{{ isEditing ? '编辑渠道' : '添加渠道' }}</h3>
<span class="scope-chip">{{ scopeApp || '全局默认' }}</span>
</div>
<button class="btn btn-ghost btn-xs" @click="closeModal">✕ 关闭</button>
</div>
<section class="ed-section">
<div class="ed-section-title">渠道</div>
<div class="grid grid-cols-2 gap-3">
<div>
<span class="ed-label">分发渠道 <i class="req">*</i></span>
<select v-model.number="form.channel" :disabled="isEditing" class="select select-bordered select-sm w-full">
<option :value="0" disabled>请选择渠道</option>
<optgroup v-for="g in CHANNEL_GROUPS" :key="g.label" :label="g.label">
<option v-for="ch in g.channels" :key="ch" :value="ch">{{ channelName(ch) }}({{ ch }})</option>
</optgroup>
</select>
</div>
<div>
<span class="ed-label">上架状态</span>
<select v-model.number="form.status" class="select select-bordered select-sm w-full">
<option v-for="s in STATUS" :key="s.v" :value="s.v">{{ s.label }}</option>
</select>
</div>
</div>
<div class="mt-3">
<span class="ed-label">描述</span>
<input v-model="form.description" class="input input-bordered input-sm w-full" placeholder="这个渠道包的说明" />
</div>
</section>
<section class="ed-section">
<div class="ed-section-title">本渠道下载地址</div>
<input v-model="form.address" class="input input-bordered input-sm w-full" placeholder="本商店的下载链接,留空则用应用默认地址" />
<p class="ed-tip">
客户端点「立即更新」时直接打开这个地址,所以<b>必须是本渠道自己的商店链接</b>——
华为、小米、App Store 各不相同,填错会把用户引到别家商店。
留空则回退到
<NuxtLink class="link" to="/appversion">版本控制</NuxtLink>
里配的「默认下载地址」。
</p>
</section>
<section class="ed-section">
<div class="ed-section-title">能力开关</div>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input v-model="form.allowskipdevicebinding" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
<span>允许跳过设备绑定</span>
</label>
<p class="ed-tip">
游客登录开关已移到
<NuxtLink class="link" to="/guestaccess">服务配置 → 游客显隐控制</NuxtLink>
(按应用配,不分渠道)。
</p>
</section>
<section class="ed-section">
<div class="ed-section-title">支付渠道</div>
<div class="pay-grid">
<label v-for="p in PAY_CHANNELS" :key="p.key" class="pay-item" :class="{ checked: payChecked(p.key) }">
<input type="checkbox" class="checkbox checkbox-xs checkbox-primary" :checked="payChecked(p.key)" @change="togglePay(p.key)" />
<span>{{ p.label }}</span>
<span v-if="payChecked(p.key) && payMissing(p.key)" class="pay-miss">未配凭据</span>
</label>
</div>
<p class="ed-tip">
这里只决定「这个渠道开放哪几种支付入口」;商户号、密钥等凭据在
<a v-if="scopeApp" class="link" @click.prevent="gotoPayConfig">应用「{{ scopeApp }}」→ 业务功能配置 → 支付</a>
<span v-else>各应用的「业务功能配置 → 支付」</span>
里填。
</p>
</section>
<div class="ed-footer">
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input v-model="form.enable" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
<span>启用</span>
<span class="text-xs text-slate-400">停用后该渠道配置不下发客户端</span>
</label>
<div class="flex gap-2">
<button v-if="isEditing" class="btn btn-ghost btn-sm text-error mr-auto" @click="del">删除</button>
<button class="btn btn-ghost btn-sm" @click="closeModal">取消</button>
<button class="btn btn-primary btn-sm text-white" :disabled="submitting" @click="save">保存</button>
</div>
</div>
</div>
</div>
</div>
<div class="py-16 text-center text-slate-400 text-sm">正在跳转到「版本与分发」…</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
definePageMeta({ title: '渠道分发' })
// 与后端 comm.ChannelApp 一一对应。
// ⚠️ 版本号 / 最低支持版本 / 强制更新 / 更新日志 / 送审版本 / 游客登录开关**不在这里**:
// 它们是「整个应用一套」的,已搬到 app_release(服务配置 → 版本控制 / 游客显隐控制)。
// 别再加回来——按渠道各存一份意味着同一个开关有 8 处能改而客户端只读一处。
interface ChannelApp {
row_id: number
app_name: string
channel: number
address: string
status: number
description: string
allowskipdevicebinding: boolean
paychannels: string
enable: boolean
}
// 渠道枚举(与后端 pb.UserChannel / comm.Channel* 一致)
const CHANNELS: Record<number, string> = {
1: '华为', 2: '小米', 3: 'OPPO', 4: 'VIVO', 5: '荣耀',
100: '官方包', 101: 'App Store', 102: 'Google Play',
}
function channelName(v: number): string {
return CHANNELS[v] ?? '渠道' + v
}
const CHANNEL_GROUPS = [
{ label: '中国厂商', icon: '📱', color: '#0369a1', bg: '#dbeafe', channels: [1, 2, 3, 4, 5] },
{ label: '全球商店', icon: '🌍', color: '#c2410c', bg: '#fff7ed', channels: [101, 102] },
{ label: '官方渠道', icon: '🏠', color: '#059669', bg: '#dcfce7', channels: [100] },
]
// 上架状态(与 comm.ChannelStatus* 一致)
const STATUS = [
{ v: 0, label: '草稿(不下发)', color: '#64748b', bg: '#f1f5f9' },
{ v: 1, label: '已上架', color: '#059669', bg: '#dcfce7' },
{ v: 2, label: '审核中', color: '#b45309', bg: '#fef9c3' },
{ v: 3, label: '已下架', color: '#be123c', bg: '#ffe4e6' },
]
function statusLabel(v: number): string {
return STATUS.find((s) => s.v === v)?.label ?? '未知'
}
function statusStyle(v: number) {
const s = STATUS.find((x) => x.v === v)
return s ? { background: s.bg, color: s.color } : {}
}
// 支付渠道 → 业务功能配置里的模块键(与后端 comm.PayChannelModule 一致)
const PAY_CHANNELS = [
{ key: 'wxpay', label: '微信支付', module: 'wechatpay' },
{ key: 'alipay', label: '支付宝', module: 'alipay' },
{ key: 'paypal', label: 'PayPal', module: 'paypal' },
{ key: 'apple', label: 'Apple 内购', module: 'appleiap' },
{ key: 'google', label: 'Google 内购', module: 'googleiap' },
]
function payLabel(k: string): string {
return PAY_CHANNELS.find((p) => p.key === k)?.label ?? k
}
const { webApi, consoleApi } = useApi()
const { success, error } = useToast()
const router = useRouter()
const scopeApp = ref('')
const scopeApps = ref<string[]>([])
const items = ref<ChannelApp[]>([])
const payConfigured = ref<string[]>([]) // 已配凭据的模块键
const payChecked_ = ref(false) // 是否成功查到(全局作用域查不了)
const modalOpen = ref(false)
const isEditing = ref(false)
const submitting = ref(false)
function emptyForm(): ChannelApp {
return {
row_id: 0, app_name: '', channel: 0, address: '', status: 1, description: '',
allowskipdevicebinding: false, paychannels: '', enable: true,
}
}
const form = reactive<ChannelApp>(emptyForm())
const groups = computed(() =>
CHANNEL_GROUPS.map((g) => ({
...g,
items: items.value.filter((c) => g.channels.includes(c.channel)).sort((a, b) => a.channel - b.channel),
missing: g.channels.filter((ch) => !items.value.some((c) => c.channel === ch)),
})),
)
function payList(c: ChannelApp): string[] {
return (c.paychannels || '').split(',').map((x) => x.trim()).filter(Boolean)
}
// 某支付方式是否「开了但没配凭据」。查不到配置状态时一律不报警,避免误伤。
function payMissing(key: string): boolean {
if (!payChecked_.value) return false
const mod = PAY_CHANNELS.find((p) => p.key === key)?.module
return !!mod && !payConfigured.value.includes(mod)
}
// 汇总所有渠道里「开了但没配」的支付方式,连带列出是哪几个渠道开的
const payWarnings = computed(() => {
if (!payChecked_.value) return []
const map: Record<string, string[]> = {}
for (const c of items.value) {
if (!c.enable) continue
for (const p of payList(c)) {
if (!payMissing(p)) continue
;(map[p] = map[p] || []).push(channelName(c.channel))
}
}
return Object.keys(map).map((k) => ({ key: k, label: payLabel(k), channels: map[k]! }))
})
// ===== 加载 =====
async function loadScopeApps() {
try {
const list = await consoleApi<Array<{ name: string }>>('products/list', {})
scopeApps.value = (list ?? []).map((p: { name: string }) => p.name).filter(Boolean)
} catch {
scopeApps.value = []
}
}
async function loadItems() {
try {
const d = await webApi<{ items: ChannelApp[] }>('getchannelapps', { app_name: scopeApp.value })
items.value = d?.items ?? []
} catch (e: any) {
error(e?.message ?? '加载失败')
items.value = []
}
}
async function loadPayStatus() {
try {
const d = await webApi<{ configured: string[]; checked: boolean }>('getchannelpaystatus', { app_name: scopeApp.value })
payConfigured.value = d?.configured ?? []
payChecked_.value = !!d?.checked
} catch {
payConfigured.value = []
payChecked_.value = false
}
}
async function loadAll() {
await Promise.all([loadItems(), loadPayStatus()])
}
// ===== 编辑 =====
function startAdd(channel?: number) {
Object.assign(form, emptyForm())
if (typeof channel === 'number') form.channel = channel
isEditing.value = false
modalOpen.value = true
}
function startEdit(c: ChannelApp) {
Object.assign(form, emptyForm(), c)
isEditing.value = true
modalOpen.value = true
}
function closeModal() {
modalOpen.value = false
}
function payChecked(key: string): boolean {
return payList(form).includes(key)
}
function togglePay(key: string) {
const cur = payList(form)
const i = cur.indexOf(key)
if (i >= 0) cur.splice(i, 1)
else cur.push(key)
form.paychannels = cur.join(',')
}
async function save() {
if (!form.channel) { error('请选择分发渠道'); return }
submitting.value = true
try {
await webApi('savechannelapp', { ...form, app_name: scopeApp.value })
success('保存成功')
closeModal()
await loadItems()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
submitting.value = false
}
}
function del() {
if (!window.confirm(`确定删除「${channelName(form.channel)}」这个渠道的配置吗?`)) return
webApi('delchannelapp', { app_name: scopeApp.value, channel: form.channel })
.then(() => { success('已删除'); closeModal(); loadItems() })
.catch((e: any) => error(e?.message ?? '删除失败'))
}
function gotoPayConfig() {
if (!scopeApp.value) { error('请先在上方选择一个应用'); return }
router.push(`/apps/${encodeURIComponent(scopeApp.value)}?tab=modules`)
}
onMounted(async () => {
await loadScopeApps()
await loadAll()
})
// 2026-09-23:「渠道分发」并入「版本与分发」(/appdist),页面本体已拆成
// components/AppReleasePanel.vue 与 components/ChannelAppPanel.vue。
// 这里只留重定向——后台里这个地址被收藏/直接输入的不在少数,直接删文件会变成 404
// (同「会议模板」并入「会议配置」时的处理)。
definePageMeta({ title: '版本与分发' })
onMounted(() => { navigateTo('/appdist', { replace: true }) })
</script>
<style scoped>
.group-tag { font-size: 12px; font-weight: 700; padding: 3px 10px; border-radius: 8px; }
.ch-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
.ch-card { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; padding: 14px 16px; cursor: pointer; transition: all .15s; }
.ch-card:hover { border-color: #c7d2fe; box-shadow: 0 4px 14px rgba(99, 102, 241, .08); }
.ch-card.off { opacity: .55; }
.ch-empty { border-style: dashed; background: #fafbfc; display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 92px; }
.ch-empty:hover { border-color: #c7d2fe; background: #f8fafc; }
.status-badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 6px; white-space: nowrap; }
.flag { font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 5px; background: #f1f5f9; color: #475569; }
.flag-off { background: #fee2e2; color: #b91c1c; }
.pay-pill { font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 6px; background: #eef2ff; color: #4338ca; }
.pay-pill.missing { background: #fef3c7; color: #b45309; }
.pay-warn { background: #fffbeb; border: 1px solid #fde68a; color: #92400e; border-radius: 12px; padding: 12px 14px; margin-bottom: 16px; }
.warn-pill { display: inline-block; margin-right: 8px; padding: 1px 8px; border-radius: 6px; background: #fef3c7; }
.scope-tip { background: #f8fafc; border: 1px solid #eef2f7; color: #64748b; font-size: 12px; border-radius: 10px; padding: 9px 13px; margin-bottom: 16px; }
/* 弹窗 */
.ch-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, .45); z-index: 100; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(2px); padding: 24px; }
.ch-box { background: #fff; border-radius: 18px; padding: 24px; width: 700px; max-width: 96vw; max-height: 90vh; overflow-y: auto; box-shadow: 0 24px 80px rgba(0, 0, 0, .18); }
.scope-chip { font-size: 11px; font-weight: 600; padding: 2px 9px; border-radius: 6px; background: #eef2ff; color: #4338ca; }
.ed-section { padding: 14px 0; border-top: 1px dashed #eef2f7; }
.ed-section:first-of-type { padding-top: 0; border-top: none; }
.ed-section-title { font-size: 11px; font-weight: 700; letter-spacing: .04em; color: #94a3b8; margin-bottom: 10px; }
.ed-label { display: block; font-size: 11px; color: #64748b; margin-bottom: 4px; }
.ed-warn { font-size: 11px; color: #b45309; margin-top: 8px; }
.ed-tip { font-size: 11px; color: #94a3b8; margin-top: 8px; }
.req { color: #dc2626; font-style: normal; }
.link { color: #4f46e5; cursor: pointer; text-decoration: underline; }
.pay-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.pay-item { display: flex; align-items: center; gap: 7px; padding: 8px 10px; border-radius: 9px; border: 1.5px solid #e2e8f0; cursor: pointer; font-size: 13px; color: #475569; transition: all .15s; }
.pay-item:hover { border-color: #c7d2fe; background: #f8fafc; }
.pay-item.checked { border-color: #6366f1; background: #eef2ff; color: #4338ca; }
.pay-miss { font-size: 10px; color: #b45309; background: #fef3c7; padding: 1px 5px; border-radius: 4px; margin-left: auto; }
.ed-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 14px; border-top: 1px solid #eef2f7; }
@media (max-width: 860px) {
.pay-grid { grid-template-columns: repeat(2, 1fr); }
}
</style>

2
apps/admin/app/pages/environments.vue

@ -97,7 +97,7 @@
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">仓库地址</span></label>
<input v-model="form.registry" type="text" class="input input-bordered w-full" placeholder="docker-registry.ideapsound.com" />
<input v-model="form.registry" type="text" class="input input-bordered w-full" placeholder="registry.ymaikj.com" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">Docker 网络</span></label>

12
apps/admin/app/pages/systemconfig.vue

@ -164,16 +164,10 @@ const CATALOG = reactive<CatDef[]>([
{ key: 'bucket', label: 'Bucket', value: '', placeholder: 'yunyan-firmware', mono: true, required: true },
{ key: 'access_key_id', label: 'AccessKey ID', value: '', mono: true, encrypted: true, required: true },
{ key: 'access_key_secret', label: 'AccessKey Secret', value: '', mono: true, encrypted: true, required: true },
{ key: 'domain', label: '自定义域名 (CDN)', value: '', placeholder: 'cdn.yunyan.com', wide: true, hint: '可选,配置后文件下载 URL 走该域名' },
]),
mk('storage_tencent_cos', 'tencent_cos', '腾讯云 COS', 'COS', 'Tencent', '#00a4ff', [
{ key: 'region', label: 'Region', value: '', placeholder: 'ap-guangzhou', mono: true, required: true },
{ key: 'appid', label: 'AppId', value: '', mono: true, required: true },
{ key: 'bucket', label: 'Bucket', value: '', mono: true, required: true },
{ key: 'secret_id', label: 'SecretId', value: '', mono: true, encrypted: true, required: true },
{ key: 'secret_key', label: 'SecretKey', value: '', mono: true, encrypted: true, required: true },
{ key: 'domain', label: '自定义域名 (CDN)', value: '', wide: true, hint: '可选' },
{ key: 'domain', label: '自定义域名 (CDN)', value: '', placeholder: 'oss.ymaikj.com', wide: true, hint: '可选,配置后文件下载 URL 走该域名' },
]),
// 2026-09-23:腾讯云 COS 卡片已删除。服务端读存储配置只认 provider=aliyun_oss
// (comm.LoadAliyunOSSConf),配了 COS 也不会有任何地方读它。
],
},
{

4
apps/admin/app/stores/auth.ts

@ -183,6 +183,10 @@ export const useAuthStore = defineStore('auth', {
// 2026-09-12「应用环境配置」并入「服务与环境配置」(svcconfig):只勾过旧 id 的存量账号
// 不该因为一次合并就失去入口。反向不成立——勾了 svcconfig 本来就包含这一页。
if (pageId === 'svcconfig' && this.access.includes('appconfig')) return true
// 2026-09-23「渠道分发」(channels) 并入「版本与分发」(appversion):只勾过 channels
// 的存量账号不该因为一次合并就失去入口。菜单 id 沿用 appversion 未变,
// 所以勾过 appversion 的账号本来就有权限,不需要反向兼容。
if (pageId === 'appversion' && this.access.includes('channels')) return true
// 管理员未显式配置权限时,按默认权限集放行(除 SaaS 管理 / 系统管理 外全部)。
if (this.identity === IDENTITY_MANAGER && this.access.length === 0) {
return DEFAULT_ADMIN_ACCESS.includes(pageId)

7
apps/admin/app/utils/menus.ts

@ -81,7 +81,11 @@ export const MENU_GROUPS: MenuGroup[] = [
// 版本控制 / 游客显隐控制:按应用配、不分渠道(存 app_release 表)。
// 这两组开关原先埋在「SaaS 管理 → 渠道分发」里按渠道各存一份,同一个开关有 8 处能改,
// 客户端只读一处,出过「后台开了游客登录但 App 里没有」的事故,故拆到这里单独管。
{ id: 'appversion', label: '版本控制', to: '/appversion', icon: 'M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2' },
// 2026-09-23:「渠道分发」并入本页,标签与路由改了,**菜单 id 仍是 appversion** ——
// 存量后台账号的 access 清单里存的是这个串,改 id 等于让这些账号丢入口
// (同「会议模板 → 会议配置」那次的处理)。只勾过旧 id 'channels' 的账号
// 由 auth.ts 的 hasAccess 兼容放行。
{ id: 'appversion', label: '版本与分发', to: '/appdist', icon: 'M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2' },
{ id: 'guestaccess', label: '游客显隐控制', to: '/guestaccess', icon: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z' },
],
},
@ -91,7 +95,6 @@ export const MENU_GROUPS: MenuGroup[] = [
items: [
{ id: 'apps', label: '应用中心', to: '/apps', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
{ id: 'environments', label: '环境节点', to: '/environments', icon: 'M5 3h14a2 2 0 012 2v4a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2zM5 13h14a2 2 0 012 2v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4a2 2 0 012-2zM7 7h.01M7 17h.01' },
{ id: 'channels', label: '渠道分发', to: '/channels', icon: 'M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2' },
],
},
{

2
apps/client/GoogleService-Info.plist

@ -9,7 +9,7 @@
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.saitong.voitrans</string>
<string>com.yimai.eaimar</string>
<key>PROJECT_ID</key>
<string>voitrans</string>
<key>STORAGE_BUCKET</key>

2
apps/client/assets/config/GoogleService-Info.plist

@ -9,7 +9,7 @@
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.saitong.voitrans</string>
<string>com.yimai.eaimar</string>
<key>PROJECT_ID</key>
<string>voitrans</string>
<key>STORAGE_BUCKET</key>

388
apps/client/docs/face_to_face_ble_protocol.md

@ -1,27 +1,34 @@
# 面对面翻译 · 独立 BLE 链路协议(草案 v0.1)
# 面对面翻译 · 双耳独立 BLE 链路协议(草案 v0.2)
> 状态:**待固件确认**。本文只定义协议,不含实现。
> 固件与 App 双方按本文对齐后再各自写代码;有异议的条目在 §14 逐条回复,改动记进 §16 变更记录。
>
> 配套阅读:[bes_earphone_protocol.md](bes_earphone_protocol.md)(现有 SPP 协议,本文与它并存,不替代)
> 配套阅读:[bes_earphone_protocol.md](bes_earphone_protocol.md)(现有经典蓝牙协议,本文与它并存,不替代)
>
> **v0.2 相对 v0.1 的核心变化**:产品形态定为「两个人各戴一只耳机」,TWS 拆成两只独立的 BLE 外设,
> 各自一条链路连 App。v0.1 的「档位 A 主耳中转」与 `TOPO` 自适应字段整体删除。
> 另外吸收了 2026-09-20 ~ 09-23 通话翻译真机定性的一批结论(写类型、调速语义、看门狗、音频路径),见 §16。
---
## 0. 为什么要单开一条 BLE
## 0. 产品形态与为什么要单开 BLE
面对面翻译要求手机同时做四件事:**收左耳麦、收右耳麦、往左耳放译文、往右耳放译文**,四条流内容各不相同。
**形态:A、B 两个人各戴一只耳机,一副耳机拆成两只独立设备,各自一条 BLE 连到同一部手机。**
A 说话 → A 那只耳机的麦上行 → 云端翻译 → 译文只下行到 B 那只耳机;反向同理。
手机只做中枢,不参与收音和放音(手机侧 UI 只显示字幕)。
现有恒玄链路是**一条经典蓝牙 SPP**(RFCOMM,UUID `aeaf5241-5453-7070-7353-445542594c46`),
所有指令与音频挤在这一条上,上行靠 `mode=0x03` 交织、下行靠 `AA 56` 的 84 字节双槽区分方向。
这套结构服务「通话翻译」够用(两路的语义是固定的:本端 mic / 对端 spk),
但面对面的两路是**两个人**,需要:
这要求手机同时做四件事:**收左耳麦、收右耳麦、往左耳放译文、往右耳放译文**,四条流内容各不相同,且:
- 两路上行**同时并发**且可独立起停(A 说话时 B 插话,不能排队);
- 两路上行**同时并发**、可独立起停(A 说话时 B 插话,不能排队);
- 两路下行**可独立寻址**(A 说的话只能进 B 的耳朵);
- 每路带**独立序号**,好判丢包、好做打断。
- 每路带**独立序号**,好判丢包、好做打断;
- 两只耳机之间**不需要同步**:各听各的译文,没有立体声对齐要求。这一点让拆 TWS 变得可行。
在 SPP 的定长双槽结构上硬塞这些语义,会把现有通话翻译的包格式一起改掉。
所以本功能**另起一条 BLE 链路**,与 SPP 完全隔离:进面对面时建链,退出时拆链并恢复原状。
现有恒玄链路是**一条经典蓝牙**(Android 是 SPP/RFCOMM,iOS 是 GATT over BR/EDR,见 §1.1),
所有指令与音频挤在这一条上,上行靠 `mode=0x03` 交织、下行靠 `AA 56` 的 84 字节双槽区分方向。
它服务「通话翻译」够用(两路语义固定:本端 mic / 对端 spk),但**副耳根本没有到手机的链路**,
面对面的两路是两个人、两只耳机,在这条链路上做不出来。所以另起 BLE:进面对面时拆 TWS、两耳各自建链,
退出时拆链并重新组对,恢复原状。
---
@ -29,139 +36,141 @@
| 不动 | 说明 |
|---|---|
| 现有 SPP 全部指令与音频格式 | `AA 51/52/56/57/58/59/5A/5E/5F/65/69/6A`、`BB D1/D2/D9/DA/E5/E6/E7/64/55/68/60/5B` 一律不改 |
| 现有 `AA 66` / `AA 67` 面对面 | 那是**旧版**面对面(手机麦上行 + `AA 69` 单声道下行),保留可用,新模式用新命令号,两者互斥但互不修改 |
| 现有经典蓝牙全部指令与音频格式 | `AA 51/52/56/57/58/59/5A/5E/5F/65/69/6A`、`BB D1/D2/D9/DA/E5/E6/E7/64/55/68/60/5B` 一律不改 |
| 现有 `AA 66` / `AA 67` 面对面 | 那是**旧版**面对面(手机麦上行 + `AA 69` 单声道下行),命令号保留,App 侧当前也没有调用方;新模式用新命令号,两者互斥但互不修改 |
| 通话翻译 / AI 对话 / 录音 / OTA | 全部保持现状;面对面 BLE 与它们**互斥**,不并行 |
| 经典蓝牙配对关系 | 面对面结束后必须回到进入前的状态(见 §11) |
App 侧对应载体:[face_to_face_ble_service.dart](../lib/data/services/face_to_face_ble_service.dart)
(接口已固化,本文补的是它 `TODO(固件)` 里缺的那部分)。
业务层 [translation_controller.dart](../lib/modules/translation/controllers/translation_controller.dart)
的 `FaceToFaceTrigger` / `activeSpeaker` 语义不变。
### 1.1 ⚠️ iOS 上现有链路不是 SPP
---
Android 上现有链路是 SPP(RFCOMM);**iOS 上是 GATT over BR/EDR**(CoreBluetooth API,
但底层是经典蓝牙,靠 `registerForConnectionEvents` 接管系统已配对设备,详见 CLAUDE.md
「iOS 上恒玄走的是 GATT over BR/EDR,不是 BLE」)。这意味着:
## 2. 总体拓扑:两档,协议同一套
- 面对面期间主耳要**同时维护一条 BR/EDR 链路(到手机,控制面)和一条 LE 链路(本协议)**,
副耳只有 LE 链路。能否共存是 §14 Q1 的一部分;
- §6.2 的连接间隔 / PHY / DLE 这些是 LE 概念,**只对本协议的两条新链路成立**,
别拿去套现有链路。
固件按自身能力选一档,**App 侧接口与业务逻辑完全相同**,靠握手里的 `TOPO` 字段自适应。
### 1.2 App 侧载体
### 档位 A —— 单链路(主耳中转)· 建议先落地
v0.1 提到的 `face_to_face_ble_service.dart` **已在 2026-09-09 设备内核重构中删除**,不要再找它。
新链路的落点按现行分层:
```
BLE (1 条)
手机 ────────────────► 主耳 ──耳间私有链路──► 副耳
控制/双路音频 按 EAR 字段分发
```
| 层 | 落点 | 说明 |
|---|---|---|
| LE 链路 + 帧编解码 | `bluetooth_manager` 原生(Swift / Kotlin) | 扫描、连接、GATT、G.722、`BB 90`/`AA 91` 组拆包 |
| 音频 | **原生直连** `azure_speech`(同 `CallPcmBridge` 的接缝写法) | 上行 PCM 不经 Dart;下行译文 PCM 由 `azure_speech` 直接喂回 `bluetooth_manager` |
| 设备契约 | `lib/devices/bes/` 的 `BesDeviceSession` | 声明能力(沿用 `DeviceCapability.faceToFace`),`AudioRoute` 加 `f2fEarL/f2fEarR` |
| 业务 | `translation_controller.dart` 的 `FaceToFaceTrigger.device` 分支 | 只收文本、状态、按键事件;`setActiveSpeaker` 语义不变 |
- 只有主耳做 GATT peripheral,两路音频在同一条 BLE 上用帧头的 `EAR` 字段区分;
- 与现有 SPP 的双路交织同构,固件改动量最小,TWS 主从关系不用拆;
- 代价:副耳多一跳转发(实测应 < 20ms,需固件给实测值);耳间链路带宽要能承载一路上行 + 一路下行。
**理由**:这条链路有实时截止期(每 40ms 一包、四路并发),按 9-21 的口径必须下沉原生,
Dart 只做没有截止期的状态机与展示(见 CLAUDE.md「音频链路继续下沉」)。
### 档位 B —— 双链路(左右耳各自独立)· 目标形态
---
## 2. 拓扑:两只耳机各自独立
```
BLE (2 条,互相独立)
手机 ═══════════════► 左耳
═══════════════► 右耳
手机 ═══════════════► 左耳(EAR=1)
═══════════════► 右耳(EAR=2)
─ ─ ─ ─ ─ ─ ─ ─► 主耳(BR/EDR,现有链路,只走 AA 6B / 6C 控制)
```
- 左右耳各自广播、各自被 App 连接,各跑一条 GATT;
- 进入面对面时固件**解除 TWS 主从**,左右耳各自广播、各自被 App 连接,各跑一条 GATT;
- 时空壶 WT2 Edge 走的就是这条路(其耳机不出现在系统蓝牙列表,全靠 App 内分别连 L/R);
- 延迟最低、两路彻底解耦;代价是要拆 TWS 主从,A2DP/HFP 期间必然中断(见 §14 Q1/Q2)。
> **协议帧在两档下完全一致**。档位 B 时每条链路只应出现自己耳位的 `EAR` 值,
> 收到不属于本耳的包直接丢弃并上报 `ERR_EAR_MISMATCH`。
- 两条链路彻底解耦,延迟最低;代价是**A2DP / HFP 期间必然中断**(见 §11.5 来电处理);
- **每条链路上只应出现自己耳位的 `EAR` 值**。帧里保留 `EAR` 字段是为了防御:
收到不属于本耳的包直接丢弃并上报 `ERR_EAR_MISMATCH`(属 App 侧 bug)。
---
## 3. 完整时序
```
App 耳机(主耳) 副耳
│ │ │
│ ① SPP: AA 6B <ver><cap><topo><token> │ │
│───────────────────────────────────────►│ │
│ │ 切工作模式:停A2DP/HFP│
│ ② SPP: BB EB <ok><topo><earmask>... │ 起 BLE 广播(带 token) │
│◄───────────────────────────────────────│ │
│ │ │
│ ③ BLE 扫描 → 按 token 过滤 → 连接 │ │
│ ④ MTU 交换 / 2M PHY / 订阅通知 │ │
│───────────────────────────────────────►│ │
│ │ │
│ ⑤ BLE: AA 01 HELLO <token><role> │ │
│───────────────────────────────────────►│ │
│ ⑥ BLE: BB 81 HELLO_ACK <ear><fw>... │ │
│◄───────────────────────────────────────│ │
│ ⑦ BLE: AA 02 SESSION_CFG │ │
│───────────────────────────────────────►│ │
│ ⑧ BLE: AA 03 START_STREAM │ │
│───────────────────────────────────────►│ 开双耳拾音 │
│ │ │
│ ⑨ BLE: BB 90 上行音频(EAR=L, seq++) │ │
│◄═══════════════════════════════════════│◄─────────────────────│
│ ⑩ 云端 ASR→翻译→TTS │ │
│ ⑪ BLE: AA 91 下行音频(EAR=R, seq++) │ │
│═══════════════════════════════════════►│─────────────────────►│ 播放
App 主耳(BR/EDR) 左耳(LE) 右耳(LE)
│ ① 经典: AA 6B <ver><cap><token> │ │ │
│─────────────────────────────────────►│ │ │
│ │ 停A2DP/HFP、拆TWS主从 │
│ ② 经典: BB EB <ok><earmask>... │ 两耳各起 LE 广播(带 token) │
│◄─────────────────────────────────────│ │ │
│ ③ LE 扫描 → 按 token 过滤 → 集齐 EAR=1 与 EAR=2 → 分别连接 │
│ ④ 各链路:MTU 交换 / 2M PHY / 订阅通知 │
│─────────────────────────────────────────────────────►│────────────────►│
│ ⑤ LE: AA 01 HELLO <token><role>(每条链路各一次) │ │
│ ⑥ LE: BB 81 HELLO_ACK <ear><fw>... │ │
│ ⑦ LE: AA 02 SESSION_CFG │ │
│ ⑧ LE: AA 03 START_STREAM │ 开拾音 │ 开拾音
│ │ │
│ ⑫ BLE: AA 05 KEEPALIVE (1s 周期) │ │
│◄──────────────────────────────────────►│ │
│ ⑨ LE(左): BB 90 上行音频(EAR=1, seq++) │ │
│◄═════════════════════════════════════════════════════│ │
│ ⑩ 云端 AST(端到端) │ │
│ ⑪ LE(右): AA 91 下行音频(EAR=2, seq++) │ │
│═══════════════════════════════════════════════════════════════════════►│ 播放
│ │ │
│ ⑬ BLE: AA 04 STOP_STREAM │ │
│───────────────────────────────────────►│ │
│ ⑭ BLE 主动断开 │ │
│ ⑮ SPP: AA 6C 退出面对面 │ │
│───────────────────────────────────────►│ 停广播/恢复TWS/恢复A2DP│
│ ⑯ SPP: BB EC <ok> │ │
│◄───────────────────────────────────────│ │
│ ⑫ LE: AA 04 STOP_STREAM(每条链路) │ │
│ ⑬ LE: AA 06 EXIT(每条链路,见 §7)→ 耳机主动断开 │ │
│ ⑭ 经典: AA 6C 退出面对面(兜底,幂等) │ 恢复TWS/恢复A2DP │
│─────────────────────────────────────►│ │ │
│ ⑮ 经典: BB EC <ok> │ │ │
│◄─────────────────────────────────────│ │ │
```
**为什么入口放在 SPP 而不是直接扫 BLE**:耳机平时不广播这个服务(省电、也避免被无关 App 扫到),
只有收到 `AA 6B` 才开始广播,且广播里带上 App 给的 token。
**为什么入口放在经典链路而不是直接扫 BLE**:耳机平时不广播这个服务(省电、也避免被无关 App 扫到),
只有收到 `AA 6B` 才拆主从、开始广播,且广播里带上 App 给的 token。
这样「扫描时连到隔壁桌同型号耳机」这个问题在协议层就不成立(见 §5)。
**为什么退出要有两条路**:拆主从之后经典链路是否还活着,取决于固件(§14 Q1)。
所以 LE 上必须有自己的 `EXIT`(⑬),经典链路的 `AA 6C`(⑭)只是兜底与清状态。
任何一条到了,固件都要走 §11.2 的恢复清单。
---
## 4. 阶段一 · SPP 侧模式切换
## 4. 阶段一 · 经典链路侧模式切换
沿用现有 SPP 帧格式:`AA CMD [LEN] [payload]` / `BB CMD LEN [payload]`。
沿用现有帧格式:`AA CMD [LEN] [payload]` / `BB CMD LEN [payload]`。
命令号选在模式控制段(`0x5x`/`0x6x`)内的空位,响应码遵守本机固件 `响应 = 请求 | 0x80` 的规律。
> ⚠️ **命令号是 (产品, 固件版本) 的函数**(CLAUDE.md 同名一节)。下表与 `6B/6C` 只针对 **Echo-one** 这条固件线,
> 换产品要重新核对。
>
> 已占用下行:`06 09 0B 0C 0E 0F 10 20 51 52 56 57 58 59 5A 5E 5F 65 66 67 69 6A`
> 已占用上行:`02 03 04 07 0A 18 55 5B 60 61 64 68 86 89 8C 8E 90 D1 D2 D6 D9 DA DE DF E5 E6 E7 E9`
> 本协议新增 `6B/6C` ↔ `EB/EC`,与上表均无冲突。
>
> ⚠️ App 侧的 `BesBluetoothService.probeCommands()` 目前跳过的是 `0x51~0x6A`(会切工作模式的段),
> **落地本协议时必须把跳过区间扩到 `0x6C`** —— 否则扫命令时会把耳机扫进面对面模式,
> 且扫描进程不会发 `AA 6C` 退出,只能靠看门狗兜底。
> ⚠️ App 侧 `BesCmd.switchesMode()` 与 `BesBluetoothService.probeCommands()` 目前跳过的是 `0x51~0x6A`,
> **落地本协议时必须把区间扩到 `0x6C`** —— 否则扫命令时会把耳机扫进面对面模式并拆掉 TWS,
> 且扫描进程不会发退出,只能靠看门狗兜底。
### 4.1 `AA 6B` 进入面对面 BLE 模式
```
AA 6B 07 <PROTO_VER> <CAP> <TOPO_REQ> <TOKEN 4B>
AA 6B 06 <PROTO_VER> <CAP> <TOKEN 4B>
```
| 字段 | 长度 | 取值 |
|---|---|---|
| `PROTO_VER` | 1 | 本文档版本,首版 `0x01` |
| `PROTO_VER` | 1 | 本文档版本,`0x02` |
| `CAP` | 1 | App 支持的编码位图:bit0 G.722、bit1 mSBC、bit2 Opus(预留)。首版固定 `0x01` |
| `TOPO_REQ` | 1 | `0x00`=由耳机决定(推荐)、`0x01`=要求单链路、`0x02`=要求双链路 |
| `TOKEN` | 4 | App 生成的随机会话标识,每次进入重新生成,用于广播过滤 |
| `TOKEN` | 4 | App 生成的随机会话标识(`crypto` 级随机),每次进入重新生成,用于广播过滤 |
(v0.1 的 `TOPO_REQ` 已删除:形态只有一种。)
### 4.2 `BB EB` 响应
```
BB EB 08 <RESULT> <TOPO> <EAR_MASK> <PROTO_VER> <CODEC> <FRAME_MS> <FW_MINOR>
BB EB 07 <RESULT> <EAR_MASK> <PROTO_VER> <CODEC> <FRAME_MS> <FW_MINOR> <CLASSIC_KEEP>
```
| 字段 | 取值 |
|---|---|
| `RESULT` | 见 §12 错误码,`0x00`=已进入并开始广播 |
| `TOPO` | `0x01`=单链路(仅主耳广播)、`0x02`=双链路(左右耳各自广播) |
| `EAR_MASK` | bit0=左耳可用、bit1=右耳可用。**单耳在盒时只置一位**,App 据此提示用户 |
| `RESULT` | 见 §12 错误码,`0x00`=已拆主从并开始广播 |
| `EAR_MASK` | bit0=左耳可用、bit1=右耳可用。**两位必须都置**,否则固件应直接回 `ERR_EAR_UNAVAILABLE`(面对面需要双耳,单耳在盒/未佩戴不进入) |
| `CODEC` | 实际采用的编码,`0x01`=G.722 |
| `FRAME_MS` | 每包承载的音频时长,`20` 或 `40`(建议 40) |
| `FW_MINOR` | 固件的本协议实现小版本,便于后续差异化兼容 |
| `CLASSIC_KEEP` | `0x01`=拆主从后经典链路仍保持;`0x00`=经典链路将断开,App 退出只能走 LE `EXIT`(§7) |
### 4.3 `AA 6C` 退出
@ -176,12 +185,12 @@ App 在异常恢复路径上会无条件补发一次(见 §11)。
## 5. 阶段二 · BLE 广播与发现
### 5.1 广播内容
### 5.1 广播内容(左右耳各自广播)
| 字段 | 内容 |
|---|---|
| Flags | LE General Discoverable + BR/EDR Not Supported |
| Local Name | `<产品名>-F2F`,左右耳**可以同名**(靠 MSD 区分,不靠名字) |
| Local Name | `<产品名>-F2F`,左右耳**同名**(靠 MSD 区分,不靠名字) |
| Service UUID (16B) | 见 §6.1 |
| Manufacturer Specific Data | 见下 |
@ -195,7 +204,7 @@ App 在异常恢复路径上会无条件补发一次(见 §11)。
|---|---|
| `MAGIC` | 固定 `F2 F2`,快速筛掉无关广播 |
| `VER` | 协议版本,与 `AA 6B` 的 `PROTO_VER` 一致 |
| `EAR` | `0x01`=左耳、`0x02`=右耳、`0x03`=主耳(单链路档,代表双耳) |
| `EAR` | `0x01`=左耳、`0x02`=右耳 |
| `TOKEN` | **原样回显 `AA 6B` 里那 4 字节** |
| `BATT` | 该耳电量 0~100,低 7 位;最高位为充电位(与 `BB 0A` 同口径) |
@ -204,14 +213,17 @@ App 在异常恢复路径上会无条件补发一次(见 §11)。
1. 只接受 `MAGIC` 命中的广播;
2. **`TOKEN` 必须与本次 `AA 6B` 下发的完全一致,否则一律忽略** ——
这是防止连到旁边同型号耳机的唯一手段,会场/展会场景必然会同时扫到多副;
3. 档位 B 下需集齐 `EAR=0x01` 与 `0x02` 两个广播才算就绪;只集到一个时按 §14 Q9 的降级策略处理;
4. 扫描超时 **15s**:超时后 App 发 `AA 6C` 回滚,并向用户报「耳机未就绪」。
3. **必须集齐 `EAR=0x01` 与 `0x02` 两个广播才开始连接**;15s 内只集到一个 → 按 §5.2 第 4 条回滚,
提示「请确认双耳都已取出并佩戴」。不做单耳降级:单耳面对面在产品上不成立;
4. 扫描超时 **15s**:超时后 App 发 `AA 6C` 回滚,并向用户报「耳机未就绪」;
5. **扫描必须在 App 前台完成**:iOS 后台扫描会截断广播数据,MSD 里的 TOKEN 拿不到,第 2 条就无从判定。
扫描期间切后台 → 视为超时回滚。
### 5.3 广播参数与耳机侧超时
- 建链期快速广播:间隔 30~50ms,持续 30s;
- **30s 内无人连接,耳机自动退出面对面模式并恢复常态**(第一道看门狗,见 §11);
- 连接建立后立即停止广播(档位 B 下各耳独立停)。
- **30s 内无人连接,该耳自动退出面对面模式**;两耳都退出后重新组对、恢复常态(第一道看门狗,见 §11);
- 连接建立后立即停止广播(各耳独立停)。
### 5.4 iOS 注意
@ -221,11 +233,11 @@ iOS 拿不到 BLE 设备的 MAC(CoreBluetooth 只给每台手机各不相同
---
## 6. 阶段三 · GATT 服务与特征
## 6. 阶段三 · GATT 服务与特征(两耳完全相同)
### 6.1 UUID(**待固件分配,下表为占位**)
沿用现有 SPP UUID 的 128-bit 风格,基址建议:`AEAF5F32-XXXX-4C4B-A100-000000000000`
沿用现有 UUID 的 128-bit 风格,基址建议:`AEAF5F32-XXXX-4C4B-A100-000000000000`
| 项 | 占位 UUID | 属性 | 方向 |
|---|---|---|---|
@ -237,46 +249,58 @@ iOS 拿不到 BLE 设备的 MAC(CoreBluetooth 只给每台手机各不相同
**控制与音频分开成 4 个特征,不合并**:音频是每 40ms 一包的高频流,
与控制帧共用一个特征时,控制消息(尤其是「打断」和 `STOP_STREAM`)会排在音频队列后面,
表现为「点了停止还在播」。分开后两条队列各自独立。
表现为「点了停止还在播」。9-21 在通话翻译上实测过同一个坑(控制命令排到积压音频后面 → 电量/版本全查不到),
分开后两条队列各自独立。
### 6.2 链路参数要求
### 6.2 链路参数要求(每条 LE 链路各自满足)
| 项 | 要求 | 理由 |
|---|---|---|
| ATT MTU | ≥ 185,建议 247 | 40ms 聚合包 = 6B 头 + 80B 音频 = 86B;留余量给未来的 Opus/更长聚合 |
| PHY | 优先 2M,回退 1M | 四路并发时降低空口占用 |
| PHY | 优先 2M,回退 1M | 两条链路并发时降低空口占用 |
| 连接间隔 | 15~30ms | 大于 40ms 会让下行音频出现可闻的断续 |
| Slave latency | 0 | 音频流不允许休眠跳包 |
| 数据长度扩展 (DLE) | 开 | 否则 86B 包会被拆成多个 27B 空口包,吞吐骤降 |
| Supervision timeout | 2~4s | 它就是 §11.1 的看门狗 2:App 被杀后链路在此时间内必断 |
### 6.3 App 侧写入规则(强制,9-21 真机定性)
1. `AUDIO_DOWN` 与 `CTRL_TX` **一律 Write Without Response**。带应答写每包要等 ATT 层 ACK 才能发下一包,
50 包/秒的实时流必然追不上;
2. iOS 的 `canSendWriteWithoutResponse` **只能作观测,不能作门槛**(拿它当闸门会死锁,见 CLAUDE.md「GATT 写入三条铁律」);
3. 控制命令**绝不排队**在音频后面,直发。
---
## 7. 阶段四 · BLE 链路握手
## 7. 阶段四 · BLE 链路握手(每条链路各自一遍)
BLE 链路上的操作码空间**与 SPP 独立**(不同链路,不会混淆)。
BLE 链路上的操作码空间**与经典链路独立**(不同链路,不会混淆)。
帧格式同样是 `AA <OP> <LEN> <payload>` / `BB <OP|0x80> <LEN> <payload>`,`LEN` 为 payload 字节数。
| 步骤 | 帧 | payload | 说明 |
|---|---|---|---|
| ① HELLO | `AA 01` | `<VER 1B><TOKEN 4B><ROLE 1B>` | `ROLE`:`0x01`=App。token 再校验一次,防止连错 |
| ② HELLO_ACK | `BB 81` | `<RESULT><EAR><FW 3B><CODEC><FRAME_MS><MTU_OK>` | `EAR` 必须与广播里一致;`MTU_OK`=0 时 App 降级到 20ms 单帧包 |
| ③ SESSION_CFG | `AA 02` | `<UP_ENABLE 1B><VAD_MODE 1B><DOWN_GAIN 1B><TRIGGER 1B>` | 见下 |
| ③ SESSION_CFG | `AA 02` | `<VAD_MODE 1B><DOWN_GAIN 1B><TRIGGER 1B>` | 见下 |
| ④ CFG_ACK | `BB 82` | `<RESULT>` | |
| ⑤ START_STREAM | `AA 03` | `<EAR_MASK 1B>` | 指定开哪几路上行;档位 B 下固定为本耳 |
| ⑥ START_ACK | `BB 83` | `<RESULT>` | 回 `0x00` 后耳机开始推 `BB 90` |
| ⑤ START_STREAM | `AA 03` | — | 开本耳上行。回 `0x00` 后耳机开始推 `BB 90` |
| ⑥ START_ACK | `BB 83` | `<RESULT>` | |
| ⑦ STOP_STREAM | `AA 04` | — | 停上行,链路保留(切换语言/暂停时用,不必拆链) |
| ⑧ KEEPALIVE | `AA 05` | `<SEQ 1B>` | App 每 **1s** 发一次 |
| ⑨ KEEPALIVE_ACK | `BB 85` | `<SEQ 1B><LOSS 1B>` | `LOSS`=耳机侧统计的下行丢包率(%),用于诊断 |
| ⑧ KEEPALIVE | `AA 05` | `<SEQ 1B>` | App 每 **5s** 发一次,**只用于诊断**(§11.1),耳机不据此断链 |
| ⑨ KEEPALIVE_ACK | `BB 85` | `<SEQ 1B><UP_LOSS 1B><DOWN_LOSS 1B>` | 丢包率(%),写日志 |
| ⑩ EXIT | `AA 06` | — | 本耳退出面对面:停流、断开本 LE 连接、走 §11.2 恢复。两耳都收到后重新组对 |
| ⑪ EXIT_ACK | `BB 86` | `<RESULT>` | 回完再断 |
**`SESSION_CFG` 字段**
| 字段 | 取值 |
|---|---|
| `UP_ENABLE` | bit0=左耳上行、bit1=右耳上行 |
| `VAD_MODE` | `0x00`=**常发**(不做静音抑制,默认)、`0x01`=静音抑制(耳机侧 VAD 判静音则不发包) |
| `DOWN_GAIN` | 下行播放增益 0~100,默认 80 |
| `TRIGGER` | `0x00`=免按键(连续拾音)、`0x01`=按住说话(按键事件驱动,对应 App 的 `FaceToFaceTrigger.device`) |
(v0.1 的 `UP_ENABLE` 位图已删除:每条链路只有本耳一路,开关就是 `START/STOP_STREAM`。)
> ⚠️ `VAD_MODE` 默认 `0x00` 是刻意的:断句策略在 App/云端侧,固件 VAD 一旦判早就会把一句话切碎,
> 每段各翻一次,效果比多传点静音差得多。同传模式下已经踩过这个坑
> (见 CLAUDE.md「面对面 / 同声翻译:延迟构成」第 1 条)。省电需求确认后再开 `0x01`。
@ -291,15 +315,18 @@ BB 90 <LEN> <EAR|FLAGS 1B> <SEQ 2B LE> <PAYLOAD…>
| 字段 | 说明 |
|---|---|
| `EAR` | 低 4 位:`0x1`=左耳、`0x2`=右耳 |
| `FLAGS` | 高 4 位:bit4 `VAD_ACTIVE`(本包含人声)、bit5 `SEG_BEGIN`(一段发声起始)、bit6 `SEG_END`(结束)、bit7 保留 |
| `EAR` | 低 4 位:`0x1`=左耳、`0x2`=右耳(必须等于本链路耳位) |
| `FLAGS` | 高 4 位:bit4 `VAD_ACTIVE`(本包含人声)、bit5 `SEG_BEGIN`(一段发声起始)、bit6 `SEG_END`(结束)、**bit7 `OWN_VOICE`**(骨传导/VPU 判定为**佩戴者本人**在说话;固件没有该传感器时恒为 0) |
| `SEQ` | **每耳独立**自增,回绕 `0xFFFF`。App 据此算丢包,不做重传 |
| `PAYLOAD` | `N × 40B` G.722 码流。`FRAME_MS=40` 时 N=2,`=20` 时 N=1 |
- 编码:**G.722**,16kHz / 16bit / 单声道,一帧 640B PCM(320 样本 = 20ms)→ 40B 码流。
选它是因为现有原生插件的 `G722Codec` 编解码器两端都已落地、已验证,不引入新依赖。
- `SEG_BEGIN` / `SEG_END` 是**耳机给的提示**,不是命令:App 可以用它抢跑一次断句,
但最终断句仍以云端 ASR 为准(`VAD_MODE=0x00` 时耳机照发不误)。
但最终断句仍以云端为准(`VAD_MODE=0x00` 时耳机照发不误)。
- **`OWN_VOICE` 是面对面串音抑制的关键**:两个人面对面坐着,A 的耳机麦一样能收到 B 的声音。
有这一位,App 可以在 `OWN_VOICE=0` 时不把这一路送去识别(或送去但标低权重)。
没有它只能靠云端两路对比,效果差一档(§14 Q6)。
- 丢包**不重传**:语音流重传只会加大延迟。App 检测到 seq 跳变时补静音帧维持时基。
---
@ -312,7 +339,7 @@ AA 91 <LEN> <EAR|FLAGS 1B> <SEQ 2B LE> <PAYLOAD…>
| 字段 | 说明 |
|---|---|
| `EAR` | 低 4 位:这包该播给哪只耳。档位 A 由主耳按此转发;档位 B 下与本链路耳位不符则丢弃并回 `ERR_EAR_MISMATCH` |
| `EAR` | 低 4 位:这包该播给哪只耳。与本链路耳位不符则丢弃并回 `ERR_EAR_MISMATCH` |
| `FLAGS` | bit4 `PLAY_BEGIN`(一段译文开始,耳机可据此压低本地音/给提示音)、bit5 `PLAY_END`(结束,可恢复)、bit6 `FLUSH`(**打断**:丢弃本耳未播完的缓冲,立即播本包) |
| `SEQ` | 每耳独立自增 |
| `PAYLOAD` | 同上行 |
@ -320,10 +347,19 @@ AA 91 <LEN> <EAR|FLAGS 1B> <SEQ 2B LE> <PAYLOAD…>
- **打断(`FLUSH`)是面对面必需的**:对方还在听上一句译文时新一句已经翻好,
没有 flush 就会越积越多、译文与对话彻底脱节。App 在每段译文首包置 `PLAY_BEGIN`,
需要抢播时同时置 `FLUSH`。
- **不需要静音填充**。这点与 SPP 的 `AA 56` 84 字节双槽不同:那是定长双槽包,空槽必须填静音帧;
- **积压追赶不靠 FLUSH 一种手段**。译文是云端突发产出、播放只能 1 倍速,积压必然出现。
App 侧**直接复用 `CallTranslationDownlink` 的追赶器**(裁静音 + WSOLA 变速,9-22 已在通话翻译落地并实测),
只换包头与 EAR;`FLUSH` 只留给「新一句到了、上一句还没播完」这种语义上该丢的场景。
- **不需要静音填充**。这点与经典链路 `AA 56` 84 字节双槽不同:那是定长双槽包,空槽必须填静音帧;
BLE 是包交换,没数据就不发包,耳机侧靠 `SEQ` 与到达时间维持时基即可。
- **节流**:App 按 `FRAME_MS` 的实时速率发送。耳机可用 `BB 86 <interval_ms>` 请求改节奏
(语义对齐现有 SPP 的 `0xD6/0xE9` 调速),合法值 `16 / 20 / 25 / 40`。
但**起播前先垫 4 包编码静音**再发真音频(9-23 定性:从空缓冲起播必出杂音),
一段结束后再续 500ms 静音才停发。
- **节奏调整:按包,不按速率。** 耳机用 `BB 88 <ADJ int8>` 请求:`+1`=缓冲低一包、请立即补一包;
`-1`=缓冲高一包、请扣一包;`0`=缓冲已空(App 视为起播,重新垫静音)。
⚠️ v0.1 写的「`interval_ms` 16/20/25/40 对齐 `0xD6/0xE9`」是对 `D6` 的误读——
9-23 真机定性 `D6` 的 `01` 只持续 20ms 就回 `02`,语义是「补一包」不是「改速率」。新协议直接按补/扣包定义,别再重现这个坑。
- App 发包**定时器必须固定节拍、永不重排**(iOS `DispatchSourceTimer` / Android `scheduleAtFixedRate`),
编码与写入耗时不能叠进周期(9-23 第 1 条)。
- 缓冲:耳机侧建议 60~120ms 抖动缓冲;溢出时**丢最旧的**并上报 `EVT_BUF_OVERFLOW`,不要阻塞。
---
@ -336,12 +372,12 @@ BB 87 <LEN> <EVENT 1B> <PARAM…>
| `EVENT` | 含义 | PARAM | App 侧对应 |
|---|---|---|---|
| `0x01` | 按键 | `<EAR><KEY_CODE><ACTION>`,ACTION:`0x01`按下 / `0x02`抬起 / `0x03`单击 / `0x04`双击 | **按住说话**:左耳按下→`activeSpeaker=1`、右耳按下→`=2`、抬起→`=0`。语义已在 `translation_controller` 落地,固件只需如实上报 |
| `0x01` | 按键 | `<EAR><KEY_CODE><ACTION>`,ACTION:`0x01`按下 / `0x02`抬起 / `0x03`单击 / `0x04`双击 | **按住说话**:左耳按下→`activeSpeaker=1`、右耳按下→`=2`、抬起→`=0`。语义已在 `translation_controller.setActiveSpeaker` 落地,固件只需如实上报 |
| `0x02` | 佩戴状态 | `<EAR><STATE>`,STATE:`0x00`摘下 / `0x01`戴上 / `0x02`入盒 | 摘下/入盒 → App 暂停该路,UI 提示 |
| `0x03` | 电量 | `<EAR><LEVEL>`,最高位为充电位 | 与 `BB 0A` 同口径 |
| `0x04` | 链路统计 | `<EAR><UP_LOSS%><DOWN_LOSS%>` | 每 10s 一次,仅用于诊断日志 |
| `0x05` | 异常 | `<CODE>`,见 §12 | 缓冲溢出、编码失败等 |
| `0x06` | 耳机请求退出 | `<REASON>` | 用户在耳机上操作或电量过低;App 收到后走正常退出流程 |
| `0x06` | 耳机请求退出 | `<REASON>`:`0x01`用户操作 / `0x02`电量过低 / `0x03`**来电** / `0x04`对耳失联 | App 收到后走正常退出流程;来电见 §11.5 |
---
@ -349,34 +385,37 @@ BB 87 <LEN> <EVENT 1B> <PARAM…>
功能结束后**必须完整回到进入前的状态**。这一节的每一条都是验收项。
### 11.1 三层看门狗
### 11.1 看门狗
| 层 | 条件 | 动作 | 责任方 |
|---|---|---|---|
| 1 | 开始广播后 **30s** 无 BLE 连接 | 停广播、退出面对面模式、恢复常态、SPP 主动上报 `BB EC 00` | 固件 |
| 2 | BLE 已连接但 **5s** 未收到 `AA 05` KEEPALIVE | 断 BLE、退出面对面模式、恢复常态 | 固件 |
| 3 | BLE 意外断开 / App 侧异常 | 立即补发 SPP `AA 6C`;SPP 也不可用时,**下次 SPP 连上后先发一次 `AA 6C`** 清状态 | App |
> 第 2 层是本协议里最重要的一条。App 被系统杀掉、崩溃、用户强退时,
> 没有任何机会发退出指令;缺了它耳机会永远卡在面对面模式,用户表现为
> 「耳机连着但放不了音乐、也接不了电话」,且看不出原因。
| 1 | 开始广播后 **30s** 无 LE 连接 | 该耳退出面对面;两耳都退出后重新组对、恢复常态、经典链路上报 `BB EC 00` | 固件 |
| 2 | **LE 连接断开**(supervision timeout 2~4s,§6.2) | 该耳立即退出面对面;等待对耳 ≤5s 后重新组对 | 固件 |
| 3 | App 侧异常 / 主动退出 | 每条链路发 `AA 06 EXIT`;再补发经典链路 `AA 6C`;经典链路也不可用时,**下次连上后先发一次 `AA 6C`** 清状态 | App |
> **第 2 层是本协议里最重要的一条,且刻意不靠 keepalive。** App 被系统杀掉、崩溃、用户强退时,
> 没有任何机会发退出指令;但 LE 连接本身会在 supervision timeout 内必然断开,固件收到断连事件即可恢复。
> v0.1 的「5s 没收到 keepalive 就断链」删掉了:keepalive 若由 App 定时器发,iOS 锁屏后定时器一停就会被耳机踢出,
> 把一个正常场景做成了故障;而链路断开这个信号不依赖任何定时器。
> 缺了第 2 层耳机会永远卡在面对面模式,用户表现为「耳机连着但放不了音乐、也接不了电话」,且看不出原因。
> (同类教训见 CLAUDE.md 里 BES OTA 的 90s 看门狗一节。)
### 11.2 固件的恢复清单
收到 `AA 6C`、或任一看门狗触发时,**逐项恢复**:
收到 `AA 06 EXIT` / `AA 6C`、或任一看门狗触发时,**逐项恢复**:
1. 停止 BLE 广播,断开本协议的 GATT 连接;
2. 恢复 TWS 主从与耳间同步(档位 B 拆过主从时);
1. 停止 LE 广播,断开本协议的 GATT 连接;
2. **两耳重新组对,恢复 TWS 主从与耳间同步**;
3. 恢复 A2DP / HFP —— 音乐能播、来电能接;
4. 恢复按键功能映射到用户原设置(面对面期间若临时改过键位);
5. 恢复 ANC / 通透模式到进入前的档位;
6. 恢复麦克风通路与增益;
7. SPP 回 `BB EC 00`(看门狗触发时为主动上报)。
7. 经典链路回 `BB EC 00`(看门狗触发时为主动上报);`CLASSIC_KEEP=0` 的固件在重新组对后**主动回连**手机的经典链路。
### 11.3 App 的恢复动作
- 退出 BLE 模式后不重连 SPP:SPP 链路全程未断,`BesBluetoothService` 状态不受影响;
- `CLASSIC_KEEP=1` 时经典链路全程未断,`BesBluetoothService` 状态不受影响;
`=0` 时等固件回连,期间设备页显示「恢复中」,不当成掉线报错;
- 恢复完成前不允许再次进入面对面(防止半状态叠加);
- UI 上「结束」按钮必须**先返回、后台跑收尾**,不要 await 整条退出链
(录音页踩过这个坑,见 CLAUDE.md「录音页退不出去」)。
@ -385,6 +424,16 @@ BB 87 <LEN> <EVENT 1B> <PARAM…>
面对面 BLE 模式与下列功能**同一时刻只能有一个**,冲突时 `AA 6B` 回 `ERR_BUSY`:
通话翻译(`AA 51`)、AI 对话(`0x64`)、主麦录音(`AA 59`)、媒体录音(`AA 5E`)、OTA、旧版面对面(`AA 66`)。
App 侧同样在 `DeviceConnection.isBlockedByCall` 这一层拦,双保险。
### 11.5 单耳失联与来电
- **单耳 LE 断开**(走远、没电):固件该耳退出并等待对耳;App 侧另一耳仍连着,
**停掉该耳的上行与对耳的下行**(一方听不到了,继续翻译没有意义),提示「左/右耳已断开」,
10s 内重扫同 token 尝试回连,超时走完整退出。
- **来电**:TWS 已拆、HFP 已停,来电无法在耳机上接。固件收到系统来电 → 上报 `EVT 0x06 REASON=0x03` →
App 立即走退出流程并提示「有来电,面对面翻译已结束」;固件恢复 HFP 后由系统接管来电。
⚠️ 这一步的时序(振铃到 HFP 恢复要多久)是 §14 Q2 的实测项,决定用户会不会漏接。
---
@ -395,7 +444,7 @@ BB 87 <LEN> <EVENT 1B> <PARAM…>
| `0x00` | `OK` | 成功 | — |
| `0x01` | `ERR_UNSUPPORTED` | 固件不支持本协议或版本过低 | 提示「耳机固件需升级」,引导 OTA |
| `0x02` | `ERR_BUSY` | 正在通话/AI/录音/OTA | 提示「请先结束当前功能」 |
| `0x03` | `ERR_EAR_UNAVAILABLE` | 单耳或双耳不可用(在盒/未佩戴) | 提示「请取出并佩戴双耳」 |
| `0x03` | `ERR_EAR_UNAVAILABLE` | 任一耳不可用(在盒/未佩戴/没电) | 提示「请取出并佩戴双耳」 |
| `0x04` | `ERR_TOKEN_MISMATCH` | token 校验失败 | 静默重试一次,仍失败则回滚 |
| `0x05` | `ERR_BLE_RESOURCE` | BLE 资源不足(连接数/内存) | 提示重试 |
| `0x06` | `ERR_CODEC` | 编码不支持或初始化失败 | 记日志,回滚 |
@ -403,25 +452,28 @@ BB 87 <LEN> <EVENT 1B> <PARAM…>
| `0x08` | `ERR_EAR_MISMATCH` | 下行包耳位与链路不符 | 记日志(属 App 侧 bug) |
| `0x09` | `EVT_BUF_OVERFLOW` | 下行缓冲溢出 | 记日志,App 降速 |
| `0x0A` | `ERR_INTERNAL` | 固件内部错误 | 回滚并提示 |
| `0x0B` | `ERR_TWS_SPLIT` | 拆主从失败 / 对耳不应答 | 回滚并提示「请把双耳放回盒中再取出重试」 |
---
## 13. 带宽与延迟预算
**带宽**(G.722 @16kHz = 16 kbps/路)
**带宽**(G.722 @16kHz = 16 kbps/路,**每条链路各承担一上一下**)
| 流 | 路数 | 小计 |
|---|---|---|
| 上行(左耳 + 右耳) | 2 | 32 kbps |
| 下行(左耳 + 右耳) | 2 | 32 kbps |
| 控制 + keepalive | — | < 2 kbps |
| **合计** | | **≈ 66 kbps** |
| 每条 LE 链路 | 小计 |
|---|---|
| 上行 1 路 | 16 kbps |
| 下行 1 路 | 16 kbps |
| 控制 + keepalive | < 1 kbps |
| **单链路合计** | **≈ 33 kbps** |
40ms 聚合时每包 86 字节,四条流合计每 40ms 4 包 ≈ 8.6 KB/s。
BLE 1M PHY 在 30ms 连接间隔下的实测可用吞吐通常在 100~300 kbps,**余量充足**;
开 2M PHY + DLE 后更宽松。档位 B 下两条链路各承担一半,压力更小。
40ms 聚合时每包 86 字节,单链路每 40ms 2 包 ≈ 4.3 KB/s。
BLE 1M PHY 在 30ms 连接间隔下的实测可用吞吐通常在 100~300 kbps,**余量充足**;开 2M PHY + DLE 后更宽松。
> 对照:若直接传 16k/16bit 裸 PCM,单路即 256 kbps,四路 1 Mbps —— BLE 上不可行。
> ⚠️ 手机侧是**两条 LE 连接并发**,再加主耳可能还挂着一条 BR/EDR,三条链路共用手机一根天线的时隙。
> 单链路余量充足不等于三链路共存余量充足,§14 Q4 要固件给**共存下**的实测数,不是单链路理论值。
>
> 对照:若直接传 16k/16bit 裸 PCM,单路即 256 kbps —— BLE 上不可行。
> **所以下行/上行都必须是编码后码流,不能传裸 PCM。**
**延迟预算**(单向,从 A 开口到 B 听到)
@ -430,13 +482,17 @@ BLE 1M PHY 在 30ms 连接间隔下的实测可用吞吐通常在 100~300 kbps
|---|---|---|
| 耳机采集 + 编码 | 20~40ms | 一个聚合包 |
| BLE 上行 | 30~60ms | 含连接间隔与重传 |
| 档位 A 的耳间转发 | +10~20ms | 仅档位 A、且仅下行侧 |
| 云端 ASR→翻译→TTS | **800~2500ms** | 大头。端到端模型可压到 ~800ms,三段串行则 2s+ |
| 云端翻译 | **800ms(端到端)/ 2000ms+(三段串行)** | 大头,见下 |
| BLE 下行 | 30~60ms | |
| 解码 + 抖动缓冲 + 播放 | 60~120ms | |
| **合计** | **≈ 1.0~2.8s** | |
| **合计** | **≈ 1.0 / 2.3s** | |
> 蓝牙链路只占其中 100~250ms。**优化重心在云端那一段**,不要在协议上过度抠 10ms。
>
> ⚠️ **云端走端到端是本功能的前置工作,不是可选优化。** 面对面当前是 ASR → HTTP 机器翻译 → TTS 三段串行
> (CLAUDE.md「面对面 / 同声翻译:延迟构成」),2s+ 的等待配上按住说话,体验不成立。
> 阿里端到端 AST 的 `pushExternalAudio(leg:)` 已现成,面对面的两只耳机恰好与 AST 的 leg A / leg B 同构:
> 左耳上行进 leg A、译文回右耳;右耳上行进 leg B、译文回左耳。通话翻译那套原生桥(`BesCallPcmBridge`)可直接复用。
---
@ -446,20 +502,22 @@ BLE 1M PHY 在 30ms 连接间隔下的实测可用吞吐通常在 100~300 kbps
| # | 问题 | 影响 |
|---|---|---|
| Q1 | 能否做**档位 B(双链路)**?拆开 TWS 主从后,两只耳机还能各自独立跑 GATT 并保持同步吗? | 决定最终形态;不能则先做档位 A |
| Q2 | 面对面模式下 **A2DP / HFP 是否必须中断**?若中断,来电时如何处理(自动退出面对面?) | 决定 §11 恢复清单与用户提示文案 |
| Q1 | **拆 TWS 主从后**,两只耳机能否各自独立跑 GATT peripheral?拆开后**主耳到手机的经典链路能否保持**(`CLASSIC_KEEP`)?iOS 上那条是 BR/EDR GATT,与两条 LE 共存有没有限制? | 决定退出通道(§3 ⑬⑭)与 §11.3 |
| Q2 | 面对面模式下 A2DP / HFP 必然中断。**来电时**从振铃到 `EVT 0x06` 上报、再到 HFP 恢复可接听,各需多久? | 决定 §11.5 文案与是否可接受漏接 |
| Q3 | §6.1 的 **GATT UUID 由谁分配**?固件方是否已有预留的 128-bit 基址? | 阻塞两端编码 |
| Q4 | 支持 **MTU 247 / 2M PHY / DLE** 吗?最大支持的 MTU 是多少? | 决定聚合帧长(40ms vs 20ms) |
| Q5 | **G.722 能否直接用在 BLE 通道**上(现有实现在 SPP 上)?编解码器是否可复用? | 不能则要换 Opus,两端都要加依赖 |
| Q6 | 是否有**入耳检测 / 骨传导 / VPU** 可用来判定「是佩戴者本人在说话」?能否随上行包给出该标志? | 面对面串音抑制的关键;没有的话只能靠云端,效果差一档 |
| Q7 | 面对面模式下**哪些按键可用**、键位码如何编排?长按/抬起事件能否上报? | 决定「按住说话」能否做 |
| Q4 | 支持 **MTU 247 / 2M PHY / DLE** 吗?两条 LE + 一条 BR/EDR **共存下**的实测吞吐与丢包? | 决定聚合帧长(40ms vs 20ms) |
| Q5 | **G.722 能否直接用在 BLE 通道**上(现有实现在经典链路上)?编解码器是否可复用? | 不能则要换 Opus,两端都要加依赖 |
| Q6 | 是否有**入耳检测 / 骨传导 / VPU** 可判定「是佩戴者本人在说话」?能否随上行包置 `OWN_VOICE`(§8)? | 面对面串音抑制的关键;没有的话只能靠云端,效果差一档 |
| Q7 | 面对面模式下**哪些按键可用**、键位码如何编排?按下/抬起事件能否上报? | 决定「按住说话」能否做 |
| Q8 | 广播里的 **TOKEN 回显**能实现吗?广播内容能否在运行时动态改? | 不能则无法防止连错耳机,需另想办法 |
| Q9 | **单耳在盒 / 只戴一只**时的降级行为?是拒绝进入,还是以单耳模式进入? | 决定 `EAR_MASK` 语义与 App 提示 |
| Q10 | §11.1 的三层**看门狗超时值**(30s / 5s)是否合适?固件侧能否实现第 1、2 层? | 直接决定「App 崩了耳机会不会卡死」 |
| Q9 | **拆主从 + 两耳各起广播**从收到 `AA 6B` 到广播出现要多久?重新组对要多久? | 决定 §5.2 扫描超时与 T12 连续进出的可行性 |
| Q10 | §11.1 看门狗 1(30s)与 supervision timeout(2~4s)能否实现?断连后重新组对的等待(5s)是否合适? | 直接决定「App 崩了耳机会不会卡死」 |
| Q11 | 与 **OTA / AI 对话 / 通话**的互斥由固件拦(回 `ERR_BUSY`)还是 App 自律? | 建议固件拦,App 也拦,双保险 |
| Q12 | 档位 B 下两只耳机的**上行时间戳是否需要对齐**?耳间有共同时钟吗? | 影响能否做跨耳的回声/串音抑制 |
| Q13 | 单包最大长度、单位时间最大包数有无限制? | 影响聚合策略 |
| Q14 | 耳机侧**下行抖动缓冲**多大?溢出策略是丢旧还是丢新? | 影响打断(FLUSH)的实际效果 |
| Q12 | 单包最大长度、单位时间最大包数有无限制? | 影响聚合策略 |
| Q13 | 耳机侧**下行抖动缓冲**多大?溢出策略是丢旧还是丢新?`BB 88` 补/扣包的触发阈值是多少? | 影响打断(FLUSH)与追赶的实际效果 |
| Q14 | 单耳失联后另一耳如何得知(§11.5 `REASON=0x04`)?拆主从后两耳之间还有没有任何私有链路? | 决定单耳失联能否由固件自己发现 |
(v0.1 的 Q1「能否做双链路」已由产品决定为必做;Q12「上行时间戳是否对齐」删除——两人各听各的,不需要对齐。)
---
@ -467,20 +525,23 @@ BLE 1M PHY 在 30ms 连接间隔下的实测可用吞吐通常在 100~300 kbps
| # | 场景 | 期望 |
|---|---|---|
| T01 | 正常进入 → 双耳广播 → 连接 → 握手 → 上下行通 | 全流程 < 5s,双耳都能收发 |
| T01 | 正常进入 → 拆主从 → 双耳广播 → 分别连接 → 握手 → 上下行通 | 全流程 < 5s,双耳都能收发 |
| T02 | 左耳说话 | 只有右耳听到译文;左耳自己听不到 |
| T03 | 双方同时说话 | 两路各自独立上行,互不排队,两路译文分别到达对侧 |
| T04 | 打断:A 的第二句在第一句译文播完前到达 | 置 `FLUSH` 后立即切到新句,不排队堆积 |
| T05 | 扫描期旁边有另一副同型号耳机(也在面对面模式) | **绝不连错**:token 不匹配的广播被忽略 |
| T06 | 正常退出 | 音乐可播、来电可接、按键功能恢复、ANC 恢复 |
| T07 | **App 进程被杀** | ≤ 5s 后耳机自行退出并恢复常态(看门狗 2) |
| T08 | 进入模式后一直不连(App 卡住) | ≤ 30s 后耳机自行退出(看门狗 1) |
| T09 | 面对面期间来电 | 按 Q2 的结论执行,且结束后状态一致 |
| T06 | 正常退出 | 两耳重新组对,音乐可播、来电可接、按键功能恢复、ANC 恢复 |
| T07 | **App 进程被杀** | supervision timeout 内两条 LE 断开,≤ 10s 耳机重新组对并恢复常态(看门狗 2) |
| T08 | 进入模式后一直不连(App 卡住) | ≤ 30s 后耳机自行退出并组对(看门狗 1) |
| T09 | 面对面期间来电 | 按 Q2 的结论执行,用户能接到电话,结束后状态一致 |
| T10 | 面对面期间摘下一只耳 / 放回盒 | 上报 `EVT 0x02`,App 暂停该路并提示,戴回后恢复 |
| T11 | 走出距离边缘导致丢包 | 无崩溃、无杂音,seq 补洞,恢复后自愈 |
| T12 | 连续进出 20 次 | 无资源泄漏,第 20 次与第 1 次耗时相当 |
| T11 | 一只耳走远导致该链路断开 | 另一耳停流并提示,10s 内回来能续上;超时后完整退出、两耳组对 |
| T12 | 连续进出 20 次 | 无资源泄漏,第 20 次与第 1 次耗时相当,每次都成功组对 |
| T13 | 重复发 `AA 6C` 三次 | 三次都回 `BB EC 00`(幂等) |
| T14 | 面对面期间尝试进 AI / 通话翻译 | 回 `ERR_BUSY`,两边状态都不被破坏 |
| T15 | 单耳在盒时发 `AA 6B` | 回 `ERR_EAR_UNAVAILABLE`,不拆主从 |
| T16 | 两人面对面坐着、只有 A 说话 | B 那只耳机的上行 `OWN_VOICE=0`(Q6 成立时),B 侧不产生识别结果 |
| T17 | 一句 20 秒不换气的长句 | 下行积压由追赶器(裁静音 + 变速)消化,无杂音、无整段丢弃 |
---
@ -489,3 +550,4 @@ BLE 1M PHY 在 30ms 连接间隔下的实测可用吞吐通常在 100~300 kbps
| 版本 | 日期 | 变更 |
|---|---|---|
| v0.1 | 2026-09-05 | 首版草案,待固件确认 §14 |
| v0.2 | 2026-09-23 | **形态定为两耳独立 BLE**:删除档位 A 与 `TOPO`/`TOPO_REQ`/`UP_ENABLE`;`AA 6B` 长度 07→06,`BB EB` 新增 `CLASSIC_KEEP`;LE 侧新增 `AA 06 EXIT`;看门狗 2 改为「链路断开即退出」,keepalive 降为诊断(1s→5s);`BB 86` 调速改为 `BB 88` 按包补/扣(修正对 `D6` 的误读);上行 FLAGS bit7 定义为 `OWN_VOICE`;补 §1.1 iOS 是 BR/EDR GATT、§1.2 App 载体改为原生直连(`face_to_face_ble_service.dart` 已删)、§5.2 前台扫描、§6.3 写入规则、§9 复用追赶器、§11.5 单耳失联与来电、§13 端到端为前置;`EVT 0x06` 增加 REASON 表;新增 `ERR_TWS_SPLIT`、T15~T17 |

22
apps/client/ios/Runner.xcodeproj/project.pbxproj

@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 60;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@ -338,7 +338,7 @@
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
@ -389,14 +389,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@ -441,14 +437,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@ -658,7 +650,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1;
MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.yimai.eaimar;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -852,7 +844,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1;
MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.yimai.eaimar;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -886,7 +878,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.1;
MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.yimai.eaimar;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -996,7 +988,7 @@
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
@ -1005,7 +997,7 @@
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
package = 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */;
package = 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */

2
apps/client/ios/Runner/GoogleService-Info.plist

@ -9,7 +9,7 @@
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.saitong.voitrans</string>
<string>com.yimai.eaimar</string>
<key>PROJECT_ID</key>
<string>voitrans</string>
<key>STORAGE_BUCKET</key>

16
apps/client/lib/data/models/device_category.dart

@ -162,6 +162,22 @@ class DeviceCategories {
return null;
}
/// 按设备名(服务端下发的 `devicename` 或蓝牙广播名)猜品类 id;猜不到返回 null。
///
/// 口径与 [productsOf] 的 `nameKeywords` 匹配完全一致,给「设备管理」这类
/// 只有一行绑定记录、没有在线会话的地方用。**只做展示/入口判断**,
/// 不要拿它做协议分支——协议分支按能力(`DeviceCapability`)走。
static String? categoryIdForName(String? deviceName) {
final name = (deviceName ?? '').trim().toLowerCase();
if (name.isEmpty) return null;
for (final c in [...all, ...hidden]) {
if (c.nameKeywords.any((k) => name.contains(k.toLowerCase()))) {
return c.id;
}
}
return null;
}
/// 该品类下的产品(服务端下发的产品表里筛)
static List<DBProduct> productsOf(DeviceCategory category) {
if (!AppConfig.isInitialized()) return const [];

13
apps/client/lib/data/services/network/api.dart

@ -519,6 +519,19 @@ class Api {
);
}
// 为一条会议记录签发分享票据。入参 {id, expiredays?},回 {ticket, expiretime}。
//
// ⚠️ 分享链接里放的必须是这个票据,**不能再放 logintoken**:那是完整登录态,
// 拿到链接就等于拿到账号,且除了改密码没有任何失效手段。票据只对应这一条记录、
// 只读、默认 7 天过期(服务端 modules/echomeet/api_sharelink.go)。
static createShareLinkEchomeet([params]) {
return DioManager().request(
NWMethod.post,
'/api/home/echomeet_createsharelink',
params: params,
);
}
static readRecordEchomeet([params]) {
return DioManager().request(
NWMethod.post,

67
apps/client/lib/data/services/speech_impl/azure_ast_service.dart

@ -56,6 +56,19 @@ class AzureAstService extends GetxService implements AstService {
late final String _alibabaAppURL;
final List<String> _defaultSupportedLanguages = ['zh-CN', 'en-US'];
/// 最近一次 initialize 用的参数:需要「补一次初始化」时用它,**绝不能退回默认值**。
///
/// 2026-09-23 Redmi K90 真机:快速开关通话翻译 + 切语言,两条初始化并发——后一条的
/// dispose() 把 `_isInitialized` 清成 false、前一条的原生 initialize 因此返回 false;
/// 随后 recognizeCallback() 看到"未初始化"就调了无参 initialize(),落到默认的
/// provider=azure + zh-CN/en-US,把刚握手成功的阿里 zh→de 会话整个拆掉。
/// 表现是「德语/意大利语一开就卡死、只有中英正常」——因为默认值恰好就是中英。
List<String>? _lastLanguages;
String _lastProvider = 'azure';
/// 正在进行中的 initialize:后来的调用先等它结束再开始,避免 dispose 撞上在途的原生初始化。
Future<bool>? _initInFlight;
@override
List<String> get supportedLanguages => _defaultSupportedLanguages;
@ -290,11 +303,34 @@ class AzureAstService extends GetxService implements AstService {
}
@override
Future<Stream<ASTEvent>> recognizeCallback() async {
/// 确保原生 AST 已按**当前语言对**初始化:先等在途的初始化,仍没成就按上次参数补一次。
///
/// ⚠️ 这里绝不能退回无参 initialize():它的默认值是 azure + zh-CN/en-US,会把当前语言对的
/// 阿里会话整个换掉(见 _lastLanguages 的说明)。没有可复用的参数就直接报错,让调用方知道。
Future<void> _ensureInitialized(String who) async {
if (!_isInitialized) {
Logger.info('[STS] recognizeCallback: 未初始化,先执行initialize');
await initialize();
final inflight = _initInFlight;
if (inflight != null) {
Logger.warning('[STS] $who: 初始化进行中,等待其完成');
try {
await inflight;
} catch (_) {}
}
}
if (!_isInitialized) {
final langs = _lastLanguages;
if (langs == null) {
throw StateError('[STS] $who: AST 尚未初始化,且没有可复用的参数');
}
Logger.warning('[STS] $who: 未初始化,按上次参数补初始化 provider=$_lastProvider langs=$langs');
final ok = await initialize(supportedLanguages: langs, provider: _lastProvider);
if (!ok) throw StateError('[STS] $who: 按上次参数补初始化失败');
}
}
@override
Future<Stream<ASTEvent>> recognizeCallback() async {
await _ensureInitialized('recognizeCallback');
try {
_eventStreamController ??= StreamController<ASTEvent>.broadcast();
Logger.info('[STS] recognizeCallback: streamController=${_eventStreamController.hashCode}, hasListener=${_eventStreamController!.hasListener}');
@ -388,6 +424,8 @@ class AzureAstService extends GetxService implements AstService {
@override
Future<bool> startContinuousTranslation() async {
try {
// 原生初始化没落地就 start,等于对着空会话开录;快速开关/切语言时真机复现过。
await _ensureInitialized('startContinuousTranslation');
final bool result =
await _channel.invokeMethod('startContinuousTranslation');
@ -434,6 +472,29 @@ class AzureAstService extends GetxService implements AstService {
List<String>? supportedLanguages,
String provider = 'azure',
}) async {
// 串行化:前一条还在原生里跑时就 dispose 会让它返回 false,然后触发一连串误判。
final prev = _initInFlight;
if (prev != null) {
Logger.warning('[STS] initialize: 上一条初始化仍在进行,先等它结束');
try {
await prev;
} catch (_) {}
}
final task = _initializeInner(supportedLanguages: supportedLanguages, provider: provider);
_initInFlight = task;
try {
return await task;
} finally {
if (identical(_initInFlight, task)) _initInFlight = null;
}
}
Future<bool> _initializeInner({
List<String>? supportedLanguages,
String provider = 'azure',
}) async {
_lastProvider = provider;
if (supportedLanguages != null) _lastLanguages = List<String>.from(supportedLanguages);
try {
final List<String> languages =
supportedLanguages ?? _defaultSupportedLanguages;

54
apps/client/lib/data/services/version_update_service.dart

@ -6,6 +6,7 @@ import 'package:url_launcher/url_launcher.dart';
import 'package:logger/logger.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:get_storage/get_storage.dart';
import '../models/channel_app_model.dart';
import 'network/api.dart';
@ -115,8 +116,45 @@ class VersionUpdateService extends GetxService {
/// - 更要命的是它**不在网关白名单里**,未登录时返回 `code:18 NoLogin`。而游客登录入口
/// 恰恰只在未登录时才需要显示,于是入口在任何未登录设备上都不可能出现,且不报错。
/// 单数接口本来就在白名单里。
Future<VersionUpdateInfo> checkForUpdate() async {
if (_lastCheckTime != null &&
/// 网页下载页:Android 上打开服务端给的地址失败时的兜底(页面会按品牌跳商店 / 直接给安装包)。
/// 与后台「版本控制」页推荐填的默认地址、服务端 comm.DefaultDistDomain 是同一个。
static const String officialDownloadPage = 'https://dl.ymaikj.com';
/// 推荐更新的「稍后提醒」:记住被推迟的版本号与时刻,同一版本 [_snoozeHours] 小时内冷启动不再弹。
/// 只作用于冷启动的自动检查;设置页手动「检查更新」不看它。强更从不受它影响。
static const String _snoozeKey = 'update_snooze';
static const int _snoozeHours = 24;
void snoozeVersion(String version) {
try {
GetStorage().write(_snoozeKey, {
'version': version,
'ts': DateTime.now().millisecondsSinceEpoch,
});
} catch (e) {
_logger.w('记录稍后提醒失败: $e');
}
}
bool isSnoozed(String version) {
try {
final raw = GetStorage().read(_snoozeKey);
if (raw is! Map) return false;
if (raw['version']?.toString() != version) return false;
final ts = raw['ts'];
if (ts is! int) return false;
return DateTime.now().millisecondsSinceEpoch - ts <
Duration(hours: _snoozeHours).inMilliseconds;
} catch (_) {
return false;
}
}
/// [bypassCache] 为 true 时跳过 10 分钟缓存真的发一次请求——设置页手动「检查更新」用,
/// 否则刚发完版用户主动点检查可能拿到缓存里的「已是最新」。
Future<VersionUpdateInfo> checkForUpdate({bool bypassCache = false}) async {
if (!bypassCache &&
_lastCheckTime != null &&
DateTime.now().difference(_lastCheckTime!).inMinutes <
_cacheIntervalMinutes) {
// [1]没有超过10分钟,返回缓存数据
@ -409,8 +447,16 @@ class VersionUpdateService extends GetxService {
}
try {
_logger.d('lxm---执行更新操作,下载地址: ${updateInfo.appInfo!.address}');
return await _openDownloadUrl(updateInfo.appInfo!.address!);
final address = updateInfo.appInfo!.address ?? '';
_logger.d('lxm---执行更新操作,下载地址: $address');
if (address.isNotEmpty && await _openDownloadUrl(address)) return true;
// Android 兜底:服务端给的地址打不开(商店未装 / 链接失效)就去网页下载页,
// 它按品牌跳商店、认不出就直接给安装包。iOS 不兜底——那页面给的是 apk。
if (Platform.isAndroid) {
_logger.w('下载地址打不开,回退网页下载页: $officialDownloadPage');
return await _openDownloadUrl(officialDownloadPage);
}
return false;
} catch (e) {
_logger.e('执行更新失败: $e');
return false;

7
apps/client/lib/data/utils/background_keepalive.dart

@ -31,6 +31,10 @@ class BackgroundKeepalive {
static bool get _apply => !kIsWeb && Platform.isIOS;
/// 静音循环此刻是否真的在播(取样日志用;被电话打断会变 false)
static bool get isPlaying => _player?.playing ?? false;
static int get refs => _refs;
static Future<void> acquire(String owner) async {
if (!_apply) return;
_refs++;
@ -65,7 +69,8 @@ class BackgroundKeepalive {
await p.setVolume(0);
unawaited(p.play());
_player = p;
Logger.i(_tag, '后台保活已开启($owner)');
// warning 级别:release 包的文件日志只收 warning 以上,切后台排查要看得到它
Logger.w(_tag, '后台保活已开启($owner)');
} catch (e) {
// 起不来不阻断会话本身:只是锁屏后可能被挂起,前台使用不受影响
Logger.w(_tag, '后台保活开启失败($owner): $e');

170
apps/client/lib/devices/eaicar/eaicar_device_plugin.dart

@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:classic_bluetooth/classic_bluetooth.dart';
import 'package:device_plugin_interface/device_plugin_interface.dart';
@ -381,6 +382,9 @@ class EaiCarSession implements DeviceSession {
// 设备按键/语音唤醒 AI:设备主动上报 0xCC 0x11。声明它就能白嫖
// DeviceAiSessionService 那整套会话编排
DeviceCapability.aiWake,
// 设备设置面板:音量/亮度/启停/行车感应/摇摆/随机表情,状态来自 0x15,
// 写命令见 [settingsCommand]。业务层按这个能力位在设备管理页开面板。
DeviceCapability.settingsPanel,
// ⚠️ 刻意**不声明** speakerDownlink:AI 回复不走 BLE,走经典蓝牙 A2DP
// (手机正常播放,设备当音箱)。openSpeaker(ai) 返回的是"手机扬声器"这个汇,
// 见 [_EaiCarPhoneSpeaker]。
@ -403,6 +407,13 @@ class EaiCarSession implements DeviceSession {
DeviceConnectionState _state = DeviceConnectionState.linkConnected;
DeviceInfo _info = const DeviceInfo(id: '', name: '', vendor: '');
/// 最近一次 0x15 汇总解出来的全部设置(写成功后本地回填)。
/// 握手成功后必有值;为 null 说明 0x15 从没应答过。
EaiCarAllSettings? _settings;
/// 只读快照,给面板/排查用;写要走 [invokeFeature] 的 `settings.*`。
EaiCarAllSettings? get settings => _settings;
@override
String get vendor => DeviceVendors.eaicar;
@ -532,6 +543,138 @@ class EaiCarSession implements DeviceSession {
);
_emitInfo();
_emitFeature('common.battery', {'percent': s.battery});
// 2026-09-22 之前这里只留固件和电量,音量/亮度/各开关解完就丢——
// 设备设置面板要的"当前值"正是这些,整份留下并派事件。
_settings = s;
_emitFeature(DeviceFeatureKeys.settingsChanged, s.toSettingsMap());
}
/// `settings.*` 写命令的**纯映射**:feature key + 参数 → (命令号, 载荷, 是否等应答)。
///
/// 抽成静态纯函数是为了让 `test/devices/eaicar_settings_features_test.dart`
/// 不连设备就能逐字节钉死;[invokeFeature] 只是"映射 → 发 → 回填"。
/// 参数越界抛 `device.invalid_argument`,不认识的 key 返回 null。
///
/// ⚠️ `play_random_expression`:设备**没有**"随机播一个"的原生命令,
/// 这里由 app 在 0x00~0x09 里挑([random] 可注入,测试用固定值)。
static ({int cmd, List<int> payload, bool expectResponse})? settingsCommand(
String key,
Map<String, Object?> args, {
int Function(int max)? random,
}) {
int intArg(String name) {
final v = args[name];
if (v is num) return v.toInt();
throw DeviceException(
DeviceErrorCode.invalidArgument, '$key 缺少整数参数 $name');
}
bool onArg() {
final v = args['on'];
if (v is bool) return v;
throw DeviceException(
DeviceErrorCode.invalidArgument, '$key 缺少布尔参数 on');
}
int ranged(String name, int max) {
final v = intArg(name);
if (v < 0 || v > max) {
throw DeviceException(
DeviceErrorCode.invalidArgument, '$key $name=$v 超出 0~$max');
}
return v;
}
switch (key) {
case DeviceFeatures.settingsVolume:
return (
cmd: EaiCarProtocol.cmdSetVolume,
payload: [ranged('value', EaiCarProtocol.volumeMax)],
expectResponse: true,
);
case DeviceFeatures.settingsBrightness:
return (
cmd: EaiCarProtocol.cmdControlBrightness,
payload: [ranged('value', EaiCarProtocol.brightnessMax)],
expectResponse: true,
);
case DeviceFeatures.settingsPower:
return (
cmd: EaiCarProtocol.cmdAutoPowerSwitch,
payload: [onArg() ? 0x01 : 0x00],
expectResponse: true,
);
case DeviceFeatures.settingsDrivingSense:
return (
cmd: EaiCarProtocol.cmdGyroscopeSwitch,
payload: [onArg() ? 0x01 : 0x00],
expectResponse: true,
);
case DeviceFeatures.settingsSwing:
return (
cmd: EaiCarProtocol.cmdMotorSwitch,
payload: [onArg() ? 0x01 : 0x00],
expectResponse: true,
);
case DeviceFeatures.settingsRandomExpressionInterval:
final s = intArg('seconds');
if (!EaiCarProtocol.randomExpressionIntervalOptions.contains(s)) {
throw DeviceException(DeviceErrorCode.invalidArgument,
'$key seconds=$s 不在 ${EaiCarProtocol.randomExpressionIntervalOptions}');
}
return (
cmd: EaiCarProtocol.cmdRandomExpressionInterval,
payload: [s],
expectResponse: true,
);
case DeviceFeatures.settingsPlayRandomExpression:
final pick = (random ?? _defaultRandom)(EaiCarProtocol.emotionCount);
return (
cmd: EaiCarProtocol.cmdControlEmotion,
payload: [pick.clamp(0, EaiCarProtocol.emotionCount - 1)],
expectResponse: false,
);
default:
return null;
}
}
static final math.Random _rng = math.Random();
static int _defaultRandom(int max) => _rng.nextInt(max);
/// 发一条 `settings.*` 写命令:设备拒绝(应答 len==0)抛 `feature_failed`;
/// 成功后**本地回填** [_settings] 并派 `settings.changed`。
///
/// 为什么要本地回填:六条写命令的应答只有 `0x64` 成功码,不回显新值,
/// 也没有主动上报;不回填的话面板要等下一次 0x15 才知道自己改成了没有。
Future<Map<String, Object?>> _applySettingsCommand(
String key, Map<String, Object?> args) async {
final c = settingsCommand(key, args);
if (c == null) {
throw DeviceException(DeviceErrorCode.notSupported, 'feature $key');
}
final f = await _link.send(c.cmd,
payload: c.payload, expectResponse: c.expectResponse);
if (c.expectResponse && (f == null || !f.success)) {
throw DeviceException(
DeviceErrorCode.featureFailed, '$key 设备拒绝 (0x${c.cmd.toRadixString(16)})');
}
final cur = _settings;
if (cur != null && key != DeviceFeatures.settingsPlayRandomExpression) {
final v = c.payload[0];
_settings = switch (key) {
DeviceFeatures.settingsVolume => cur.copyWith(volume: v),
DeviceFeatures.settingsBrightness => cur.copyWith(brightness: v),
DeviceFeatures.settingsPower => cur.copyWith(powerSwitch: v != 0),
DeviceFeatures.settingsDrivingSense => cur.copyWith(gyroscope: v != 0),
DeviceFeatures.settingsSwing => cur.copyWith(motor: v != 0),
DeviceFeatures.settingsRandomExpressionInterval =>
cur.copyWith(randomExprFreq: v),
_ => cur,
};
_emitFeature(DeviceFeatureKeys.settingsChanged, _settings!.toSettingsMap());
}
return {'ok': true};
}
void _applyVersion(List<int> p) {
@ -772,6 +915,33 @@ class EaiCarSession implements DeviceSession {
case 'state.refresh':
await refreshInfo();
return {'ok': true};
// ---------- 设备设置面板(DeviceCapability.settingsPanel) ----------
case DeviceFeatures.settingsQuery:
// 每次都真的问一次 0x15,不拿缓存糊弄面板:面板打开就是想看"现在"的值。
// 字节与握手 / refreshInfo 完全一样(不带载荷,真机验证过;lumi 带 0xFF,
// 设备两种都认,别在这里另起一种写法)。
final f = await _link.send(EaiCarProtocol.cmdSyncSummary);
if (f == null || !f.success) {
throw DeviceException(
DeviceErrorCode.readFailed, '0x15 无应答或设备拒绝');
}
_applySummary(f.payload);
final s = _settings;
if (s == null) {
throw DeviceException(
DeviceErrorCode.readFailed, '0x15 应答不足 14 字节');
}
return s.toSettingsMap();
case DeviceFeatures.settingsVolume:
case DeviceFeatures.settingsBrightness:
case DeviceFeatures.settingsPower:
case DeviceFeatures.settingsDrivingSense:
case DeviceFeatures.settingsSwing:
case DeviceFeatures.settingsRandomExpressionInterval:
case DeviceFeatures.settingsPlayRandomExpression:
return _applySettingsCommand(key, args);
case 'eaicar.volume':
final v = (args['value'] as num?)?.toInt() ?? 0;
await _link.send(EaiCarProtocol.cmdSetVolume, payload: [v & 0xFF]);

69
apps/client/lib/devices/eaicar/eaicar_protocol.dart

@ -150,9 +150,30 @@ class EaiCarProtocol {
static const int cmdGyroscopeSwitch = 0x11;
static const int cmdMusicHeadStyle = 0x12;
/// 待机随机表情频次,载荷 1 字节 = **秒数原值**(不是档位索引),
/// 允许 [randomExpressionIntervalOptions] 里的 9 档(lumi `settings_view.dart:1517`)。
/// 0x15 回读 `[2]` 也是秒数原值。
static const int cmdRandomExpressionInterval = 0x13;
/// 电机(摇摆)开关,载荷 1 字节 0/1。lumi 常量名 MOTOR,UI 文案「摇摆开关」。
static const int cmdMotorSwitch = 0x14;
/// [cmdRandomExpressionInterval] 的合法取值(秒)
static const List<int> randomExpressionIntervalOptions = [
20, 30, 40, 50, 60, 90, 120, 150, 180,
];
/// [cmdControlEmotion] 的表情 id 范围:0x00 开心 / 01 生气 / 02 伤心 / 03 害怕 /
/// 04 疲惫 / 05 得意 / 06 无聊 / 07 疑惑 / 08 跳舞 / 09 唱歌
/// (lumi `chat_controller.dart:35-59`,与协议文档一致)
static const int emotionCount = 10;
/// 音量 / 亮度的上限。lumi 原生层把音量校验写成 0~16,但 UI 只发 0~10,
/// 协议文档也写 0~10,按 10 收。
static const int volumeMax = 10;
static const int brightnessMax = 10;
/// 同步参数汇总。应答 data = 14 字节,见 [EaiCarAllSettings]
static const int cmdSyncSummary = 0x15;
@ -399,4 +420,52 @@ class EaiCarAllSettings {
motor: u(13) != 0,
);
}
EaiCarAllSettings copyWith({
int? volume,
int? randomExprFreq,
int? brightness,
bool? powerSwitch,
bool? gyroscope,
bool? motor,
}) =>
EaiCarAllSettings(
otaAbnormal: otaAbnormal,
volume: volume ?? this.volume,
randomExprFreq: randomExprFreq ?? this.randomExprFreq,
brightness: brightness ?? this.brightness,
battery: battery,
firmwareVersion: firmwareVersion,
powerSwitch: powerSwitch ?? this.powerSwitch,
musicHeadStyle: musicHeadStyle,
musicExpression: musicExpression,
gyroscope: gyroscope ?? this.gyroscope,
motor: motor ?? this.motor,
);
/// `DeviceFeatures.settingsQuery` 返回值 / `settings.changed` 事件的 data。
///
/// 键名是契约层定的通用语义(见 `DeviceFeatures.settings*`),不是 C2 字段名:
/// `gyroscope` 对外叫 `driving_sense`,`motor` 对外叫 `swing`。
Map<String, Object?> toSettingsMap() => {
'items': {
'power': {'on': powerSwitch},
'volume': {'value': volume, 'min': 0, 'max': EaiCarProtocol.volumeMax},
'brightness': {
'value': brightness,
'min': 0,
'max': EaiCarProtocol.brightnessMax,
},
'driving_sense': {'on': gyroscope},
'swing': {'on': motor},
'random_expression_interval': {
'value': randomExprFreq,
'options': EaiCarProtocol.randomExpressionIntervalOptions,
},
'play_random_expression': <String, Object?>{},
},
'battery': battery,
'firmware': firmwareVersion,
'otaAbnormal': otaAbnormal,
};
}

2
apps/client/lib/modules/common/widgets/version_update_dialog.dart

@ -120,6 +120,8 @@ class VersionUpdateDialog extends StatelessWidget {
Expanded(
child: TextButton(
onPressed: () {
// 记住这一版被推迟了:冷启动 24 小时内不再弹(见 VersionUpdateService.isSnoozed)
VersionUpdateService.to.snoozeVersion(updateInfo.latestVersion);
Get.back();
onCancel?.call();
},

165
apps/client/lib/modules/eaicar/module.dart

@ -55,6 +55,21 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor(
'eaicarWelcome': '欢迎回来',
'eaicarA2dpTitle': '需要连接蓝牙音频',
'eaicarA2dpHint': 'AI 回复和音乐要经典蓝牙才能从设备里放出来。请到「设置 → 蓝牙」里配对 @name。',
'deviceSettingsTitle': '设备设置',
'deviceNotConnected': '设备未连接',
'goConnect': '去连接',
'refresh': '刷新',
'settingReadFailed': '读取设备状态失败',
'settingApplyFailed': '设置失败',
'settingNoItems': '该设备没有可调项',
'settingPower': '启停开关',
'settingVolume': '音量',
'settingBrightness': '亮度',
'settingDrivingSense': '行车感应',
'settingSwing': '摇摆',
'settingRandomExpressionInterval': '随机表情频次',
'settingSeconds': '@n 秒',
'settingPlayRandomExpression': '随机来一个表情',
},
'zh_TW': {
'agentEaiCar': 'EaiCar',
@ -62,6 +77,21 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor(
'eaicarWelcome': '歡迎回來',
'eaicarA2dpTitle': '需要連接藍牙音訊',
'eaicarA2dpHint': 'AI 回覆和音樂要經典藍牙才能從裝置裡播出來。請到「設定 → 藍牙」配對 @name。',
'deviceSettingsTitle': '裝置設定',
'deviceNotConnected': '裝置未連接',
'goConnect': '去連接',
'refresh': '重新整理',
'settingReadFailed': '讀取裝置狀態失敗',
'settingApplyFailed': '設定失敗',
'settingNoItems': '此裝置沒有可調項目',
'settingPower': '啟停開關',
'settingVolume': '音量',
'settingBrightness': '亮度',
'settingDrivingSense': '行車感應',
'settingSwing': '搖擺',
'settingRandomExpressionInterval': '隨機表情頻率',
'settingSeconds': '@n 秒',
'settingPlayRandomExpression': '隨機來一個表情',
},
'zh_HK': {
'agentEaiCar': 'EaiCar',
@ -69,6 +99,21 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor(
'eaicarWelcome': '歡迎回來',
'eaicarA2dpTitle': '需要連接藍牙音訊',
'eaicarA2dpHint': 'AI 回覆和音樂要經典藍牙先可以喺裝置度播出嚟。請去「設定 → 藍牙」配對 @name。',
'deviceSettingsTitle': '裝置設定',
'deviceNotConnected': '裝置未連接',
'goConnect': '去連接',
'refresh': '重新整理',
'settingReadFailed': '讀取裝置狀態失敗',
'settingApplyFailed': '設定失敗',
'settingNoItems': '呢部裝置冇可調項目',
'settingPower': '啟停開關',
'settingVolume': '音量',
'settingBrightness': '亮度',
'settingDrivingSense': '行車感應',
'settingSwing': '搖擺',
'settingRandomExpressionInterval': '隨機表情頻率',
'settingSeconds': '@n 秒',
'settingPlayRandomExpression': '隨機嚟一個表情',
},
'en_US': {
'agentEaiCar': 'EaiCar',
@ -76,6 +121,21 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor(
'eaicarWelcome': 'Welcome back',
'eaicarA2dpTitle': 'Bluetooth audio not connected',
'eaicarA2dpHint': 'Pair @name under Settings → Bluetooth so replies and music play from the device.',
'deviceSettingsTitle': 'Device settings',
'deviceNotConnected': 'Device not connected',
'goConnect': 'Connect',
'refresh': 'Refresh',
'settingReadFailed': 'Failed to read device status',
'settingApplyFailed': 'Failed to apply',
'settingNoItems': 'No adjustable settings on this device',
'settingPower': 'Auto power',
'settingVolume': 'Volume',
'settingBrightness': 'Brightness',
'settingDrivingSense': 'Driving sensor',
'settingSwing': 'Swing',
'settingRandomExpressionInterval': 'Random expression interval',
'settingSeconds': '@n s',
'settingPlayRandomExpression': 'Play a random expression',
},
'ja_JP': {
'agentEaiCar': 'EaiCar',
@ -83,6 +143,21 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor(
'eaicarWelcome': 'おかえりなさい',
'eaicarA2dpTitle': 'Bluetoothオーディオ未接続',
'eaicarA2dpHint': '「設定 → Bluetooth」で @name をペアリングすると、応答と音楽が本体から再生されます。',
'deviceSettingsTitle': 'デバイス設定',
'deviceNotConnected': 'デバイス未接続',
'goConnect': '接続する',
'refresh': '更新',
'settingReadFailed': 'デバイス状態の取得に失敗',
'settingApplyFailed': '設定に失敗',
'settingNoItems': 'このデバイスに調整項目はありません',
'settingPower': '自動オン/オフ',
'settingVolume': '音量',
'settingBrightness': '明るさ',
'settingDrivingSense': '走行センサー',
'settingSwing': 'スイング',
'settingRandomExpressionInterval': 'ランダム表情の間隔',
'settingSeconds': '@n 秒',
'settingPlayRandomExpression': 'ランダムに表情を再生',
},
'ko_KR': {
'agentEaiCar': 'EaiCar',
@ -90,6 +165,21 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor(
'eaicarWelcome': '다시 오신 것을 환영합니다',
'eaicarA2dpTitle': '블루투스 오디오 미연결',
'eaicarA2dpHint': '설정 → 블루투스에서 @name 을(를) 페어링하면 응답과 음악이 기기에서 재생됩니다。',
'deviceSettingsTitle': '기기 설정',
'deviceNotConnected': '기기 미연결',
'goConnect': '연결하기',
'refresh': '새로고침',
'settingReadFailed': '기기 상태를 읽지 못했습니다',
'settingApplyFailed': '설정 실패',
'settingNoItems': '이 기기에는 조절 항목이 없습니다',
'settingPower': '자동 전원',
'settingVolume': '음량',
'settingBrightness': '밝기',
'settingDrivingSense': '주행 감지',
'settingSwing': '스윙',
'settingRandomExpressionInterval': '랜덤 표정 주기',
'settingSeconds': '@n 초',
'settingPlayRandomExpression': '랜덤 표정 재생',
},
'de_DE': {
'agentEaiCar': 'EaiCar',
@ -97,6 +187,21 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor(
'eaicarWelcome': 'Willkommen zurück',
'eaicarA2dpTitle': 'Bluetooth-Audio nicht verbunden',
'eaicarA2dpHint': 'Koppeln Sie @name unter Einstellungen → Bluetooth, damit Antworten und Musik über das Gerät laufen.',
'deviceSettingsTitle': 'Geräteeinstellungen',
'deviceNotConnected': 'Gerät nicht verbunden',
'goConnect': 'Verbinden',
'refresh': 'Aktualisieren',
'settingReadFailed': 'Gerätestatus konnte nicht gelesen werden',
'settingApplyFailed': 'Einstellung fehlgeschlagen',
'settingNoItems': 'Keine einstellbaren Optionen',
'settingPower': 'Automatisches Ein/Aus',
'settingVolume': 'Lautstärke',
'settingBrightness': 'Helligkeit',
'settingDrivingSense': 'Fahrsensor',
'settingSwing': 'Schwingen',
'settingRandomExpressionInterval': 'Intervall zufälliger Ausdrücke',
'settingSeconds': '@n s',
'settingPlayRandomExpression': 'Zufälligen Ausdruck abspielen',
},
'fr_FR': {
'agentEaiCar': 'EaiCar',
@ -104,6 +209,21 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor(
'eaicarWelcome': 'Bon retour',
'eaicarA2dpTitle': 'Audio Bluetooth non connecté',
'eaicarA2dpHint': 'Associez @name dans Réglages → Bluetooth pour que les réponses et la musique sortent de l\'appareil.',
'deviceSettingsTitle': 'Réglages de l\'appareil',
'deviceNotConnected': 'Appareil non connecté',
'goConnect': 'Connecter',
'refresh': 'Actualiser',
'settingReadFailed': 'Impossible de lire l\'état de l\'appareil',
'settingApplyFailed': 'Échec du réglage',
'settingNoItems': 'Aucun réglage disponible',
'settingPower': 'Marche/arrêt auto',
'settingVolume': 'Volume',
'settingBrightness': 'Luminosité',
'settingDrivingSense': 'Capteur de conduite',
'settingSwing': 'Balancement',
'settingRandomExpressionInterval': 'Intervalle des expressions aléatoires',
'settingSeconds': '@n s',
'settingPlayRandomExpression': 'Jouer une expression aléatoire',
},
'es_ES': {
'agentEaiCar': 'EaiCar',
@ -111,6 +231,21 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor(
'eaicarWelcome': 'Bienvenido de nuevo',
'eaicarA2dpTitle': 'Audio Bluetooth no conectado',
'eaicarA2dpHint': 'Empareja @name en Ajustes → Bluetooth para que las respuestas y la música suenen en el dispositivo.',
'deviceSettingsTitle': 'Ajustes del dispositivo',
'deviceNotConnected': 'Dispositivo no conectado',
'goConnect': 'Conectar',
'refresh': 'Actualizar',
'settingReadFailed': 'No se pudo leer el estado del dispositivo',
'settingApplyFailed': 'Error al aplicar',
'settingNoItems': 'No hay ajustes disponibles',
'settingPower': 'Encendido automático',
'settingVolume': 'Volumen',
'settingBrightness': 'Brillo',
'settingDrivingSense': 'Sensor de conducción',
'settingSwing': 'Balanceo',
'settingRandomExpressionInterval': 'Intervalo de expresiones aleatorias',
'settingSeconds': '@n s',
'settingPlayRandomExpression': 'Reproducir una expresión aleatoria',
},
'pt_PT': {
'agentEaiCar': 'EaiCar',
@ -118,6 +253,21 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor(
'eaicarWelcome': 'Bem-vindo de volta',
'eaicarA2dpTitle': 'Áudio Bluetooth não ligado',
'eaicarA2dpHint': 'Emparelhe @name em Definições → Bluetooth para que as respostas e a música saiam do dispositivo.',
'deviceSettingsTitle': 'Definições do dispositivo',
'deviceNotConnected': 'Dispositivo não ligado',
'goConnect': 'Ligar',
'refresh': 'Atualizar',
'settingReadFailed': 'Falha ao ler o estado do dispositivo',
'settingApplyFailed': 'Falha ao aplicar',
'settingNoItems': 'Sem definições ajustáveis',
'settingPower': 'Ligar/desligar automático',
'settingVolume': 'Volume',
'settingBrightness': 'Brilho',
'settingDrivingSense': 'Sensor de condução',
'settingSwing': 'Balanço',
'settingRandomExpressionInterval': 'Intervalo de expressões aleatórias',
'settingSeconds': '@n s',
'settingPlayRandomExpression': 'Reproduzir uma expressão aleatória',
},
'ru_RU': {
'agentEaiCar': 'EaiCar',
@ -125,6 +275,21 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor(
'eaicarWelcome': 'С возвращением',
'eaicarA2dpTitle': 'Bluetooth-аудио не подключено',
'eaicarA2dpHint': 'Подключите @name в «Настройки → Bluetooth», чтобы ответы и музыка звучали из устройства.',
'deviceSettingsTitle': 'Настройки устройства',
'deviceNotConnected': 'Устройство не подключено',
'goConnect': 'Подключить',
'refresh': 'Обновить',
'settingReadFailed': 'Не удалось прочитать состояние устройства',
'settingApplyFailed': 'Не удалось применить',
'settingNoItems': 'Нет настраиваемых параметров',
'settingPower': 'Автовключение',
'settingVolume': 'Громкость',
'settingBrightness': 'Яркость',
'settingDrivingSense': 'Датчик движения',
'settingSwing': 'Покачивание',
'settingRandomExpressionInterval': 'Интервал случайных эмоций',
'settingSeconds': '@n с',
'settingPlayRandomExpression': 'Показать случайную эмоцию',
},
},
);

37
apps/client/lib/modules/meeting/views/bottomSheet/share_bottom_sheet.dart

@ -7,7 +7,6 @@ import 'package:flutter/services.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:htmltopdfwidgets/htmltopdfwidgets.dart' as pdf;
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
@ -15,6 +14,7 @@ import 'package:share_plus/share_plus.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../../../../core/utils/logger.dart';
import '../../../../data/services/network/api.dart';
import '../../../../data/services/network/dio_manager.dart';
import '../../controllers/meeting_details_controller.dart';
import '../../controllers/mind_map_controller.dart';
@ -233,20 +233,41 @@ class _ShareBottomSheetState extends State<ShareBottomSheet> {
// 功能说明:生成会议分享链接并通过系统分享面板分享
// 执行流程:
// 1. 关闭底部弹窗
// 2. 从本地存储获取用户登录token
// 2. 向服务端申请**只读分享票据**(echomeet_createsharelink)
// 3. 获取会议ID和服务器地址
// 4. 构建分享URL格式:https://share.ymaikj.com/share/{serverHost}/ymaikj.com/{id}/{token}
// 注:路径第二段是品牌标识、不是要请求的域名,由 share.ymaikj.com 那端解析
// 4. 构建分享URL:https://share.ymaikj.com/share/{serverHost}/ymaikj.com/{id}/{ticket}
// · 第二段 ymaikj.com 是品牌标识、不是要请求的域名,由落地页解析
// · {serverHost} 告诉落地页去哪个后端取数据;落地页那边有域名白名单
// 5. 调用SharePlus分享链接
// 6. 分享成功后显示提示
//
// ⚠️ 最后一段以前放的是 storage.read("logintoken") —— 用户的完整登录 JWT。
// 链接转发进群、被截图、进浏览器历史,都等于把账号交出去,而且除了改密码
// 没有任何失效手段。2026-09-23 改成服务端签发的票据:只含这一条记录、
// 只读、默认 7 天过期。**不要改回去。**
onTap: () async {
// 在关闭弹窗前捕获分享位置(iPad需要)
final origin = _getSharePositionOrigin();
Get.back();
try {
final GetStorage storage = GetStorage();
String token = storage.read("logintoken") ?? '';
int id = _controller.meetingData.value.id;
final int id = _controller.meetingData.value.id;
// 票据要走一次网络,慢网下没有反馈会让人以为按钮没响应
EasyLoading.show();
String ticket = '';
try {
final res = await Api.createShareLinkEchomeet(
{'id': id, 'expiredays': 7});
ticket = (res?['ticket'] ?? '').toString();
} finally {
EasyLoading.dismiss();
}
// 拿不到票据就别分享出一条必然打不开的链接
if (ticket.isEmpty) {
EasyLoading.showToast(
'operationFailedPleaseRetry'.tr); // 对应中文:操作失败,请重试
return;
}
// 获取动态服务器地址
final serverUrl = DioManager().baseUrl;
@ -260,7 +281,7 @@ class _ShareBottomSheetState extends State<ShareBottomSheet> {
subject:
'Eaimar WEB,由DeepSound.Ai提供支持-实时记录会议纪要,多语言翻译。',
uri: Uri.parse(
'https://share.ymaikj.com/share/$serverHost/ymaikj.com/$id/$token',
'https://share.ymaikj.com/share/$serverHost/ymaikj.com/$id/$ticket',
),
sharePositionOrigin: origin,
));

29
apps/client/lib/modules/my_devices/controllers/my_devices_controller.dart

@ -4,6 +4,7 @@ import 'package:device_plugin_interface/device_plugin_interface.dart';
import 'package:get/get.dart';
import '/core/utils/logger.dart';
import '/data/models/device_category.dart';
import '/data/models/user_Info.dart';
import '/data/services/network/api.dart';
import '/devices/device_auth.dart';
@ -128,17 +129,33 @@ class MyDevicesController extends GetxController {
}
/// 当前是不是正连着这一台:比服务端记的 MAC 与在线会话的真实 MAC / 本地 id。
bool isConnected(UserDevice device) {
if (device.devicemac.isEmpty) return false;
bool isConnected(UserDevice device) => sessionFor(device) != null;
/// 这一行对应的在线会话;没连着为 null。
/// 匹配口径:服务端记的 MAC == 会话的真实 MAC / 本地 id / metadata.localId。
DeviceSession? sessionFor(UserDevice device) {
if (device.devicemac.isEmpty) return null;
final mac = device.devicemac.toUpperCase();
for (final s in DeviceHub.to.sessions) {
final info = s.info;
if ((info.macAddress ?? '').toUpperCase() == mac) return true;
if (info.id.toUpperCase() == mac) return true;
if ((info.macAddress ?? '').toUpperCase() == mac) return s;
if (info.id.toUpperCase() == mac) return s;
final localId = (info.metadata['localId'] as String?) ?? '';
if (localId.isNotEmpty && localId.toUpperCase() == mac) return true;
if (localId.isNotEmpty && localId.toUpperCase() == mac) return s;
}
return null;
}
return false;
/// 这一行能不能点开「设备设置面板」。
///
/// 连着:看会话有没有声明 [DeviceCapability.settingsPanel](按能力,不认厂商)。
/// 没连着:退化为按设备名猜品类——只有车载香薰这一档目前有面板,
/// 点进去看到的是「未连接 / 去连接」。别的品类不可点,行为与以前完全一样。
bool canOpenSettings(UserDevice device) {
final s = sessionFor(device);
if (s != null) return s.supports(DeviceCapability.settingsPanel);
return DeviceCategories.categoryIdForName(device.devicename) ==
DeviceCategories.carAroma;
}
/// 本页已去掉「添加设备」入口,跳品类选择页

23
apps/client/lib/modules/my_devices/views/my_devices_view.dart

@ -6,6 +6,7 @@ import 'package:get/get.dart';
import '/data/models/user_Info.dart';
import '../../../routes/app_routes.dart';
import '../controllers/my_devices_controller.dart';
import 'widgets/device_settings_sheet.dart';
enum _MyDeviceCategory {
unknown(0),
@ -332,8 +333,15 @@ class MyDevicesView extends GetView<MyDevicesController> {
UserDevice device, _MyDeviceCategory category, bool isDark) {
final connected = controller.isConnected(device);
const connectedColor = Color(0xFF22C55E);
// 2026-09-22:有设置面板的设备(目前只有 EaiCar)整行可点,点开底部面板。
// 解绑按钮自己的 GestureDetector 在子树里先吃掉事件,不会冒泡到这里;
// 其它设备 onTap 为 null,行为与以前一字不差。
final canOpen = controller.canOpenSettings(device);
return Container(
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: canOpen ? () => DeviceSettingsSheet.show(device) : null,
child: Container(
margin: EdgeInsets.only(bottom: 10.h),
padding: EdgeInsets.all(18.w),
decoration: BoxDecoration(
@ -457,6 +465,18 @@ class MyDevicesView extends GetView<MyDevicesController> {
],
),
),
if (canOpen) ...[
Icon(
Icons.tune,
size: 18.sp,
color: connected
? connectedColor
: (isDark
? Colors.white.withValues(alpha: 0.3)
: const Color(0xFFBBBBBB)),
),
SizedBox(width: 10.w),
],
GestureDetector(
onTap: () => _showUnbindDialog(device, isDark),
child: Container(
@ -512,6 +532,7 @@ class MyDevicesView extends GetView<MyDevicesController> {
],
],
),
),
);
}

584
apps/client/lib/modules/my_devices/views/widgets/device_settings_sheet.dart

@ -0,0 +1,584 @@
import 'dart:async';
import 'package:device_plugin_interface/device_plugin_interface.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import '/core/utils/logger.dart';
import '/data/models/user_Info.dart';
import '/devices/device_hub.dart';
import '/routes/app_routes.dart';
import '../../controllers/my_devices_controller.dart';
/// 设备设置面板(底部弹出)。**厂商无关**:只认 [DeviceCapability.settingsPanel]
/// 与 `settings.*` 特性键,按 `settings.query` 返回的 `items` 渲染——
/// 设备说自己有哪几项,面板就画哪几项。
///
/// 交互口径(与 lumi 设置页一致):
/// - 打开先发一次 `settings.query`(3 s 超时),成功前是骨架屏;
/// - 开关点了立刻翻转(乐观),写失败翻回来并提示;
/// - 滑块拖动只改本地值,300 ms 防抖后发,松手立即发最后一个值;
/// - 频次滑块吸附到设备给的 `options` 档位;
/// - 订阅会话事件:`settings.changed` 整体刷新(覆盖乐观值),断连切到「未连接」态。
///
/// ⚠️ 六条写命令设备只回成功码不回显新值,"当前值"的可信来源只有查询与插件回填,
/// 所以面板从不自己推断状态,只信 `items`。
class DeviceSettingsSheet extends StatefulWidget {
const DeviceSettingsSheet({super.key, required this.device});
final UserDevice device;
static Future<void> show(UserDevice device) {
final isDark = Get.isDarkMode;
return Get.bottomSheet(
DeviceSettingsSheet(device: device),
isScrollControlled: true,
backgroundColor: isDark ? const Color(0xFF1C1C1E) : Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(18.r)),
),
);
}
@override
State<DeviceSettingsSheet> createState() => _DeviceSettingsSheetState();
}
class _DeviceSettingsSheetState extends State<DeviceSettingsSheet> {
static const String _tag = 'DeviceSettingsSheet';
static const Duration _queryTimeout = Duration(seconds: 3);
static const Duration _sliderDebounce = Duration(milliseconds: 300);
DeviceSession? _session;
StreamSubscription<DeviceSessionEvent>? _eventSub;
Worker? _revisionWorker;
/// `settings.query` 返回的 items;null = 还没拿到
Map<String, Object?>? _items;
int? _battery;
String? _firmware;
bool _loading = false;
String? _error;
/// 正在写的键,用来禁掉对应控件防连点
final Set<String> _busy = {};
/// 滑块的本地值(拖动中先改这个,防抖后再发)
final Map<String, int> _sliderLocal = {};
final Map<String, Timer> _debounce = {};
DateTime _lastExpressionTap = DateTime.fromMillisecondsSinceEpoch(0);
@override
void initState() {
super.initState();
_attach();
// 设备在面板开着时连上/断开:DeviceHub.revision 会变,重新找会话
_revisionWorker = ever(DeviceHub.to.revision, (_) => _attach());
}
@override
void dispose() {
_revisionWorker?.dispose();
_eventSub?.cancel();
for (final t in _debounce.values) {
t.cancel();
}
super.dispose();
}
MyDevicesController get _controller => Get.find<MyDevicesController>();
void _attach() {
final s = _controller.sessionFor(widget.device);
final usable =
s != null && s.supports(DeviceCapability.settingsPanel) ? s : null;
if (identical(usable, _session)) return;
_eventSub?.cancel();
_session = usable;
if (usable == null) {
if (mounted) setState(() => _items = null);
return;
}
_eventSub = usable.eventStream.listen(_onEvent);
unawaited(_query());
}
void _onEvent(DeviceSessionEvent e) {
if (!mounted) return;
switch (e.type) {
case DeviceSessionEventType.feature:
final f = e.feature;
if (f != null && f.key == DeviceFeatureKeys.settingsChanged) {
_applySnapshot(f.data);
}
break;
case DeviceSessionEventType.connectionStateChanged:
if (e.connectionState == DeviceConnectionState.disconnected) {
setState(() {
_session = null;
_items = null;
});
}
break;
default:
break;
}
}
void _applySnapshot(Map<String, Object?> data) {
final items = data['items'];
if (items is! Map) return;
setState(() {
_items = items.map((k, v) => MapEntry(k.toString(), v));
_battery = (data['battery'] as num?)?.toInt();
_firmware = data['firmware'] as String?;
_error = null;
// 设备给的值覆盖本地乐观值,拖动中的滑块除外(防抖定时器还没触发)
for (final k in _sliderLocal.keys.toList()) {
if (!(_debounce[k]?.isActive ?? false)) _sliderLocal.remove(k);
}
});
}
Future<void> _query() async {
final s = _session;
if (s == null) return;
setState(() {
_loading = true;
_error = null;
});
try {
final r = await s
.invokeFeature(DeviceFeatures.settingsQuery)
.timeout(_queryTimeout);
if (!mounted) return;
_applySnapshot(r);
} catch (e) {
Logger.w(_tag, 'settings.query 失败: $e');
if (mounted) setState(() => _error = 'settingReadFailed'.tr);
} finally {
if (mounted) setState(() => _loading = false);
}
}
/// 发一条写命令。失败时由调用方决定怎么回滚(开关翻回 / 滑块由下一次快照覆盖)。
Future<bool> _write(String key, Map<String, Object?> args) async {
final s = _session;
if (s == null) return false;
setState(() => _busy.add(key));
try {
await s.invokeFeature(key, args).timeout(_queryTimeout);
return true;
} catch (e) {
Logger.w(_tag, '$key 写入失败: $e');
if (mounted) {
Get.snackbar(
'settingApplyFailed'.tr,
e is DeviceException ? (e.message ?? e.code) : e.toString(),
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 2),
);
}
return false;
} finally {
if (mounted) setState(() => _busy.remove(key));
}
}
// ---------------- 控件动作 ----------------
Future<void> _toggle(String itemKey, String featureKey, bool on) async {
// 乐观翻转
setState(() => _items = {
..._items!,
itemKey: {'on': on},
});
final ok = await _write(featureKey, {'on': on});
if (!ok && mounted) {
setState(() => _items = {
..._items!,
itemKey: {'on': !on},
});
}
}
void _slide(String itemKey, String featureKey, int value, String argName,
{bool flush = false}) {
setState(() => _sliderLocal[itemKey] = value);
_debounce[itemKey]?.cancel();
void send() {
_debounce.remove(itemKey);
unawaited(_write(featureKey, {argName: value}));
}
if (flush) {
send();
} else {
_debounce[itemKey] = Timer(_sliderDebounce, send);
}
}
Future<void> _playRandomExpression() async {
final now = DateTime.now();
if (now.difference(_lastExpressionTap) < const Duration(seconds: 1)) return;
_lastExpressionTap = now;
await _write(DeviceFeatures.settingsPlayRandomExpression, const {});
}
// ---------------- 渲染 ----------------
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final textColor = isDark ? Colors.white : const Color(0xFF1A1A2E);
final subColor =
isDark ? Colors.white.withValues(alpha: 0.5) : const Color(0xFF888888);
return SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 16.h),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 36.w,
height: 4.h,
decoration: BoxDecoration(
color: subColor.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2.r),
),
),
),
SizedBox(height: 14.h),
_header(textColor, subColor),
SizedBox(height: 12.h),
if (_session == null)
_notConnected(textColor, subColor)
else if (_items == null)
_loading ? _skeleton(subColor) : _errorBox(textColor, subColor)
else
_controls(textColor, subColor),
],
),
),
);
}
Widget _header(Color textColor, Color subColor) {
final parts = <String>[];
if (_battery != null) parts.add('$_battery%');
if (_firmware != null && _firmware!.isNotEmpty) parts.add('v$_firmware');
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.device.devicename.isNotEmpty
? widget.device.devicename
: 'deviceSettingsTitle'.tr,
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w700,
color: textColor),
),
if (parts.isNotEmpty)
Padding(
padding: EdgeInsets.only(top: 2.h),
child: Text(parts.join(' · '),
style: TextStyle(fontSize: 12.sp, color: subColor)),
),
],
),
),
if (_session != null)
IconButton(
tooltip: 'refresh'.tr,
onPressed: _loading ? null : _query,
icon: Icon(Icons.refresh, color: subColor, size: 20.sp),
),
],
);
}
Widget _notConnected(Color textColor, Color subColor) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 24.h),
child: Column(
children: [
Icon(Icons.bluetooth_disabled, size: 40.sp, color: subColor),
SizedBox(height: 10.h),
Text('deviceNotConnected'.tr,
style: TextStyle(fontSize: 14.sp, color: textColor)),
SizedBox(height: 14.h),
SizedBox(
height: 42.h,
child: ElevatedButton(
onPressed: () {
Get.back();
Get.toNamed(Routes.eaicarScan);
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6366F1),
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.r)),
),
child: Text('goConnect'.tr,
style: TextStyle(
fontSize: 14.sp, fontWeight: FontWeight.w600)),
),
),
],
),
);
}
Widget _skeleton(Color subColor) {
Widget bar(double w) => Container(
width: w,
height: 14.h,
margin: EdgeInsets.symmetric(vertical: 12.h),
decoration: BoxDecoration(
color: subColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6.r),
),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [bar(140.w), bar(220.w), bar(180.w), bar(200.w)],
);
}
Widget _errorBox(Color textColor, Color subColor) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 20.h),
child: Column(
children: [
Text(_error ?? 'settingReadFailed'.tr,
style: TextStyle(fontSize: 14.sp, color: textColor)),
SizedBox(height: 10.h),
TextButton(onPressed: _query, child: Text('retry'.tr)),
],
),
);
}
/// 固定顺序:启停 → 音量 → 亮度 → 行车感应 → 摇摆 → 随机表情频次 → 随机来一个。
/// items 里没有的项不画。
Widget _controls(Color textColor, Color subColor) {
final items = _items!;
final rows = <Widget>[];
void toggleRow(String itemKey, String featureKey, String titleKey,
IconData icon) {
final it = items[itemKey];
if (it is! Map) return;
final on = it['on'] == true;
rows.add(_toggleTile(
icon: icon,
title: titleKey.tr,
value: on,
busy: _busy.contains(featureKey),
onChanged: (v) => _toggle(itemKey, featureKey, v),
textColor: textColor,
subColor: subColor,
));
}
void sliderRow(String itemKey, String featureKey, String titleKey,
IconData icon) {
final it = items[itemKey];
if (it is! Map) return;
final min = (it['min'] as num?)?.toInt() ?? 0;
final max = (it['max'] as num?)?.toInt() ?? 10;
final cur = _sliderLocal[itemKey] ?? (it['value'] as num?)?.toInt() ?? 0;
rows.add(_sliderTile(
icon: icon,
title: titleKey.tr,
valueLabel: '$cur',
value: cur.clamp(min, max).toDouble(),
min: min.toDouble(),
max: max.toDouble(),
divisions: (max - min).clamp(1, 100),
onChanged: (v) => _slide(itemKey, featureKey, v.round(), 'value'),
onChangeEnd: (v) =>
_slide(itemKey, featureKey, v.round(), 'value', flush: true),
textColor: textColor,
subColor: subColor,
));
}
toggleRow('power', DeviceFeatures.settingsPower, 'settingPower',
Icons.power_settings_new);
sliderRow('volume', DeviceFeatures.settingsVolume, 'settingVolume',
Icons.volume_up);
sliderRow('brightness', DeviceFeatures.settingsBrightness,
'settingBrightness', Icons.brightness_6);
toggleRow('driving_sense', DeviceFeatures.settingsDrivingSense,
'settingDrivingSense', Icons.screen_rotation);
toggleRow('swing', DeviceFeatures.settingsSwing, 'settingSwing',
Icons.settings_remote);
final rei = items['random_expression_interval'];
if (rei is Map) {
final options = ((rei['options'] as List?) ?? const [20, 180])
.map((e) => (e as num).toInt())
.toList()
..sort();
final cur = _sliderLocal['random_expression_interval'] ??
(rei['value'] as num?)?.toInt() ??
options.first;
// 用档位索引当滑块值,吸附天然成立;显示秒数
var idx = options.indexOf(cur);
if (idx < 0) {
idx = 0;
for (var i = 0; i < options.length; i++) {
if ((options[i] - cur).abs() < (options[idx] - cur).abs()) idx = i;
}
}
rows.add(_sliderTile(
icon: Icons.emoji_emotions_outlined,
title: 'settingRandomExpressionInterval'.tr,
valueLabel: 'settingSeconds'.trParams({'n': '${options[idx]}'}),
value: idx.toDouble(),
min: 0,
max: (options.length - 1).toDouble(),
divisions: (options.length - 1).clamp(1, 100),
onChanged: (v) => _slide('random_expression_interval',
DeviceFeatures.settingsRandomExpressionInterval,
options[v.round()], 'seconds'),
onChangeEnd: (v) => _slide('random_expression_interval',
DeviceFeatures.settingsRandomExpressionInterval,
options[v.round()], 'seconds',
flush: true),
textColor: textColor,
subColor: subColor,
));
}
if (items['play_random_expression'] is Map) {
final busy = _busy.contains(DeviceFeatures.settingsPlayRandomExpression);
rows.add(Padding(
padding: EdgeInsets.only(top: 6.h),
child: SizedBox(
width: double.infinity,
height: 44.h,
child: OutlinedButton.icon(
onPressed: busy ? null : _playRandomExpression,
icon: Icon(Icons.casino_outlined, size: 18.sp),
label: Text('settingPlayRandomExpression'.tr,
style:
TextStyle(fontSize: 14.sp, fontWeight: FontWeight.w600)),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF6366F1),
side: const BorderSide(color: Color(0xFF6366F1)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.r)),
),
),
),
));
}
if (rows.isEmpty) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 20.h),
child: Center(
child: Text('settingNoItems'.tr,
style: TextStyle(fontSize: 14.sp, color: subColor)),
),
);
}
return Column(children: rows);
}
Widget _toggleTile({
required IconData icon,
required String title,
required bool value,
required bool busy,
required ValueChanged<bool> onChanged,
required Color textColor,
required Color subColor,
}) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 6.h),
child: Row(
children: [
Icon(icon, size: 20.sp, color: subColor),
SizedBox(width: 12.w),
Expanded(
child: Text(title,
style: TextStyle(fontSize: 15.sp, color: textColor)),
),
Switch.adaptive(
value: value,
onChanged: busy ? null : onChanged,
activeThumbColor: const Color(0xFF6366F1),
),
],
),
);
}
Widget _sliderTile({
required IconData icon,
required String title,
required String valueLabel,
required double value,
required double min,
required double max,
required int divisions,
required ValueChanged<double> onChanged,
required ValueChanged<double> onChangeEnd,
required Color textColor,
required Color subColor,
}) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4.h),
child: Column(
children: [
Row(
children: [
Icon(icon, size: 20.sp, color: subColor),
SizedBox(width: 12.w),
Expanded(
child: Text(title,
style: TextStyle(fontSize: 15.sp, color: textColor)),
),
Text(valueLabel,
style: TextStyle(
fontSize: 13.sp,
color: subColor,
fontFeatures: const [FontFeature.tabularFigures()])),
],
),
SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: const Color(0xFF6366F1),
thumbColor: const Color(0xFF6366F1),
overlayColor: const Color(0xFF6366F1).withValues(alpha: 0.12),
inactiveTrackColor: subColor.withValues(alpha: 0.2),
),
child: Slider(
value: value.clamp(min, max),
min: min,
max: max,
divisions: divisions,
onChanged: onChanged,
onChangeEnd: onChangeEnd,
),
),
],
),
);
}
}

3
apps/client/lib/modules/settings/controllers/settings_controller.dart

@ -336,7 +336,8 @@ class SettingsController extends GetxController {
);
final versionService = VersionUpdateService.to;
final updateInfo = await versionService.checkForUpdate();
// 用户主动点的检查要真的发请求,不吃 10 分钟缓存
final updateInfo = await versionService.checkForUpdate(bypassCache: true);
// 关闭加载提示 - 使用精确的关闭方式
if (dialogShown) {

7
apps/client/lib/modules/splash/controllers/splash_controller.dart

@ -12,6 +12,7 @@ import '../../../data/models/user_Info.dart';
import '../../../routes/app_routes.dart';
import '../../../data/services/network/api.dart';
import '../../../data/services/network/dio_manager.dart';
import '../../../data/models/channel_app_model.dart';
import '../../../data/services/version_update_service.dart';
import '../../../modules/common/widgets/version_update_dialog.dart';
import '../../../modules/common/widgets/privacy_policy_dialog.dart';
@ -242,6 +243,12 @@ class SplashController extends GetxController {
if (updateInfo.hasUpdate) {
Logger.i(_tag, '发现新版本: ${updateInfo.latestVersion}');
// 推荐更新被用户「稍后提醒」过:同一版本 24 小时内不再弹。强更不受影响。
if (updateInfo.updateType == UpdateType.optional &&
versionService.isSnoozed(updateInfo.latestVersion)) {
Logger.i(_tag, '该版本已被稍后提醒,本次不弹');
return;
}
// 等待到下一微任务,降低与其它 UI 的并发竞争
await Future.delayed(Duration.zero);
try {

38
apps/client/lib/modules/translation/controllers/translation_controller.dart

@ -62,7 +62,7 @@ enum AudioOutputDevice {
/// - [device]:耳机左右耳按键触发,走**设备端收音**(BLE EXTERNAL 音频),
/// 按住说话、松手即停,方向由按的是哪只耳朵决定。
///
/// 设备端那条链路([FaceToFaceBleService])目前仍在等固件的 UUID 与命令,
/// 设备端那条链路(双耳独立 BLE,见 docs/face_to_face_ble_protocol.md v0.2)仍在等固件确认,
/// 所以默认是 [client];等 [setActiveSpeaker] 被耳机按键事件调用时会自动切到
/// [device],业务逻辑无需再改。
enum FaceToFaceTrigger { client, device }
@ -1064,7 +1064,20 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
}
/// 初始化通话模式的语音翻译服务
Future<void> _initializeCallModeTranslationService({bool isRecovery = false}) async {
/// 通话模式初始化的串行链:快速开关 + 切语言会让两条初始化并发(2026-09-23 Redmi K90
/// 真机复现:后一条的 dispose 撞上前一条在途的原生 initialize,前一条返回 false,
/// 接着被默认参数的补初始化换成 azure/中英,德语、意大利语一开就"卡死")。
/// 这里让后来的等前一条整个跑完再开始;服务层 AzureAstService 还有一道同样的闸。
Future<void> _callInitChain = Future<void>.value();
Future<void> _initializeCallModeTranslationService({bool isRecovery = false}) {
final next = _callInitChain.then(
(_) => _initializeCallModeTranslationServiceInner(isRecovery: isRecovery));
_callInitChain = next.catchError((_) {});
return next;
}
Future<void> _initializeCallModeTranslationServiceInner({bool isRecovery = false}) async {
try {
Logger.info('开始初始化通话模式语音翻译服务');
@ -1218,7 +1231,10 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
// lib/modules 不允许依赖厂商插件,device_hub_boundary_test 会 fail。
final dl = await _callSession?.diagnostics() ?? const {};
if (dl.isNotEmpty) {
Logger.w('Translation', '[DOWNLINK] 取样($why): $dl');
// 顺带记后台保活的静音循环此刻在不在播:它一停(比如被电话打断),
// iOS 就把 20ms 定时器合并成 60~120ms 一次,gapAvgMs 会跟着涨
Logger.w('Translation',
'[DOWNLINK] 取样($why): $dl keepalive=${BackgroundKeepalive.isPlaying}/${BackgroundKeepalive.refs}');
}
} catch (e) {
Logger.w('Translation', '[CALL-BRIDGE] 取样($why) 失败: $e');
@ -1428,6 +1444,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
if (!await _checkPermissions()) return;
if (isRecognizing.value) return;
// 上一次点击还在准备中(等初始化 / 开桥),再点一次会起第二条初始化与第一条并发。
if (isPreparing.value) {
Logger.w('Translation', '识别正在准备中,忽略重复的开始请求');
return;
}
// ⚠️ 必须在这里点亮「准备中」:view 早就绑了 isPreparing 做按钮的缩放/阴影
// 反馈,但一直没人把它设成 true——点下去到真正开始识别之间毫无视觉变化,
@ -1678,7 +1699,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
/// PCM 由 BLE 喂给 ASR(原生侧的 EXTERNAL 音频源)。
/// - [FaceToFaceTrigger.client]:App 界面触发 → 手机麦克风(原生侧 MICROPHONE)。
///
/// 设备端那条链路还在等固件(见 [FaceToFaceBleService] 的 TODO),
/// 设备端那条链路还在等固件(见 docs/face_to_face_ble_protocol.md §14),
/// 所以当前实际生效的是手机麦克风这一路。
Future<void> _configureFaceToFaceMode() async {
_audioSourceType = _isFaceToFaceDeviceMode;
@ -1820,6 +1841,15 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
/// AST.A TTS → callLegA(对端听)
/// AST.B TTS → callLegB(己方听)
Future<void> _attachBesCallTranslationBridge() async {
// 开桥之前必须等通话模式初始化真正落地:startRecognition 等 _modeInitFuture 最多 3 秒就
// 往下走,而阿里两路初始化本身要 1.2 秒以上、前面还可能排着一次 dispose——桥先开、
// startContinuousTranslation 先发,原生那边会话还没建好(2026-09-23 K90 真机日志:
// 桥开在 17:23:05.71,原生初始化 17:23:06.23 才开始)。这里给 10 秒上限,超时只记日志。
try {
await _callInitChain.timeout(const Duration(seconds: 10));
} on TimeoutException {
Logger.w('Translation', '[CALL-BRIDGE] 等通话模式初始化超过 10s,继续开桥');
} catch (_) {}
await _detachBesCallTranslationBridge(closeSinks: false);
final session = _callSession;
if (session == null) return;

6
apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AliyunBailianE2EHelper.kt

@ -475,6 +475,10 @@ class AliyunBailianE2EHelper(
/**
* 将 24kHz PCM 音频重采样为 16kHz
*/
/** 跨块连续 + 抗混叠的 3:2 重采样,替代下面逐块线性插值的老实现(保留作对照) */
private val resampler = Resampler24kTo16k()
@Suppress("unused")
private fun resample24kTo16k(input: ByteArray): ByteArray {
// 16 bit PCM
val inputShorts = ShortArray(input.size / 2)
@ -643,7 +647,7 @@ class AliyunBailianE2EHelper(
val audioBytes = android.util.Base64.decode(base64Audio, android.util.Base64.DEFAULT)
if (audioBytes != null && audioBytes.isNotEmpty()) {
// 默认输出 24k,需重采样到 16k
val resampled = resample24kTo16k(audioBytes)
val resampled = resampler.process(audioBytes)
try {
fullAudioBuffer.write(resampled)

9
apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt

@ -1912,15 +1912,24 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
)
GlobalScope.launch(Dispatchers.Main) {
// 被更新的 initialize/dispose 取代时只放弃自己这一代,并把原因写进日志:
// 2026-09-23 K90 真机上这条 false 回到 Dart 后曾被当成「未初始化」,
// 触发按默认参数(azure/中英)的兜底初始化,把阿里德语会话整个换掉。
// Dart 侧现已不再用默认参数兜底,这里的 false 只表示「本次作废」。
if (!isAstInitGenerationCurrent(initGeneration)) {
FileLogger.w(tag, "AST 初始化 gen=$initGeneration 在 A 路之前被新一代取代,本次作废")
result.success(false); return@launch
}
val initA = bailianAstHelperA?.initialize(bailianConfigA, callbackA) ?: false
delay(1200L)
if (!isAstInitGenerationCurrent(initGeneration)) {
FileLogger.w(tag, "AST 初始化 gen=$initGeneration 在 A/B 间隔期被新一代取代,本次作废(A 路已建,交由新一代重建)")
result.success(false); return@launch
}
val initB = bailianAstHelperB?.initialize(bailianConfigB, callbackB) ?: false
if (!(initA && initB)) {
FileLogger.w(tag, "AST 初始化 gen=$initGeneration 结果 A=$initA B=$initB")
}
markAstBridgeReady()
result.success(initA && initB)
}

4
apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/BesCallPcmBridge.kt

@ -46,6 +46,9 @@ class BesCallPcmBridge(
// 以下只在 ingress 线程上读写
private var ready = false
private val gate = PeerSpeechGate()
/** ⚠️ 对照实验开关(2026-09-23):true 时 B 路不过门限、逐帧直推,即 deepvoice 口径。测完改回 false。 */
private val deepvoiceParity = false
private val holdA = ArrayDeque<ByteArray>()
private val holdB = ArrayDeque<ByteArray>()
private var framesA = 0L
@ -156,6 +159,7 @@ class BesCallPcmBridge(
return
}
framesB++
if (deepvoiceParity) { deliverB(pcm); return }
val wasOpen = gate.isOpen
val frames = gate.accept(pcm)
if (frames.isEmpty()) gatedB++

73
apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/Resampler24kTo16k.kt

@ -0,0 +1,73 @@
package com.yunqiinnovation.azure_speech
/**
* 24 kHz → 16 kHz 重采样(比 3:2),**跨块连续、带抗混叠低通**。
*
* 2026-09-23 之前用的是逐块独立的线性插值:每块相位从 0 重算、没有滤波,
* 8 kHz 以上的内容折回带内,块与块之间还有微小跳变——通话翻译耳机里"偶尔杂音"的嫌疑之一。
*
* 实现:零插值到 48 kHz → 64 阶窗函数 sinc 低通(截止 7.4 kHz,Hamming)→ 每 3 个取 1 个。
* 只在 (k-j) 为偶数的抽头上做乘加(零插值的奇数位恒为 0),每个输出样本 32 次乘加。
* 系数已含 ×2 的零插值增益。Python 复刻校验:1k/3k/6k 正弦幅度 0.98/0.98/0.93,频率不变。
*
* 一个实例只服务一路(有状态:保留最后 L/2 个输入样本与 48k 网格上的相位)。
*/
class Resampler24kTo16k {
private val hist = ShortArray(HALF) // 上一块的尾巴(最近 HALF 个输入样本,旧→新)
private var histLen = 0
private var k = 0L // 下一个输出对应的 48k 网格位置(全局)
private var consumed = 0L // 已进入 hist 之前的输入总样本数(全局索引基准)
fun reset() { histLen = 0; k = 0L; consumed = 0L }
/** 输入 16bit 小端 PCM @24k,返回 16bit 小端 PCM @16k。 */
fun process(input: ByteArray): ByteArray {
val n = input.size / 2
if (n == 0) return ByteArray(0)
// 拼成 [hist | new],下标 i 对应全局输入索引 base + i
val buf = ShortArray(histLen + n)
System.arraycopy(hist, 0, buf, 0, histLen)
var i = 0
while (i < n) {
buf[histLen + i] = ((input[i * 2].toInt() and 0xFF) or (input[i * 2 + 1].toInt() shl 8)).toShort()
i++
}
val base = consumed - histLen
val lastGlobal = consumed + n - 1 // 当前可用的最大全局输入索引
val out = java.io.ByteArrayOutputStream(n * 2 * 2 / 3 + 4)
// 输出 m 对应 48k 网格 k=3m;需要输入索引 (k-j)/2,j∈[0,L),最大为 k/2(要求 ≤ lastGlobal)
while (k / 2 <= lastGlobal) {
var acc = 0.0
var j = if (k % 2 == 0L) 0 else 1 // 让 (k-j) 为偶数
while (j < TAPS) {
val idx = (k - j) / 2 - base
if (idx >= 0 && idx < buf.size) acc += H[j] * buf[idx.toInt()]
j += 2
}
val v = acc.toInt().coerceIn(-32768, 32767)
out.write(v and 0xFF); out.write((v shr 8) and 0xFF)
k += 3
}
// 留下最后 HALF 个输入样本供下一块的抽头引用
val keep = minOf(HALF, buf.size)
System.arraycopy(buf, buf.size - keep, hist, 0, keep)
histLen = keep
consumed += n
return out.toByteArray()
}
companion object {
private const val TAPS = 64
private const val HALF = TAPS / 2 + 2
private val H = doubleArrayOf(
-0.001272, -0.001643, -0.000571, 0.001391, 0.002686, 0.001673, -0.001674, -0.004812,
-0.004142, 0.001400, 0.007913, 0.008684, 0.000477, -0.011471, -0.015874, -0.005303,
0.014505, 0.026149, 0.014823, -0.015487, -0.040008, -0.031737, 0.011975, 0.058826,
0.062003, 0.001164, -0.088277, -0.126869, -0.043596, 0.165759, 0.419929, 0.593380,
0.593380, 0.419929, 0.165759, -0.043596, -0.126869, -0.088277, 0.001164, 0.062003,
0.058826, 0.011975, -0.031737, -0.040008, -0.015487, 0.014823, 0.026149, 0.014505,
-0.005303, -0.015874, -0.011471, 0.000477, 0.008684, 0.007913, 0.001400, -0.004142,
-0.004812, -0.001674, 0.001673, 0.002686, 0.001391, -0.000571, -0.001643, -0.001272,
)
}
}

5
apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AliyunBailianE2EHelper.swift

@ -389,6 +389,9 @@ class AliyunBailianE2EHelper: NSObject, URLSessionWebSocketDelegate {
/**
* 将 24kHz PCM 重采样为 16kHz
*/
/// 跨块连续 + 抗混叠的 3:2 重采样,替代下面逐块线性插值的老实现(保留作对照)
private let resampler = Resampler24kTo16k()
private func resample24kTo16k(_ input: Data) -> Data {
if input.count < 4 { return input }
@ -545,7 +548,7 @@ class AliyunBailianE2EHelper: NSObject, URLSessionWebSocketDelegate {
case "response.audio.delta":
if let base64Audio = obj["delta"] as? String, !base64Audio.isEmpty {
if let audioBytes = Data(base64Encoded: base64Audio) {
let resampled = resample24kTo16k(audioBytes)
let resampled = resampler.process(audioBytes)
if !resampled.isEmpty {
fullAudioBuffer.append(resampled)
// 极高频(音频帧),取消 info 日志

13
apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift

@ -1531,7 +1531,12 @@ private func sendAudioDataEvent(_ event: [String: Any]) {
bailianCallbackB = cbB
Task {
// 被更新的 initialize/dispose 取代时只放弃自己这一代,并把原因写进日志(与 Android 同口径):
// 2026-09-23 Redmi K90 上这条 false 回到 Dart 后曾被当成「未初始化」,触发按默认参数
// (azure/中英)的兜底初始化,把阿里德语会话整个换掉。Dart 侧现已不再用默认参数兜底,
// 这里的 false 只表示「本次作废」。release 包只收得到 os_log,级别用 .default 才看得见。
if !self.isAstInitGenerationCurrent(initGeneration) {
os_log("[AST-INIT] Alibaba gen=%d 在 A 路之前被新一代取代,本次作废", log: self.ctLog, type: .default, initGeneration)
DispatchQueue.main.async { result(false) }
return
}
@ -1539,13 +1544,19 @@ private func sendAudioDataEvent(_ event: [String: Any]) {
let rA = await bailianAstHelperA.initialize(config: cfgA, cb: cbA)
os_log("[AST-INIT] Alibaba: A service result=%d, initializing B service...", log: self.ctLog, type: .info, rA ? 1 : 0)
if !self.isAstInitGenerationCurrent(initGeneration) {
os_log("[AST-INIT] Alibaba gen=%d 在 A/B 间隔期被新一代取代,本次作废(A 路已建,交由新一代重建)", log: self.ctLog, type: .default, initGeneration)
DispatchQueue.main.async { result(false) }
return
}
let rB = await bailianAstHelperB.initialize(config: cfgB, cb: cbB)
os_log("[AST-INIT] Alibaba: B service result=%d. Both services ready.", log: self.ctLog, type: .info, rB ? 1 : 0)
if !(rA && rB) {
// 原先无论 A/B 成败都回 true,Dart 以为已就绪、桥照开,实际一路是空的。
// 现在按真实结果回;Dart 拿到 false 会按上次参数补初始化(不会退回默认值)。
os_log("[AST-INIT] Alibaba gen=%d 结果 A=%d B=%d", log: self.ctLog, type: .error, initGeneration, rA ? 1 : 0, rB ? 1 : 0)
}
self.markAstBridgeReady()
DispatchQueue.main.async { result(true) }
DispatchQueue.main.async { result(rA && rB) }
}
} else {
markAstBridgeReady()

67
apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/Resampler24kTo16k.swift

@ -0,0 +1,67 @@
import Foundation
/// 24 kHz → 16 kHz 重采样(比 3:2),**跨块连续、带抗混叠低通**。Android `Resampler24kTo16k.kt` 的对应实现。
///
/// 2026-09-23 之前用的是逐块独立的线性插值:每块相位从 0 重算、没有滤波,
/// 8 kHz 以上的内容折回带内,块与块之间还有微小跳变——通话翻译耳机里"偶尔杂音"的嫌疑之一。
///
/// 实现:零插值到 48 kHz → 64 阶窗函数 sinc 低通(截止 7.4 kHz,Hamming)→ 每 3 个取 1 个。
/// 只在 (k-j) 为偶数的抽头上做乘加,每个输出样本 32 次乘加。系数已含 ×2 的零插值增益。
/// 一个实例只服务一路(有状态)。
final class Resampler24kTo16k {
private static let taps = 64
private static let half = taps / 2 + 2
private static let h: [Double] = [
-0.001272, -0.001643, -0.000571, 0.001391, 0.002686, 0.001673, -0.001674, -0.004812,
-0.004142, 0.001400, 0.007913, 0.008684, 0.000477, -0.011471, -0.015874, -0.005303,
0.014505, 0.026149, 0.014823, -0.015487, -0.040008, -0.031737, 0.011975, 0.058826,
0.062003, 0.001164, -0.088277, -0.126869, -0.043596, 0.165759, 0.419929, 0.593380,
0.593380, 0.419929, 0.165759, -0.043596, -0.126869, -0.088277, 0.001164, 0.062003,
0.058826, 0.011975, -0.031737, -0.040008, -0.015487, 0.014823, 0.026149, 0.014505,
-0.005303, -0.015874, -0.011471, 0.000477, 0.008684, 0.007913, 0.001400, -0.004142,
-0.004812, -0.001674, 0.001673, 0.002686, 0.001391, -0.000571, -0.001643, -0.001272,
]
private var hist = [Int16](repeating: 0, count: Resampler24kTo16k.half)
private var histLen = 0
private var k: Int64 = 0 // 下一个输出对应的 48k 网格位置(全局)
private var consumed: Int64 = 0 // 已进入 hist 之前的输入总样本数(全局索引基准)
func reset() { histLen = 0; k = 0; consumed = 0 }
/// 输入 16bit 小端 PCM @24k,返回 16bit 小端 PCM @16k。
func process(_ input: Data) -> Data {
let n = input.count / 2
if n == 0 { return Data() }
var buf = [Int16](repeating: 0, count: histLen + n)
for i in 0..<histLen { buf[i] = hist[i] }
input.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in
for i in 0..<n {
let v = UInt16(raw[i * 2]) | (UInt16(raw[i * 2 + 1]) << 8)
buf[histLen + i] = Int16(bitPattern: v)
}
}
let base = consumed - Int64(histLen)
let lastGlobal = consumed + Int64(n) - 1
var out = [Int16]()
out.reserveCapacity(n * 2 / 3 + 2)
let h = Resampler24kTo16k.h
let taps = Resampler24kTo16k.taps
while k / 2 <= lastGlobal {
var acc = 0.0
var j = (k % 2 == 0) ? 0 : 1 // 让 (k-j) 为偶数
while j < taps {
let idx = (k - Int64(j)) / 2 - base
if idx >= 0 && idx < Int64(buf.count) { acc += h[j] * Double(buf[Int(idx)]) }
j += 2
}
out.append(Int16(clamping: Int(acc)))
k += 3
}
let keep = min(Resampler24kTo16k.half, buf.count)
for i in 0..<keep { hist[i] = buf[buf.count - keep + i] }
histLen = keep
consumed += Int64(n)
return out.withUnsafeBufferPointer { Data(buffer: $0) }
}
}

10
apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManagerPlugin.kt

@ -256,13 +256,9 @@ class BluetoothManagerPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
if (data["type"] == "speedAdjustment") {
val raw = data["data"] as? ByteArray
if (raw != null && raw.size > 6) {
val interval = when (raw[6]) {
0x01.toByte() -> 16L
0x02.toByte() -> 20L
0x03.toByte() -> 25L
else -> 20L
}
CallTranslationDownlink.setIntervalMs(interval)
// 帧形状 `bb d6 07 00 02 01 <XX>`,只有 data[6] 一个字节有信息(2026-09-23 抓帧确认)
// 原值交给发送器:01 立刻补一包 + 1.25x 配额,00 补两包,03 扣一包(见 onHeadsetPace)
CallTranslationDownlink.onHeadsetPace(raw[6].toInt() and 0xFF)
}
}
// 没人订阅时不必往主线程 post——通话中每 20ms 就有两帧,空转没意义

251
apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/CallTranslationDownlink.kt

@ -56,16 +56,56 @@ object CallTranslationDownlink {
/** 一帧编码后码流长度 */
private const val FRAME_ENCODED_BYTES = 40
private const val PACKET_SIZE = 84
/**
* 每包装几帧(每帧 20 ms,两路各 40 B)。2026-09-23 从 1 改 3:
* 恒玄 SPP 走 RFCOMM 信用流控,耳机一次只放 1~3 个信用、每帧吃一个;信用一停顿,
* 协议栈把攒下的 6~11 包合成一帧突发,中间 100 多 ms 空档,而耳机播放缓冲只有一两包深,
* 扛不住就是句中杂音。3 帧/包让同样的信用窗口里耳机手上的音频翻三倍(deepvoice iOS 同做法)。
* 布局与 deepvoice 一致:每帧 `[B 40B][A 40B]` 交替,头 `AA 56 <len> 03`。
*/
private const val FRAMES_PER_PACKET = 1 // ⚠️ 2026-09-23 真机试过 3:耳机完全不认(全是杂音),SPP 上只能 1 帧/84B
private const val PACKET_SIZE = 4 + FRAMES_PER_PACKET * 2 * FRAME_ENCODED_BYTES // 3 帧 = 244
// ---- 追赶策略参数 ----
private const val CATCHUP_START_SEC = 2.0
private const val CATCHUP_FULL_SEC = 4.0
private const val CATCHUP_START_SEC = 1.0
private const val CATCHUP_FULL_SEC = 2.5
private const val MAX_RATE = 1.3
private const val SILENCE_TRIM_START_SEC = 1.0
private const val SILENCE_TRIM_START_SEC = 0.5
private const val SILENCE_KEEP_MS = 150
private const val SILENCE_RMS = 300.0
private const val HARD_CAP_SEC = 0.0
private const val HARD_CAP_SEC = 30.0
// ---- 2026-09-23 杂音修正(真机:定时器修成固定节拍后仍有零星杂音)----
/** 一条腿从空到开始播,至少先攒这么多毫秒:TTS 是按块流来的,起播太早会在句中断粮 */
private const val PRIME_MS = 120
/** 这么久没新数据才认为这句结束,允许 WSOLA 补零把尾巴冲出来;之前是一断粮就补零,
* 新块到了又把零"夹"进内容里,等于在语音里嵌一个 20 多 ms 的空洞 */
private const val FLUSH_IDLE_MS = 150
/** 速率每帧最多变这么多,避免 1.0→1.3 一步跳过去 */
private const val RATE_STEP = 0.03
/** 裁静音回滞:低于 SILENCE_RMS 进入裁剪,高于它才退出;否则门限附近一块留一块丢,接缝密集 */
private const val SILENCE_RMS_EXIT = 600.0
/** 裁剪接缝处的淡入长度(样本)≈3ms */
private const val RAMP_SAMPLES = 48
// ---- 2026-09-23 耳机缓冲见底修正 ----
/** 定时器永远这个节拍,16/25 ms 的要求改成配额(每拍 20/interval 包),不再取消重排定时器 */
private const val TICK_MS = 20L * FRAMES_PER_PACKET // 一拍一包
/** 空闲后恢复发包:先一次性多塞这么多包给耳机垫底(耳机每次从空缓冲起播都回 data6=00)*/
private const val PREROLL_PACKETS = 4 // 静音垫底 4 包 = 80ms
/** 两路都空后继续用编码静音续多少拍再真正停发:短句间隔不把耳机缓冲榨干;
* 不无限续是怕耳机把持续下行当成"译文在播"而压低通话音(未验证,从严)*/
private const val TAIL_TICKS = 25 // ≈500ms
/** 一拍里最多发几包(配额 + 垫底叠加时的上限)*/
private const val MAX_PACKETS_PER_TICK = 6
/**
* ⚠️ 对照实验开关(2026-09-23,只为打一版"deepvoice 口径"的测试包):true 时
* 不裁静音、恒 1.0x、队列 4096 帧丢最旧、不打积压日志——即 8-29 最初版 / deepvoice 的行为。
* 测完改回 false。别带着 true 提交。
*/
private const val DEEPVOICE_PARITY = false
private const val PARITY_QUEUE_CAP_FRAMES = 4096
/** 一条腿:源 PCM 队列 + 变速器 + 编码器 + 统计。所有字段只在 synchronized(this) 里动。 */
private class Leg(val name: String) {
@ -74,6 +114,13 @@ object CallTranslationDownlink {
var partial = ShortArray(FRAME_SAMPLES) // 不满 20ms 的尾巴
var partialLen = 0
var silentRun = 0 // 队尾连续静音样本数(裁静音用)
var trimming = false // 裁静音回滞状态
var rampPending = false // 刚裁掉一段,下一块要淡入
var lastPushNs = 0L // 最近一次收到源 PCM 的时刻
var rateCur = 1.0 // 平滑后的当前速率
var starved = 0L // 句中断粮次数(正在播却不够一帧、又没到收尾)
var playing = false // 这一句已经起播(起播缓冲只在句首用一次)
var lastHoleLogNs = 0L
val stretcher = Wsola()
var encHandle = 0L
@ -91,6 +138,7 @@ object CallTranslationDownlink {
fun reset() {
src.clear(); srcSamples = 0; partialLen = 0; silentRun = 0
trimming = false; rampPending = false; lastPushNs = 0L; rateCur = 1.0; starved = 0; playing = false
stretcher.reset()
pushedFrames = 0; sentFrames = 0; trimmedSamples = 0; droppedSamples = 0
maxBacklogSamples = 0; lastRate = 1.0
@ -113,6 +161,11 @@ object CallTranslationDownlink {
private var sentPackets = 0L
/** 发包配额(包数,可带小数),每拍加 TICK/interval */
private var budget = 0.0
private var wasIdle = true
private var tailLeft = 0
/** 静音帧(deepvoice 原样照搬,耳机侧认这个码流) */
private val silenceFrame = byteArrayOf(
0x0A.toByte(), 0x8E.toByte(), 0x30.toByte(), 0xBD.toByte(), 0x3F.toByte(),
@ -134,8 +187,11 @@ object CallTranslationDownlink {
if (leg.encHandle == 0L) leg.encHandle = G722Codec.init(SAMPLE_RATE)
}
sentPackets = 0L
// 每场从 20 ms 起步:上一场结束时耳机可能停在 16 ms,带着它起步会先超发一段
intervalMs = 20L
budget = 0.0; wasIdle = true; tailLeft = 0
scheduleSendTask()
Log.d(TAG, "下行发送启动, interval=${intervalMs}ms 追赶策略: >${CATCHUP_START_SEC}s 起变速, ${CATCHUP_FULL_SEC}s 达 ${MAX_RATE}x, 裁静音 >${SILENCE_TRIM_START_SEC}s")
if (!DEEPVOICE_PARITY) Log.d(TAG, "下行发送启动, interval=${intervalMs}ms 追赶策略: >${CATCHUP_START_SEC}s 起变速, ${CATCHUP_FULL_SEC}s 达 ${MAX_RATE}x, 裁静音 >${SILENCE_TRIM_START_SEC}s")
}
@Synchronized
@ -154,14 +210,16 @@ object CallTranslationDownlink {
leg.encHandle = 0L
}
}
if (!DEEPVOICE_PARITY) {
Log.d(TAG, "下行发送停止, 共发出 $sentPackets 包 (pushA=${legA.pushedFrames} pushB=${legB.pushedFrames})")
Log.w(TAG, "下行积压总账 A{$a} B{$b}")
}
}
private fun summary(leg: Leg): String = synchronized(leg) {
val pushedSec = leg.pushedFrames * 0.02
val sentSec = leg.sentFrames * 0.02
"源=%.1fs 播出=%.1fs 最大积压=%.1fs 裁静音=%.1fs 变速追回=%.1fs 兜底丢=%.1fs".format(
"源=%.1fs 播出=%.1fs 最大积压=%.1fs 裁静音=%.1fs 变速追回=%.1fs 兜底丢=%.1fs 断粮=${leg.starved}次".format(
pushedSec, sentSec, leg.maxBacklogSamples / SAMPLE_RATE.toDouble(),
leg.trimmedSamples / SAMPLE_RATE.toDouble(),
max(0.0, pushedSec - sentSec - leg.trimmedSamples / SAMPLE_RATE.toDouble() - leg.droppedSamples / SAMPLE_RATE.toDouble() - leg.backlogSec()),
@ -188,20 +246,42 @@ object CallTranslationDownlink {
* 耳机要求调整下行节奏(`0xD6`/`0xE9` 的 data[6])。
* 只在运行中才重排任务,停止状态下仅记住新值。
*/
/**
* 耳机调速帧的 data[6] 原值:0 = 缓冲空/起播,1 = 要快,2 = 正常,3 = 要慢。
*
* 2026-09-23 真机定性:`01` 一次只持续 20 ms 就回 `02`,一场 150 次——它不是在要一个新速率,
* 而是"缓冲比目标低一包了,现在补一包"。所以除了按 16/20/25 ms 调配额(应对持续的 01),
* 收到 01 立刻加一包、00 加两包、03 扣一包,让它一吱就有反应。
*/
@Synchronized
fun onHeadsetPace(code: Int) {
when (code) {
0 -> { budget += 2.0; setIntervalMs(20L) }
1 -> { budget += 1.0; setIntervalMs(16L) }
3 -> { budget -= 1.0; setIntervalMs(25L) }
else -> setIntervalMs(20L)
}
}
@Synchronized
fun setIntervalMs(ms: Long) {
if (intervalMs == ms) return
intervalMs = ms
Log.d(TAG, "下行节奏调整为 ${ms}ms")
if (running) scheduleSendTask()
// 不再取消/重排定时器:重排会让下一包提前或推后一拍,一场 150 次就是 150 次抖动。
// 节奏差异由 onTick 里的配额体现。
}
private fun scheduleSendTask() {
sendTask?.cancel(false)
sendTask = scheduler.scheduleWithFixedDelay(
{ runCatching { sendOnePacket() }.onFailure { Log.w(TAG, "发包异常: ${it.message}") } },
// ⚠️ 必须是固定节拍(AtFixedRate),不能是固定间隔(WithFixedDelay):后者从上一次任务
// **结束**起算,每次 WSOLA + G.722 + 写 socket 的 1~3 ms 全叠进周期,"20 ms"实测 21~23 ms、
// 只有 0.9 倍速。耳机缓冲一直往下掉,只能 60% 的时间要 16 ms 来补,两边每 80 ms 拉锯一次,
// 缓冲贴着下水位走、偶尔见底就是杂音(2026-09-23 真机,协议栈写出间隔分布定性)。
sendTask = scheduler.scheduleAtFixedRate(
{ runCatching { onTick() }.onFailure { Log.w(TAG, "发包异常: ${it.message}") } },
0,
intervalMs,
TICK_MS,
TimeUnit.MILLISECONDS
)
}
@ -233,8 +313,15 @@ object CallTranslationDownlink {
l.partialLen = 0
l.pushedFrames++
if (!trimSilence(l, block)) {
if (l.rampPending) { rampIn(block); l.rampPending = false }
l.src.addLast(block)
l.srcSamples += FRAME_SAMPLES
l.lastPushNs = System.nanoTime()
if (DEEPVOICE_PARITY && l.src.size > PARITY_QUEUE_CAP_FRAMES) {
// deepvoice / 最初版口径:满 4096 帧丢最旧,无声无息
l.srcSamples -= l.src.removeFirst().size
l.droppedSamples += FRAME_SAMPLES
}
}
}
}
@ -256,24 +343,38 @@ object CallTranslationDownlink {
/** 积压时把长静音裁到只剩 SILENCE_KEEP_MS;返回 true 表示这一块被裁掉了。 */
private fun trimSilence(l: Leg, block: ShortArray): Boolean {
if (DEEPVOICE_PARITY) return false
var acc = 0.0
for (s in block) acc += s.toDouble() * s.toDouble()
val rms = sqrt(acc / block.size)
if (rms >= SILENCE_RMS) {
// 回滞:进入裁剪要低于 SILENCE_RMS,退出要高于 SILENCE_RMS_EXIT
val silent = if (l.trimming) rms < SILENCE_RMS_EXIT else rms < SILENCE_RMS
if (!silent) {
l.silentRun = 0
l.trimming = false
return false
}
l.silentRun += block.size
val keep = SILENCE_KEEP_MS * SAMPLE_RATE / 1000
if (l.silentRun > keep && l.backlogSec() > SILENCE_TRIM_START_SEC) {
l.trimmedSamples += block.size
l.trimming = true
l.rampPending = true
return true
}
return false
}
/** 裁剪接缝处的短淡入,压掉接缝的阶跃 */
private fun rampIn(block: ShortArray) {
val n = min(RAMP_SAMPLES, block.size)
var i = 0
while (i < n) { block[i] = (block[i] * i / n).toShort(); i++ }
}
/** 按积压深度算这一帧的播放速率。 */
private fun rateFor(backlogSec: Double): Double = when {
DEEPVOICE_PARITY -> 1.0
backlogSec <= CATCHUP_START_SEC -> 1.0
backlogSec >= CATCHUP_FULL_SEC -> MAX_RATE
else -> 1.0 + (MAX_RATE - 1.0) * (backlogSec - CATCHUP_START_SEC) / (CATCHUP_FULL_SEC - CATCHUP_START_SEC)
@ -283,9 +384,37 @@ object CallTranslationDownlink {
private fun nextEncodedFrame(l: Leg): ByteArray? = synchronized(l) {
if (l.encHandle == 0L) return null
val backlog = l.backlogSec()
val rate = rateFor(backlog)
val target = rateFor(backlog)
val rate = l.rateCur + (target - l.rateCur).coerceIn(-RATE_STEP, RATE_STEP)
l.rateCur = rate
l.lastRate = rate
val out = l.stretcher.nextFrame(rate, l) ?: return null
// 这句是否已经结束:够久没新数据。结束了才允许补零收尾;没结束就宁可空一拍也不把零夹进内容
val idleMs = if (l.lastPushNs == 0L) Long.MAX_VALUE else (System.nanoTime() - l.lastPushNs) / 1_000_000
val flushAllowed = idleMs >= FLUSH_IDLE_MS
val unreadInStretcher = l.stretcher.unreadSamples()
val available = unreadInStretcher + l.srcSamples
if (!flushAllowed) {
// 起播门槛:空腿要先攒够 PRIME_MS;播着的腿要够一整帧(含 WSOLA 前视)才动
val needForFrame = (FRAME_SAMPLES * rate).toLong() + l.stretcher.lookahead()
// 起播缓冲只在句首(还没 playing)用一次。句中队列空了不能再要求攒 120ms——
// 变速追赶时消耗比云端到达快,队列中途归零很常见,再攒一次就是在句中硬插 120ms 静音。
val priming = !l.playing && unreadInStretcher == 0L && l.srcSamples < PRIME_MS * SAMPLE_RATE / 1000
if (priming) return null
if (available < needForFrame) {
if (l.playing) {
l.starved++
val now = System.nanoTime()
if (now - l.lastHoleLogNs > 1_000_000_000L) {
l.lastHoleLogNs = now
Log.d(TAG, "${l.name} 路句中空洞: 可用 ${available * 1000 / SAMPLE_RATE}ms < 需 ${needForFrame * 1000 / SAMPLE_RATE}ms,距上次到数据 ${idleMs}ms")
}
}
return null
}
}
val out = l.stretcher.nextFrame(rate, l, allowPad = flushAllowed)
if (out == null) { l.playing = false; return null } // 这句播完(收尾补零也冲完了)
l.playing = true
var i = 0
while (i < FRAME_SAMPLES) {
val v = out[i].toInt()
@ -302,23 +431,91 @@ object CallTranslationDownlink {
if (enc == null || enc.size < FRAME_ENCODED_BYTES) silenceFrame else enc
}
private fun sendOnePacket() {
private val zeroPcm = ByteArray(FRAME_PCM_BYTES)
/** 这一路此刻没内容:用它的编码器编零,码流与前后帧连续;没有编码器才退回静态静音帧 */
private fun idleFrame(l: Leg): ByteArray = synchronized(l) {
if (l.encHandle == 0L) return silenceFrame
val enc = try { G722Codec.encodeSync(l.encHandle, zeroPcm) } catch (_: Exception) { null }
if (enc == null || enc.size < FRAME_ENCODED_BYTES) silenceFrame else enc
}
/** 每 TICK_MS 一拍:按耳机要求的节奏累计配额,配额够一包发一包 */
private fun onTick() {
if (!running) return
val b = nextEncodedFrame(legB)
val a = nextEncodedFrame(legA)
// 两路都没内容就不发,避免通话里灌满无谓的静音包
if (b == null && a == null) return
budget += TICK_MS.toDouble() / intervalMs
var n = 0
while (budget >= 1.0 && n < MAX_PACKETS_PER_TICK) {
if (!emitPacket()) { budget = 0.0; break }
budget -= 1.0
n++
}
}
/** 两路都是编码静音的一包(垫底用) */
private fun writeIdlePacket() {
packetBuf[0] = 0xAA.toByte()
packetBuf[1] = 0x56.toByte()
packetBuf[2] = PACKET_SIZE.toByte()
packetBuf[3] = 0x03.toByte()
for (i in 0 until FRAMES_PER_PACKET) {
val base = 4 + i * 2 * FRAME_ENCODED_BYTES
System.arraycopy(idleFrame(legB), 0, packetBuf, base, FRAME_ENCODED_BYTES)
System.arraycopy(idleFrame(legA), 0, packetBuf, base + FRAME_ENCODED_BYTES, FRAME_ENCODED_BYTES)
}
BluetoothManager.writeRealtime(packetBuf)
sentPackets++
}
/** 发一包;两路都空且尾巴续完时返回 false(真正空闲,不发) */
private val frameB = arrayOfNulls<ByteArray>(FRAMES_PER_PACKET)
private val frameA = arrayOfNulls<ByteArray>(FRAMES_PER_PACKET)
private fun emitPacket(): Boolean {
var any = false
for (i in 0 until FRAMES_PER_PACKET) {
// 编码结果是共享缓冲,必须立刻拷走
frameB[i] = nextEncodedFrame(legB)?.copyOf()
frameA[i] = nextEncodedFrame(legA)?.copyOf()
if (frameB[i] != null || frameA[i] != null) any = true
}
if (!any) {
// 两路都空:先用编码静音续 TAIL_TICKS 拍,让短句间隔不把耳机缓冲榨干;续完才停
if (tailLeft <= 0) { wasIdle = true; return false }
tailLeft--
} else {
tailLeft = TAIL_TICKS
if (wasIdle) {
// 从空闲恢复:耳机是从空缓冲起播的(data6=00),起播那一下最容易破。
// 先一口气塞 PREROLL_PACKETS 包**编码静音**垫底,让它在静音上起播、攒出 80ms 缓冲,
// 真音频排在后面。之前是把前几包真音频当垫底,第一包仍从空缓冲起播——杂音就落在句首。
wasIdle = false
repeat(PREROLL_PACKETS) { writeIdlePacket() }
}
}
packetBuf[0] = 0xAA.toByte()
packetBuf[1] = 0x56.toByte()
packetBuf[2] = PACKET_SIZE.toByte()
packetBuf[3] = 0x03.toByte()
System.arraycopy(b ?: silenceFrame, 0, packetBuf, 4, FRAME_ENCODED_BYTES)
System.arraycopy(a ?: silenceFrame, 0, packetBuf, 44, FRAME_ENCODED_BYTES)
// 空闲那一路用它自己的编码器编一帧零:G.722 是自适应差分编码,塞一段固定码流会让
// 耳机解码器的预测状态跳一下,每次切换都是一个小瞬态
for (i in 0 until FRAMES_PER_PACKET) {
val base = 4 + i * 2 * FRAME_ENCODED_BYTES
System.arraycopy(frameB[i] ?: idleFrame(legB), 0, packetBuf, base, FRAME_ENCODED_BYTES)
System.arraycopy(frameA[i] ?: idleFrame(legA), 0, packetBuf, base + FRAME_ENCODED_BYTES, FRAME_ENCODED_BYTES)
}
BluetoothManager.writeRealtime(packetBuf)
sentPackets++
if (sentPackets == 1L || sentPackets % 100 == 0L) {
Log.d(TAG, "已下发 $sentPackets 包 (积压 A=%.1fs B=%.1fs 速率 A=%.2f B=%.2f)".format(
if (!DEEPVOICE_PARITY && (sentPackets == 1L || sentPackets % 100 == 0L)) {
logProgress()
}
return true
}
private fun logProgress() {
run {
Log.d(TAG, "已下发 $sentPackets 包 (积压 A=%.1fs B=%.1fs 速率 A=%.2f B=%.2f 断粮 A=${legA.starved} B=${legB.starved} 节奏=${intervalMs}ms)".format(
synchronized(legA) { legA.backlogSec() }, synchronized(legB) { legB.backlogSec() },
legA.lastRate, legB.lastRate))
}
@ -337,8 +534,10 @@ object CallTranslationDownlink {
private class Wsola(
private val N: Int = 320,
private val S: Int = 160,
private val T: Int = 96,
private val T: Int = 160, // 搜索窗 ±10ms(原 ±6ms):高速段对齐更准、失真更小
) {
/** 产出一帧输出至少要在输入里看到这么多前视样本 */
fun lookahead(): Long = (T + N).toLong()
private var inBuf = ShortArray(N * 8)
private var inLen = 0
private var anaPos = 0.0
@ -357,7 +556,7 @@ object CallTranslationDownlink {
fun unreadSamples(): Long = max(0L, (inLen - padded - anaPos.roundToInt()).toLong())
/** 取 20ms 输出;返回 null 表示这条腿当前没有东西可播。 */
fun nextFrame(rate: Double, l: Leg): ShortArray? {
fun nextFrame(rate: Double, l: Leg, allowPad: Boolean = true): ShortArray? {
var produced = 0
while (produced < FRAME_SAMPLES) {
val nominal = anaPos.roundToInt()
@ -370,6 +569,8 @@ object CallTranslationDownlink {
// 源队列空了:还有实际音频没播完就补零冲出来,否则这条腿此刻没东西
val realLeft = inLen - padded - nominal
if (produced == 0 && realLeft <= 0) return null
// 句子没结束就不补零(调用方已按可用量把关,走到这里只会是帧中途的极少数情况)
if (!allowPad && produced == 0) return null
val pad = need - inLen
ensure(need); java.util.Arrays.fill(inBuf, inLen, need, 0.toShort()); inLen = need; padded += pad
}

10
apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManagerPlugin.swift

@ -156,14 +156,8 @@ public class BluetoothManagerPlugin: NSObject, FlutterPlugin {
BluetoothManager.translationCallback = { [weak instance] event in
if event["type"] as? String == "speedAdjustment",
let raw = event["data"] as? Data, raw.count > 6 {
let interval: Int
switch raw[6] {
case 0x01: interval = 16
case 0x02: interval = 20
case 0x03: interval = 25
default: interval = 20
}
CallTranslationDownlink.shared.setIntervalMs(interval)
// 原值交给发送器:01 立刻补一包 + 1.25x 配额,00 补两包,03 扣一包(见 onHeadsetPace)
CallTranslationDownlink.shared.onHeadsetPace(Int(raw[6]))
}
guard let plugin = instance, plugin.audioEventSink != nil else { return }
// 音频负载要转成 FlutterStandardTypedData 才能过通道

274
apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/CallTranslationDownlink.swift

@ -10,23 +10,27 @@ import os.log
/// 包格式:`AA 56 54 03 [40B legB][40B legA]` 共 84 字节,mode=0x03 双声道
/// - 槽 0(offset 4) = legB:对端话的译文,**己方听**
/// - 槽 1(offset 44)= legA:己方话的译文,**对端听**
/// 空槽填静音帧,两槽都空则整包不发。
///
/// 一帧固定 640 字节 PCM(320 样本 = 20ms)→ 40 字节码流,
/// 所以默认 20ms 的发送间隔正好是实时速率;耳机用 `0xD6/0xE9`
/// 要求改节奏时(16/20/25ms)调 [setIntervalMs] 跟上。
/// ⚠️ 只能 1 帧/84B:2026-09-23 Android 真机试过 3 帧/244B(deepvoice iOS 的写法),耳机完全不认。
///
/// ## 积压追赶(2026-09-22,与 Android 同一套参数)
///
/// 译文音频是模型**突发**产出的(每 3~6 秒一段文本,音频一次性到),播放却只能 1 倍速;
/// 说话人不停顿时译文时长 ≈ 原话时长,队列只涨不缩——Android 真机压测 A 路积压到 6.2 秒,
/// 用户听到的就是「越说越慢」。处理分两层,都不丢内容:
/// 1. **裁静音**:积压超过 [silenceTrimStartSec] 时,把源音频里超过 [silenceKeepMs] 的静音段
/// 裁到只剩 [silenceKeepMs](TTS 句间常有 200~400ms 停顿),无损。同量压测光这一步就省 10%。
/// 2. **变速不变调追赶**(WSOLA,见 [Wsola]):积压 ≤ [catchupStartSec] 时 1.0x 原样播;
/// 到 [catchupFullSec] 线性提到 [maxRate](1.3x 是语音可懂度的常用上限)。
/// 兜底 [hardCapSec](默认 0 = 关):积压超过它才整段丢最旧的音频——会丢内容,产品上默认不启用。
/// Android 同量压测:最大积压 6.2s → 3.2s,变速最高只用到 1.11x。
/// 译文音频是模型**突发**产出的,播放却只能 1 倍速;说话人不停顿时队列只涨不缩。
/// 处理分两层,都不丢内容:积压 >[silenceTrimStartSec] 裁掉超过 [silenceKeepMs] 的静音;
/// >[catchupStartSec] 起 WSOLA 变速不变调,[catchupFullSec] 达 [maxRate]。
/// 兜底 [hardCapSec]:积压超过它才整段丢最旧的音频,只防链路假死。
///
/// ## 杂音修正(2026-09-23,Android 真机六轮定性,iOS 同步)
///
/// 1. 起播缓冲 [primeMs] **只在句首用一次**;句中队列空了有一帧就接着播——变速追赶时消耗比
/// 云端到达快,队列中途归零很常见,再攒一次就是在句中硬插 120ms 静音(杂音落在句中)。
/// 2. 一句话没结束([flushIdleMs] 内还有新数据)不补零收尾,宁可空一拍也不把零夹进内容。
/// 3. 空闲那一路用它自己的编码器编零(G.722 自适应差分,塞固定码流会让解码器状态跳一下)。
/// 4. 长停顿后恢复发包先塞 [prerollPackets] 包编码静音垫底,让耳机在静音上起播(杂音落在句首);
/// 两路都空后再续 [tailTicks] 拍静音才停,短句间隔不把耳机缓冲榨干。
/// 5. 定时器固定 [tickMs] 节拍**永不重排**;耳机 `0xD6` 的 data[6]:0 补两包 / 1 补一包并 1.25x 配额 /
/// 3 扣一包,见 [onHeadsetPace]。它一次只吱 20ms 就回 02,意思是"缓冲低一包,现在补一包"。
/// 6. 裁静音带回滞([silenceRms] 进 / [silenceRmsExit] 出)并在接缝淡入 [rampSamples];
/// 速率每帧最多变 [rateStep];WSOLA 搜索窗 ±10ms。
public final class CallTranslationDownlink {
public static let shared = CallTranslationDownlink()
@ -38,13 +42,24 @@ public final class CallTranslationDownlink {
private static let packetSize = 84
// ---- 追赶策略参数(与 Android 一致,改一端要同改另一端)----
private static let catchupStartSec = 2.0
private static let catchupFullSec = 4.0
private static let catchupStartSec = 1.0
private static let catchupFullSec = 2.5
private static let maxRate = 1.3
private static let silenceTrimStartSec = 1.0
private static let silenceTrimStartSec = 0.5
private static let silenceKeepMs = 150
private static let silenceRms = 300.0
private static let hardCapSec = 0.0
private static let hardCapSec = 30.0
// ---- 2026-09-23 杂音修正参数(与 Android 一致)----
private static let primeMs = 120
private static let flushIdleMs = 150
private static let rateStep = 0.03
private static let silenceRmsExit = 600.0
private static let rampSamples = 48
private static let tickMs = 20
private static let prerollPackets = 4
private static let tailTicks = 25
private static let maxPacketsPerTick = 6
/// 一条腿:源 PCM 队列 + 变速器 + 编码器 + 统计。只在 [queue] 上访问。
private final class Leg {
@ -55,6 +70,13 @@ public final class CallTranslationDownlink {
var partial = [Int16](repeating: 0, count: CallTranslationDownlink.frameSamples)
var partialLen = 0
var silentRun = 0
var trimming = false // 裁静音回滞状态
var rampPending = false // 刚裁掉一段,下一块要淡入
var lastPushAt: CFAbsoluteTime = 0 // 最近一次收到源 PCM 的时刻
var rateCur = 1.0 // 平滑后的当前速率
var starved = 0 // 句中断粮次数
var playing = false // 这一句已经起播(起播缓冲只在句首用一次)
var lastHoleLogAt: CFAbsoluteTime = 0
let stretcher = Wsola()
var encoder: G722Codec?
@ -84,6 +106,8 @@ public final class CallTranslationDownlink {
func reset() {
srcClear(); partialLen = 0; silentRun = 0
trimming = false; rampPending = false; lastPushAt = 0; rateCur = 1.0
starved = 0; playing = false; lastHoleLogAt = 0
stretcher.reset()
pushedFrames = 0; sentFrames = 0; trimmedSamples = 0; droppedSamples = 0
maxBacklogSamples = 0; lastRate = 1.0
@ -101,28 +125,28 @@ public final class CallTranslationDownlink {
private var intervalMs: Int = 20
private var sentPackets = 0
// MARK: - 流畅度观测
//
// ⚠️ 这里原来用的是 NSLog,而 NSLog 的内容在 release / profile 包里**抓不到**
// (idevicesyslog 只收得到 os_log),于是下行是整条链路上唯一的盲区。排查
// 「杂音、不流畅」时看不到队列长度和发送节奏,只能猜。一律改走 os_log。
/// 发包配额(包数,可带小数),每拍加 tickMs/intervalMs
private var budget = 0.0
private var wasIdle = true
private var tailLeft = 0
// MARK: - 流畅度观测(release 包里只有 os_log 抓得到)
private static let dlLog = OSLog(subsystem: "com.eaimar.bluetooth", category: "downlink")
/// 定时器实际触发的时刻,用来算真实间隔 —— 期望 20ms,偏大就是发送跟不上
private var lastTickAt: CFAbsoluteTime = 0
private var tickGapSumMs: Double = 0
private var tickGapCount: Int = 0
private var tickGapMaxMs: Double = 0
/// 取样窗口内的积压峰值(帧),resetPeaks 会清
private var peakQueueA = 0
private var peakQueueB = 0
/// 静音帧(与 deepvoice 一致,耳机侧认这个码流)
/// 静音帧(与 deepvoice 一致)。只在没有编码器时兜底用,正常空闲用 [idleFrame] 编零。
private let silenceFrame = Data([
0x0A, 0x8E, 0x30, 0xBD, 0x3F, 0x83, 0xFF, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF
])
private let zeroPcm = Data(count: CallTranslationDownlink.framePcmBytes)
private init() {}
@ -135,6 +159,9 @@ public final class CallTranslationDownlink {
if leg.encoder == nil { leg.encoder = G722Codec(sampleRate: 16000) }
}
self.sentPackets = 0
// 每场从 20ms 起步:上一场结束时耳机可能停在 16ms
self.intervalMs = 20
self.budget = 0; self.wasIdle = true; self.tailLeft = 0
self.lastTickAt = 0
self.tickGapSumMs = 0; self.tickGapCount = 0; self.tickGapMaxMs = 0
self.peakQueueA = 0; self.peakQueueB = 0
@ -159,8 +186,6 @@ public final class CallTranslationDownlink {
leg.stretcher.reset()
leg.encoder = nil
}
// 清自己的队列还不够:已经递给 GATT 层的那些包也得丢掉,
// 否则它们会一直堵在控制命令(查电量/查版本)前面
BluetoothManager.sharedInstance?.flushRealtimeQueue()
let avgGap = self.tickGapCount > 0 ? self.tickGapSumMs / Double(self.tickGapCount) : 0
let bleStats = BluetoothManager.sharedInstance?.bleWriteStats() ?? [:]
@ -170,7 +195,7 @@ public final class CallTranslationDownlink {
""",
log: Self.dlLog, type: .info,
self.sentPackets, pushedA, pushedB,
avgGap, self.tickGapMaxMs, self.intervalMs,
avgGap, self.tickGapMaxMs, Self.tickMs,
String(describing: bleStats))
os_log("[BesCallDownlink] 下行积压总账 A{%{public}@} B{%{public}@}", log: Self.dlLog, type: .default, a, b)
}
@ -184,20 +209,11 @@ public final class CallTranslationDownlink {
let trimmed = Double(leg.trimmedSamples) / sr
let dropped = Double(leg.droppedSamples) / sr
let catchup = max(0, pushedSec - sentSec - trimmed - dropped - leg.backlogSec())
return String(format: "源=%.1fs 播出=%.1fs 最大积压=%.1fs 裁静音=%.1fs 变速追回=%.1fs 兜底丢=%.1fs",
pushedSec, sentSec, Double(leg.maxBacklogSamples) / sr, trimmed, catchup, dropped)
return String(format: "源=%.1fs 播出=%.1fs 最大积压=%.1fs 裁静音=%.1fs 变速追回=%.1fs 兜底丢=%.1fs 断粮=%d次",
pushedSec, sentSec, Double(leg.maxBacklogSamples) / sr, trimmed, catchup, dropped, leg.starved)
}
/// 下行发送器的实时统计,供 Dart 在切前后台等时刻取样。
///
/// 这些数字原来只在 os_log 里,而排查后台卡顿时 `idevicesyslog` 极不稳定
/// (2026-09-21 断过两次),于是下行成了唯一看不见的一段。暴露出来之后,
/// Dart 侧可以把它写进文件日志(`devicectl device copy from` 直接拉),
/// 不再依赖 usbmuxd。
///
/// **gapAvgMs 是判断 iOS 后台节流的关键**:期望等于 intervalMs(16/20/25),
/// 明显变大就说明 DispatchSourceTimer 被系统合并触发了,下行必然断续。
/// queueA/B 现在是「未播的源音频」折算的帧数(积压),不再是编码队列长度。
/// 下行发送器的实时统计,供 Dart 在切前后台等时刻取样(形状不变)。
public func stats() -> [String: Any] {
var out: [String: Any] = [:]
queue.sync {
@ -225,8 +241,6 @@ public final class CallTranslationDownlink {
return out
}
/// 取样之间重置峰值,否则 gapMaxMs 会被会话早期的一次尖峰永久占住,
/// 看不出「切到后台之后」到底有没有变差。
public func resetPeaks() {
queue.async {
self.tickGapSumMs = 0; self.tickGapCount = 0; self.tickGapMaxMs = 0
@ -234,28 +248,43 @@ public final class CallTranslationDownlink {
}
}
/// 耳机要求调整下行节奏(`0xD6`/`0xE9` 的 data[6])
public func setIntervalMs(_ ms: Int) {
/// 耳机调速帧的 data[6] 原值:0 = 缓冲空/起播,1 = 要快,2 = 正常,3 = 要慢。
/// 收到 01 立刻补一包(并按 16ms 配额跟上持续的要求),00 补两包,03 扣一包。
public func onHeadsetPace(_ code: Int) {
queue.async {
guard self.intervalMs != ms else { return }
self.intervalMs = ms
os_log("[BesCallDownlink] 下行节奏调整为 %dms", log: Self.dlLog, type: .info, ms)
if self.running { self.scheduleTimer() }
switch code {
case 0: self.budget += 2; self.applyInterval(20)
case 1: self.budget += 1; self.applyInterval(16)
case 3: self.budget -= 1; self.applyInterval(25)
default: self.applyInterval(20)
}
}
}
/// 兼容旧调用:只改配额节奏,不重排定时器
public func setIntervalMs(_ ms: Int) {
queue.async { self.applyInterval(ms) }
}
/// 只在 [queue] 上调用
private func applyInterval(_ ms: Int) {
guard intervalMs != ms else { return }
intervalMs = ms
os_log("[BesCallDownlink] 下行节奏调整为 %dms", log: Self.dlLog, type: .info, ms)
}
private func scheduleTimer() {
timer?.cancel()
let t = DispatchSource.makeTimerSource(queue: queue)
t.schedule(deadline: .now(), repeating: .milliseconds(intervalMs))
t.setEventHandler { [weak self] in self?.sendOnePacket() }
// 固定节拍、永不重排;节奏差异全在 onTick 的配额里体现
t.schedule(deadline: .now(), repeating: .milliseconds(Self.tickMs))
t.setEventHandler { [weak self] in self?.onTick() }
timer = t
t.resume()
}
/// 推一段待下发的 TTS PCM。
/// [leg] "A" = 己方译文给对端听;"B" = 对端译文给己方听。
/// [pcm] 16kHz / 16bit / mono / little-endian,长度任意。
public func pushPcm(leg: String, pcm: Data) {
guard !pcm.isEmpty else { return }
let isA = leg.uppercased() == "A" || leg.lowercased() == "uplink"
@ -277,10 +306,14 @@ public final class CallTranslationDownlink {
l.partialLen += take
i += take
if l.partialLen == Self.frameSamples {
let block = l.partial
var block = l.partial
l.partialLen = 0
l.pushedFrames += 1
if !self.trimSilence(l, block) { l.srcPush(block) }
if !self.trimSilence(l, block) {
if l.rampPending { Self.rampIn(&block); l.rampPending = false }
l.srcPush(block)
l.lastPushAt = CFAbsoluteTimeGetCurrent()
}
}
}
}
@ -308,20 +341,32 @@ public final class CallTranslationDownlink {
var acc = 0.0
for s in block { let d = Double(s); acc += d * d }
let rms = (acc / Double(block.count)).squareRoot()
if rms >= Self.silenceRms {
// 回滞:进入裁剪要低于 silenceRms,退出要高于 silenceRmsExit
let silent = l.trimming ? rms < Self.silenceRmsExit : rms < Self.silenceRms
if !silent {
l.silentRun = 0
l.trimming = false
return false
}
l.silentRun += block.count
let keep = Self.silenceKeepMs * Self.sampleRate / 1000
if l.silentRun > keep && l.backlogSec() > Self.silenceTrimStartSec {
l.trimmedSamples += block.count
l.trimming = true
l.rampPending = true
return true
}
return false
}
/// 按积压深度算这一帧的播放速率。
/// 裁剪接缝处的短淡入
private static func rampIn(_ block: inout [Int16]) {
let n = min(rampSamples, block.count)
if n <= 0 { return }
for i in 0..<n { block[i] = Int16(Int(block[i]) * i / n) }
}
/// 按积压深度算目标速率。
private func rateFor(_ backlogSec: Double) -> Double {
if backlogSec <= Self.catchupStartSec { return 1.0 }
if backlogSec >= Self.catchupFullSec { return Self.maxRate }
@ -331,9 +376,37 @@ public final class CallTranslationDownlink {
/// 取这条腿的下一帧编码码流;没东西可播返回 nil。只在 [queue] 上调用。
private func nextEncodedFrame(_ l: Leg) -> Data? {
guard let encoder = l.encoder else { return nil }
let rate = rateFor(l.backlogSec())
let target = rateFor(l.backlogSec())
let rate = l.rateCur + min(max(target - l.rateCur, -Self.rateStep), Self.rateStep)
l.rateCur = rate
l.lastRate = rate
guard let out = l.stretcher.nextFrame(rate: rate, leg: l) else { return nil }
let now = CFAbsoluteTimeGetCurrent()
let idleMs = l.lastPushAt == 0 ? Int.max : Int((now - l.lastPushAt) * 1000)
let flushAllowed = idleMs >= Self.flushIdleMs
let unreadInStretcher = l.stretcher.unreadSamples()
let available = unreadInStretcher + l.srcSamples
if !flushAllowed {
let needForFrame = Int(Double(Self.frameSamples) * rate) + l.stretcher.lookahead()
let priming = !l.playing && unreadInStretcher == 0 && l.srcSamples < Self.primeMs * Self.sampleRate / 1000
if priming { return nil }
if available < needForFrame {
if l.playing {
l.starved += 1
if now - l.lastHoleLogAt > 1.0 {
l.lastHoleLogAt = now
os_log("[BesCallDownlink] %{public}@ 路句中空洞: 可用 %dms < 需 %dms,距上次到数据 %dms",
log: Self.dlLog, type: .info, l.name,
available * 1000 / Self.sampleRate, needForFrame * 1000 / Self.sampleRate, idleMs)
}
}
return nil
}
}
guard let out = l.stretcher.nextFrame(rate: rate, leg: l, allowPad: flushAllowed) else {
l.playing = false
return nil
}
l.playing = true
let pcm = out.withUnsafeBufferPointer { Data(buffer: $0) } // Int16 小端 = 本机字节序
l.sentFrames += 1
if let enc = encoder.encode(pcm), enc.count >= Self.frameEncodedBytes {
@ -342,58 +415,94 @@ public final class CallTranslationDownlink {
return silenceFrame
}
/// 只在 [queue] 上调用
private func sendOnePacket() {
/// 这一路此刻没内容:用它的编码器编零,码流与前后帧连续。只在 [queue] 上调用。
private func idleFrame(_ l: Leg) -> Data {
guard let encoder = l.encoder else { return silenceFrame }
if let enc = encoder.encode(zeroPcm), enc.count >= Self.frameEncodedBytes {
return Data(enc.prefix(Self.frameEncodedBytes))
}
return silenceFrame
}
/// 每 tickMs 一拍:按耳机要求的节奏累计配额,配额够一包发一包。只在 [queue] 上调用。
private func onTick() {
guard running else { return }
// 定时器真实节奏:DispatchSourceTimer 会为省电合并触发,而 handler 里还压着
// G.722 编码与一次 GATT 写。实际间隔一旦稳定大于 intervalMs,下行就是在欠发,
// 耳机侧必然断续 —— 这是「不流畅」和「队列只涨不落」的分水岭。
let now = CFAbsoluteTimeGetCurrent()
if lastTickAt > 0 {
let gap = (now - lastTickAt) * 1000
let prevTick = lastTickAt
if prevTick > 0 {
let gap = (now - prevTick) * 1000
tickGapSumMs += gap
tickGapCount += 1
if gap > tickGapMaxMs { tickGapMaxMs = gap }
}
lastTickAt = now
// 配额按**真实流逝时间**算,不按"一拍一份":iOS 切后台会把定时器合并成 60~120 ms 一次
// (2026-09-23 真机 gapAvgMs=79),DispatchSourceTimer 错过的节拍不补发,按拍计数就只剩 1/4 的包。
// 晚触发多少就一次补齐多少(上限 maxPacketsPerTick),耳机侧看到的平均速率不变。
let elapsedMs = prevTick > 0 ? (now - prevTick) * 1000 : Double(Self.tickMs)
budget += min(elapsedMs, Double(Self.tickMs * Self.maxPacketsPerTick)) / Double(intervalMs)
var n = 0
while budget >= 1.0 && n < Self.maxPacketsPerTick {
if !emitPacket() { budget = 0; break }
budget -= 1.0
n += 1
}
}
/// 两路都是编码静音的一包(垫底用)
private func writeIdlePacket() {
var packet = Data(capacity: Self.packetSize)
packet.append(contentsOf: [0xAA, 0x56, UInt8(Self.packetSize), 0x03])
packet.append(idleFrame(legB))
packet.append(idleFrame(legA))
BluetoothManager.writeRealtimeAudio(packet)
sentPackets += 1
}
/// 发一包;两路都空且尾巴续完时返回 false(真正空闲,不发)
private func emitPacket() -> Bool {
let b = nextEncodedFrame(legB)
let a = nextEncodedFrame(legA)
// 两路都没内容就不发,避免通话里灌满无谓的静音包
if b == nil && a == nil { return }
if b == nil && a == nil {
if tailLeft <= 0 { wasIdle = true; return false }
tailLeft -= 1
} else {
tailLeft = Self.tailTicks
if wasIdle {
wasIdle = false
for _ in 0..<Self.prerollPackets { writeIdlePacket() }
}
}
var packet = Data(capacity: Self.packetSize)
packet.append(contentsOf: [0xAA, 0x56, UInt8(Self.packetSize), 0x03])
packet.append(b ?? silenceFrame)
packet.append(a ?? silenceFrame)
packet.append(b ?? idleFrame(legB))
packet.append(a ?? idleFrame(legA))
BluetoothManager.writeRealtimeAudio(packet)
sentPackets += 1
// 每 100 包(约 2 秒)一条。积压持续 >2s 说明产出快于播放、追赶已介入。
if sentPackets == 1 || sentPackets % 100 == 0 {
let avgGap = tickGapCount > 0 ? tickGapSumMs / Double(tickGapCount) : 0
let ble = BluetoothManager.sharedInstance?.bleWriteStats() ?? [:]
os_log("[BesCallDownlink] 已下发 %d 包 (积压 A=%.1fs B=%.1fs 速率 A=%.2f B=%.2f) 间隔 平均=%.1fms 最大=%.1fms | GATT 丢=%{public}@ 阻塞=%{public}@ 待发=%{public}@",
os_log("[BesCallDownlink] 已下发 %d 包 (积压 A=%.1fs B=%.1fs 速率 A=%.2f B=%.2f 断粮 A=%d B=%d 节奏=%dms) 间隔 平均=%.1fms 最大=%.1fms | GATT 丢=%{public}@ 阻塞=%{public}@ 待发=%{public}@",
log: Self.dlLog, type: .info,
sentPackets, legA.backlogSec(), legB.backlogSec(), legA.lastRate, legB.lastRate,
legA.starved, legB.starved, intervalMs,
avgGap, tickGapMaxMs,
String(describing: ble["dropped"] ?? 0),
String(describing: ble["blocked"] ?? 0),
String(describing: ble["queued"] ?? 0))
}
return true
}
/// WSOLA 变速不变调(Verhelst & Roelands),与 Android `Wsola` 同一套参数与步骤。
///
/// 合成侧固定步长 S(10ms)、帧长 N(20ms)、Hann 50% 重叠相加;分析侧步长 = S × rate,
/// 每帧在名义位置 ±T 内搜索与「上一帧自然延续」最相似的起点(互相关),保证拼接处波形连续。
/// rate=1.0 且偏移=0 时 Hann 50% 重叠相加恒等于原信号(Python 复刻验证:逐样本误差 0),
/// 所以不用在直通/变速间切换。附加时延 ≈ N + T 样本(≈26ms)。
///
/// 输入不够时:源队列已空 → 补零把尾巴冲出来(TTS 末尾本来就是静音);源队列还有 → 先取。
/// 每帧在名义位置 ±T 内搜索与「上一帧自然延续」最相似的起点。rate=1.0 时恒等于原信号。
private final class Wsola {
private let N = 320
private let S = 160
private let T = 96
private let T = 160 // 搜索窗 ±10ms(原 ±6ms):高速段对齐更准、失真更小
private var inBuf = [Int16](repeating: 0, count: 320 * 8)
private var inLen = 0
private var anaPos = 0.0
@ -415,11 +524,15 @@ public final class CallTranslationDownlink {
for i in 0..<acc.count { acc[i] = 0 }
}
/// 产出一帧输出至少要在输入里看到这么多前视样本
func lookahead() -> Int { T + N }
/// 变速器里还没播掉的样本(不含补的零)
func unreadSamples() -> Int { max(0, inLen - padded - Int(anaPos.rounded())) }
/// 取 20ms 输出;返回 nil 表示这条腿当前没有东西可播。
func nextFrame(rate: Double, leg: Leg) -> [Int16]? {
/// [allowPad] 为 false 时(句子没结束)不补零,宁可返回 nil。
func nextFrame(rate: Double, leg: Leg, allowPad: Bool) -> [Int16]? {
var produced = 0
let frame = CallTranslationDownlink.frameSamples
while produced < frame {
@ -431,6 +544,7 @@ public final class CallTranslationDownlink {
if inLen < need {
let realLeft = inLen - padded - nominal
if produced == 0 && realLeft <= 0 { return nil }
if !allowPad && produced == 0 { return nil }
let pad = need - inLen
ensure(need)
for i in inLen..<need { inBuf[i] = 0 }
@ -478,7 +592,6 @@ public final class CallTranslationDownlink {
}
private func append(_ block: [Int16]) {
// 之前补的零已经被新到的音频"夹"在中间,当成内容算(最多多算 26ms)
padded = 0
ensure(inLen + block.count)
for i in 0..<block.count { inBuf[inLen + i] = block[i] }
@ -491,7 +604,6 @@ public final class CallTranslationDownlink {
}
}
/// 丢掉已经用不到的输入前缀,避免无限增长。
private func compact() {
let keepFrom = min(Int(anaPos.rounded()), prevEnd) - T - N
if keepFrom > N * 4 {

8
apps/client/local_plugins/device_plugin_interface/lib/src/device_capability.dart

@ -87,4 +87,12 @@ enum DeviceCapability {
/// ⚠️ 与 [micUplink](边录边传)**互不蕴含**:EaiRec 支架只有前者,
/// 恒玄耳机只有后者,以后的录音笔可能两个都有。
fileStore,
/// 设备有一组**可读可写的设置项**(音量 / 亮度 / 开关…),通过
/// [DeviceFeatures.settingsQuery] 读、`settings.*` 各键写,变化时派
/// [DeviceFeatureKeys.settingsChanged] 事件。
///
/// 业务层的「设备设置面板」按它出现,**面板按设备返回的 `items` 渲染**,
/// 不写厂商分支:哪家插件声明了它、返回了哪些项,面板就画哪些。
settingsPanel,
}

49
apps/client/local_plugins/device_plugin_interface/lib/src/device_session.dart

@ -132,6 +132,10 @@ class DeviceFeatureKeys {
static const String batteryUpdated = 'battery.updated';
static const String firmwareUpdated = 'firmware.updated';
static const String macUpdated = 'mac.updated';
/// 设备设置项变了(设备回了 `0x15` 这类汇总、或 app 写成功后本地回填)。
/// `data` 与 [DeviceFeatures.settingsQuery] 的返回值同形。
static const String settingsChanged = 'settings.changed';
}
/// 通用 feature 调用 key(app → 设备)。
@ -172,6 +176,51 @@ class DeviceFeatures {
/// 设备,**不再经过 `openMic(ai)` 的 Dart 流**。返回 `{'enabled': bool}`;
/// 没有原生桥的厂商抛 `device.not_supported`,业务层据此退回 Dart 链路。
static const String aiPcmNativeBridge = 'ai.pcm.nativebridge';
// ---------- 设备设置项(DeviceCapability.settingsPanel)----------
//
// 通用语义键,厂商插件各自映射成字节。返回值统一 `{'ok': true}`,
// 设备明确拒绝抛 `device.feature_failed`,参数越界抛 `device.invalid_argument`。
// 写成功后插件**本地回填**并派 [DeviceFeatureKeys.settingsChanged]——
// 多数设备的应答只有成功码、不回显新值,面板别指望等设备来告诉它。
/// 拉一次全部设置项。返回:
/// ```
/// {
/// 'items': { // 只含设备支持的项;面板按 key 渲染
/// 'volume': {'value': 6, 'min': 0, 'max': 10},
/// 'brightness': {'value': 8, 'min': 0, 'max': 10},
/// 'random_expression_interval': {'value': 30, 'options': [20, 30, ...]},
/// 'power': {'on': true}, 'driving_sense': {'on': false}, 'swing': {'on': true},
/// 'play_random_expression': {}, // 只表示支持这个动作
/// },
/// 'battery': 80, 'firmware': '1.2.3',
/// }
/// ```
static const String settingsQuery = 'settings.query';
/// `{'value': int}`
static const String settingsVolume = 'settings.volume';
/// `{'value': int}`
static const String settingsBrightness = 'settings.brightness';
/// 启停开关 `{'on': bool}`
static const String settingsPower = 'settings.power';
/// 行车感应开关 `{'on': bool}`
static const String settingsDrivingSense = 'settings.driving_sense';
/// 摇摆开关 `{'on': bool}`
static const String settingsSwing = 'settings.swing';
/// 待机随机表情的频次 `{'seconds': int}`,取值须在 `items` 给的 `options` 里
static const String settingsRandomExpressionInterval =
'settings.random_expression_interval';
/// 立刻随机播一个表情,无参。设备没有原生"随机"命令时由插件自己挑一个。
static const String settingsPlayRandomExpression =
'settings.play_random_expression';
}
/// [DeviceFeatures.aiState] 的取值。

2
apps/client/pubspec.yaml

@ -1,7 +1,7 @@
name: eaimar
description: "Eaimar - AI Voice Assistant."
publish_to: "none"
version: 1.0.2+19
version: 1.0.2+21
environment:
sdk: ">=3.3.0 <4.0.0"

137
apps/client/test/devices/eaicar_settings_features_test.dart

@ -0,0 +1,137 @@
import 'package:device_plugin_interface/device_plugin_interface.dart';
import 'package:eaimar/devices/eaicar/eaicar_device_plugin.dart';
import 'package:eaimar/devices/eaicar/eaicar_protocol.dart';
import 'package:flutter_test/flutter_test.dart';
/// 设备设置面板的 `settings.*` feature key → 发给 EaiCar 的字节,**冻结**。
///
/// 字节来自 lumi 真正发出的命令(`ble_service/.../BleService.kt`),不是协议文档:
/// 文档的 CRC 写法是错的、0x15 应答字段也不全。改一个字节等于改协议,先上真机验。
void main() {
List<int> frame(String key, Map<String, Object?> args) {
final c = EaiCarSession.settingsCommand(key, args,
random: (max) => 0x08 /* 固定挑"跳舞",只为字节可复现 */)!;
return EaiCarProtocol.buildPacket(c.cmd, c.payload);
}
List<int> withCrc(List<int> body) =>
[...body, EaiCarProtocol.crc8Maxim(body)];
group('settings.* → 字节', () {
test('六条写命令逐条固定', () {
expect(frame(DeviceFeatures.settingsVolume, {'value': 5}),
withCrc([0xAA, 0x0B, 0x01, 0x05]));
expect(frame(DeviceFeatures.settingsBrightness, {'value': 10}),
withCrc([0xAA, 0x0A, 0x01, 0x0A]));
expect(frame(DeviceFeatures.settingsPower, {'on': true}),
withCrc([0xAA, 0x0D, 0x01, 0x01]));
expect(frame(DeviceFeatures.settingsPower, {'on': false}),
withCrc([0xAA, 0x0D, 0x01, 0x00]));
expect(frame(DeviceFeatures.settingsDrivingSense, {'on': true}),
withCrc([0xAA, 0x11, 0x01, 0x01]));
expect(frame(DeviceFeatures.settingsSwing, {'on': false}),
withCrc([0xAA, 0x14, 0x01, 0x00]));
// 频次是秒数原值,不是档位索引(lumi settings_view.dart:1517)
expect(
frame(DeviceFeatures.settingsRandomExpressionInterval,
{'seconds': 60}),
withCrc([0xAA, 0x13, 0x01, 0x3C]));
});
test('随机表情走 0x06、单向不等应答,id 落在 0x00~0x09', () {
final c = EaiCarSession.settingsCommand(
DeviceFeatures.settingsPlayRandomExpression, const {},
random: (max) => max - 1)!;
expect(c.cmd, EaiCarProtocol.cmdControlEmotion);
expect(c.expectResponse, isFalse);
expect(c.payload, [0x09]);
for (var i = 0; i < 50; i++) {
final r = EaiCarSession.settingsCommand(
DeviceFeatures.settingsPlayRandomExpression, const {})!;
expect(r.payload.single, inInclusiveRange(0, 9));
}
});
test('其余写命令都等应答(设备会回 0x64 成功码)', () {
for (final k in [
DeviceFeatures.settingsVolume,
DeviceFeatures.settingsBrightness,
]) {
expect(EaiCarSession.settingsCommand(k, {'value': 1})!.expectResponse,
isTrue);
}
for (final k in [
DeviceFeatures.settingsPower,
DeviceFeatures.settingsDrivingSense,
DeviceFeatures.settingsSwing,
]) {
expect(EaiCarSession.settingsCommand(k, {'on': true})!.expectResponse,
isTrue);
}
});
test('参数越界 / 缺参抛 invalid_argument,不会发出去', () {
void bad(String key, Map<String, Object?> args) {
expect(
() => EaiCarSession.settingsCommand(key, args),
throwsA(isA<DeviceException>()
.having((e) => e.code, 'code', DeviceErrorCode.invalidArgument)),
reason: '$key $args',
);
}
bad(DeviceFeatures.settingsVolume, {'value': 11});
bad(DeviceFeatures.settingsVolume, {'value': -1});
bad(DeviceFeatures.settingsVolume, {});
bad(DeviceFeatures.settingsBrightness, {'value': 16});
bad(DeviceFeatures.settingsPower, {'on': 1});
bad(DeviceFeatures.settingsSwing, {});
// 45 秒不在 9 档里
bad(DeviceFeatures.settingsRandomExpressionInterval, {'seconds': 45});
});
test('不认识的键返回 null(由 invokeFeature 抛 not_supported)', () {
expect(EaiCarSession.settingsCommand('settings.nope', const {}), isNull);
});
});
group('0x15 → settings.query 返回值', () {
test('14 字节逐项映射到通用键名', () {
// 与 eaicar_protocol_test 里同一条样本:音量6/频次30/亮度8/电量80/1.2.3/
// 启停开/摇头风格3/音乐表情开/陀螺仪关/电机开
final s = EaiCarAllSettings.parse(
[0x00, 0x06, 0x1E, 0x08, 0x50, 0x01, 0x02, 0x03, 0x01, 0x03, 0x01, 0x00, 0x00, 0x01])!;
final m = s.toSettingsMap();
final items = m['items'] as Map;
expect(items['volume'], {'value': 6, 'min': 0, 'max': 10});
expect(items['brightness'], {'value': 8, 'min': 0, 'max': 10});
expect((items['random_expression_interval'] as Map)['value'], 30);
expect((items['random_expression_interval'] as Map)['options'],
EaiCarProtocol.randomExpressionIntervalOptions);
expect(items['power'], {'on': true});
expect(items['driving_sense'], {'on': false});
expect(items['swing'], {'on': true});
expect(items.containsKey('play_random_expression'), isTrue);
expect(m['battery'], 80);
expect(m['firmware'], '1.2.3');
expect(m['otaAbnormal'], false);
});
test('copyWith 只改指定项(写成功后的本地回填靠它)', () {
final s = EaiCarAllSettings.parse(
[0x01, 0x00, 0x14, 0x0A, 0x3C, 0x02, 0x0C, 0x0C, 0x00, 0x02, 0x01, 0x01, 0x04, 0x01])!;
final t = s.copyWith(volume: 7, gyroscope: false);
expect(t.volume, 7);
expect(t.gyroscope, isFalse);
expect(t.brightness, s.brightness);
expect(t.battery, s.battery);
expect(t.firmwareVersion, s.firmwareVersion);
expect(t.motor, s.motor);
expect(t.otaAbnormal, s.otaAbnormal);
});
});
test('EaiCar 会话声明了 settingsPanel(面板按它出现)', () {
expect(EaiCarSession.caps.contains(DeviceCapability.settingsPanel), isTrue);
});
}

38
apps/proto/echomeet/echomeet_msg.proto

@ -231,3 +231,41 @@ message EchomeetAliSentence {
message EchomeetAliCallbackResp {
}
// ============================== 会议纪要分享 ==============================
//
// 分享链接形如 https://share.ymaikj.com/share/{serverHost}/{brand}/{id}/{ticket}
//
// ⚠️ 2026-09-23 之前客户端把**用户的登录 JWT**直接拼进这个 URL,谁拿到链接就拿到了
// 整个账号。现在改成服务端签发的分享票据:只含一条记录、只读、有有效期。
message EchomeetCreateShareLinkReq{
uint64 id = 1; //@go_tags(`json:"id"`) 会议记录id
int32 expiredays = 2; //@go_tags(`json:"expiredays"`) 有效天数,<=0 用默认(7),上限 30
}
message EchomeetCreateShareLinkResp{
string ticket = 1; //@go_tags(`json:"ticket"`) 分享票据,拼进落地页 URL 的最后一段
int64 expiretime = 2; //@go_tags(`json:"expiretime"`) 到期时间 unix 秒
}
// 落地页按票据取只读内容。**免登录**,须在 gateway 的 WhiteList 里。
message EchomeetGetSharedRecordReq{
string ticket = 1; //@go_tags(`json:"ticket"`) 分享票据
}
// 只回落地页要展示的字段。刻意不含 uid / filepath / taskid / logid / *_svc_id:
// 这是一个任何人凭链接就能打开的接口,多回一个字段就是多泄一份内部信息。
message EchomeetGetSharedRecordResp{
uint64 id = 1; //@go_tags(`json:"id"`) 会议记录id
string title = 2; //@go_tags(`json:"title"`) 标题
int64 creationtime = 3; //@go_tags(`json:"creationtime"`) 创建时间 unix 秒
int32 seconds = 4; //@go_tags(`json:"seconds"`) 时长 秒
string audiourl = 5; //@go_tags(`json:"audiourl"`) 音频url
string original = 6; //@go_tags(`json:"original"`) 听写内容
string translate = 7; //@go_tags(`json:"translate"`) 翻译
string overview = 8; //@go_tags(`json:"overview"`) 短总结
string summary = 9; //@go_tags(`json:"summary"`) 总结
string formlanguage = 10; //@go_tags(`json:"formlanguage"`) 源语言
string tolanguage = 11; //@go_tags(`json:"tolanguage"`) 目标语言
int64 expiretime = 12; //@go_tags(`json:"expiretime"`) 本链接到期时间 unix 秒
}

111
apps/services/comm/apprelease.go

@ -27,6 +27,13 @@ type AppRelease struct {
Address string `gorm:"column:address" json:"address"` // 默认下载地址;渠道填了自己的则以渠道为准
ReviewVersion string `gorm:"column:review_version;size:32" json:"review_version"` // 送审版本号;客户端报这一版时隐藏未过审功能
// ── iOS 单独一套版本号(2026-09-23 加)──
// iOS 审核慢,Android 先发 1.0.3 时 iOS 商店里往往还是 1.0.2;共用一个 Version 会让
// iOS 用户被提示更新、点进 App Store 却没有新版。Version/MinVersion 从此只管 Android,
// iOS 渠道(GlobalApple)读这两列;ForceUpdate 开关两端共用。取值一律走 VersionFor/MinVersionFor。
IosVersion string `gorm:"column:ios_version;size:32" json:"ios_version"`
IosMinVersion string `gorm:"column:ios_min_version;size:32" json:"ios_min_version"`
// ── 游客显隐 ──
Tourists bool `gorm:"column:tourists" json:"tourists"` // 允许游客登录(总开关)
TouristsVersion string `gorm:"column:touristsversion;size:32" json:"touristsversion"` // 生效版本,白名单语义见 ShowTourists
@ -35,26 +42,82 @@ type AppRelease struct {
Updatetime int64 `gorm:"column:updatetime" json:"updatetime"`
}
// NeedForceUpdate 判断某客户端版本是否必须更新:开了强更开关、配了最低版本、且客户端版本低于它。
// 客户端平台。发版配置里 Android / iOS 各一套版本号,按渠道推导(见 PlatformOfChannel)。
const (
PlatformAndroid = "android"
PlatformIOS = "ios"
)
// PlatformOfChannel 由渠道号推导平台:只有 App Store 渠道是 iOS,其余(国内五家商店、官方、Google)都是 Android。
// 客户端不单独上报平台,渠道号本身就带这个信息(iOS 恒为 GlobalApple,见 version_update_service.dart)。
func PlatformOfChannel(channel int32) string {
if channel == ChannelGlobalApple {
return PlatformIOS
}
return PlatformAndroid
}
// VersionFor 取某平台的线上版本号;iOS 没单独填时**不回退 Android 的**——
// 回退等于又回到「共用一个版本号」的老问题,iOS 用户会被指向一个商店里还没有的版本。
func (r *AppRelease) VersionFor(platform string) string {
if r == nil {
return ""
}
if platform == PlatformIOS {
return strings.TrimSpace(r.IosVersion)
}
return strings.TrimSpace(r.Version)
}
// MinVersionFor 取某平台的最低支持版本;**留空按等于线上版本处理**(2026-09-23 定的口径):
// 运营勾上强更、不填最低版本,语义就是「所有低于线上版本的都必须更新」;
// 填了则只强更低于它的,介于两者之间的仍是推荐更新。
func (r *AppRelease) MinVersionFor(platform string) string {
if r == nil {
return ""
}
min := r.MinVersion
if platform == PlatformIOS {
min = r.IosMinVersion
}
if m := strings.TrimSpace(min); m != "" {
return m
}
return r.VersionFor(platform)
}
// NeedForceUpdateOn 判断某平台某客户端版本是否必须更新:开了强更开关、且客户端版本低于该平台最低支持版本
//(最低版本留空 = 线上版本,见 MinVersionFor)。
//
// 客户端版本为空(老客户端没上报)时不强更,避免把所有人挡在门外 —— 这个判定一旦判宽只是
// 少弹一次强更框,判严就是全员无法使用,失败方向必须偏向放行。
func (r *AppRelease) NeedForceUpdate(clientVersion string) bool {
if r == nil || !r.ForceUpdate || strings.TrimSpace(r.MinVersion) == "" {
func (r *AppRelease) NeedForceUpdateOn(clientVersion, platform string) bool {
if r == nil || !r.ForceUpdate {
return false
}
if strings.TrimSpace(clientVersion) == "" {
min := r.MinVersionFor(platform)
if min == "" || strings.TrimSpace(clientVersion) == "" {
return false
}
return CompareVersion(clientVersion, r.MinVersion) < 0
return CompareVersion(clientVersion, min) < 0
}
// NeedUpdate 判断是否有更新可用:线上版本高于客户端版本。
func (r *AppRelease) NeedUpdate(clientVersion string) bool {
if r == nil || strings.TrimSpace(r.Version) == "" || strings.TrimSpace(clientVersion) == "" {
// NeedUpdateOn 判断某平台是否有更新可用:该平台线上版本高于客户端版本。
func (r *AppRelease) NeedUpdateOn(clientVersion, platform string) bool {
ver := r.VersionFor(platform)
if ver == "" || strings.TrimSpace(clientVersion) == "" {
return false
}
return CompareVersion(clientVersion, r.Version) < 0
return CompareVersion(clientVersion, ver) < 0
}
// NeedForceUpdate / NeedUpdate 是 Android 口径的便捷写法(老调用点与测试沿用)。
func (r *AppRelease) NeedForceUpdate(clientVersion string) bool {
return r.NeedForceUpdateOn(clientVersion, PlatformAndroid)
}
func (r *AppRelease) NeedUpdate(clientVersion string) bool {
return r.NeedUpdateOn(clientVersion, PlatformAndroid)
}
// IsReviewing 判断某客户端版本是否正是「送审版本」——客户端据此隐藏未过审的功能。
@ -107,6 +170,36 @@ func (r *AppRelease) DownloadAddress(channelAddress string) string {
return r.Address
}
// ResolveDownloadAddress 算出某渠道客户端「立即更新」要打开的地址(2026-09-23 定的回退链):
//
// Android 各渠道:本渠道(须已上架)→ 官方渠道(100) 的地址 → 应用默认下载地址
// iOS / Google:只认本渠道(须已上架),没有就空——绝不能回退到官方渠道或默认地址,
// 那两处如今放的是 .apk 直链,iPhone 打开一个 apk 等于把用户引进死胡同。
//
// 「须已上架」:渠道行状态是审核中 / 已下架时,商店里还是旧包,跳过去用户下到的仍是旧版,
// 所以这条渠道地址视同没有;Android 顺着回退链拿官方包,iOS 则不提示更新。
// 返回空串 = 没有任何可去的地方,调用方据此**不提示更新**(强更也不行——一个点了没反应
// 又关不掉的弹窗等于把用户锁死)。
func (r *AppRelease) ResolveDownloadAddress(channel int32, ch *ChannelApp, officialAddress string) string {
chAddr := ""
if ch != nil && ch.Status == ChannelStatusOnline {
chAddr = strings.TrimSpace(ch.Address)
}
if chAddr != "" {
return chAddr
}
if channel == ChannelGlobalApple || channel == ChannelGlobalGoogle {
return ""
}
if s := strings.TrimSpace(officialAddress); s != "" {
return s
}
if r == nil {
return ""
}
return strings.TrimSpace(r.Address)
}
// PickAppRelease 从「应用行 + 全局行」里挑生效的一条:应用行覆盖全局行,都没有返回 nil。
// 与 agent / 第三方服务 / 渠道分发下发同语义(应用优先、无则回退全局)。
func PickAppRelease(rows []*AppRelease, app string) *AppRelease {

34
apps/services/comm/apprelease_test.go

@ -19,7 +19,15 @@ func TestAppReleaseNeedForceUpdate(t *testing.T) {
}
noMin := &AppRelease{ForceUpdate: true}
if noMin.NeedForceUpdate("1.0.0") {
t.Fatal("未配最低版本时不该强更")
t.Fatal("线上版本与最低版本都没配时不该强更")
}
// 2026-09-23 口径:最低版本留空 = 线上版本,「开了强更就对所有低版本强更」
blankMin := &AppRelease{ForceUpdate: true, Version: "2.0.0"}
if !blankMin.NeedForceUpdate("1.9.9") {
t.Fatal("最低版本留空时应按线上版本强更")
}
if blankMin.NeedForceUpdate("2.0.0") {
t.Fatal("已是线上版本不该强更")
}
var nilRel *AppRelease
if nilRel.NeedForceUpdate("1.0.0") {
@ -114,6 +122,30 @@ func TestAppReleaseDownloadAddress(t *testing.T) {
}
}
// iOS 与 Android 各一套版本号:iOS 没填时不回退 Android 的(否则又回到共用一个版本号的老问题)。
func TestAppReleasePlatformVersions(t *testing.T) {
r := &AppRelease{Version: "1.0.3", MinVersion: "1.0.1", IosVersion: "1.0.2", ForceUpdate: true}
if r.VersionFor(PlatformAndroid) != "1.0.3" || r.VersionFor(PlatformIOS) != "1.0.2" {
t.Fatal("两个平台应各取各的线上版本")
}
if r.MinVersionFor(PlatformAndroid) != "1.0.1" {
t.Fatal("Android 填了最低版本应取它")
}
if r.MinVersionFor(PlatformIOS) != "1.0.2" {
t.Fatal("iOS 最低版本留空应等于 iOS 线上版本")
}
if !r.NeedUpdateOn("1.0.2", PlatformAndroid) || r.NeedUpdateOn("1.0.2", PlatformIOS) {
t.Fatal("1.0.2 在 Android 上是旧版、在 iOS 上是最新")
}
noIos := &AppRelease{Version: "1.0.3", ForceUpdate: true}
if noIos.NeedUpdateOn("0.0.1", PlatformIOS) || noIos.NeedForceUpdateOn("0.0.1", PlatformIOS) {
t.Fatal("iOS 没填版本时不该拿 Android 的版本去判 iOS")
}
if PlatformOfChannel(ChannelGlobalApple) != PlatformIOS || PlatformOfChannel(ChannelChinaHuawei) != PlatformAndroid || PlatformOfChannel(ChannelGlobalGoogle) != PlatformAndroid {
t.Fatal("只有 App Store 渠道是 iOS")
}
}
func TestPickAppRelease(t *testing.T) {
rows := []*AppRelease{
{AppName: "", Version: "1.0.0"},

46
apps/services/comm/ossconf.go

@ -15,6 +15,44 @@ type AliyunOSSConf struct {
AccessSecret string
Bucket string
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 值。
@ -94,6 +132,8 @@ func LoadAliyunOSSConf(appName, encKey string) (*AliyunOSSConf, error) {
AccessSecret: get("access_key_secret"),
Bucket: get("bucket"),
Domain: get("domain"),
DistBucket: get("dist_bucket"),
DistDomain: get("dist_domain"),
}
if err := oc.normalize(); err != nil {
return nil, err
@ -121,6 +161,8 @@ func loadOSSFromSysConfig(encKey string) *AliyunOSSConf {
AccessSecret: strings.TrimSpace(cfg.SysFieldPlain("access_key_secret", encKey)),
Bucket: strings.TrimSpace(cfg.SysFieldPlain("bucket", 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 {
return nil
@ -150,8 +192,8 @@ func (oc *AliyunOSSConf) PublicURL(signedURL, key string) string {
if d := strings.TrimSpace(oc.Domain); d != "" {
d = strings.TrimPrefix(strings.TrimPrefix(d, "https://"), "http://")
d = strings.Trim(d, "/")
// 只取第一段做 host:运营常把域名连着路径一起填(cdn.ymaikj.com/),
// 那样直接当 Host 用会拼出 https://cdn.ymaikj.com//EAIMAR/... 这种取不到的地址。
// 只取第一段做 host:运营常把域名连着路径一起填(oss.ymaikj.com/),
// 那样直接当 Host 用会拼出 https://oss.ymaikj.com//EAIMAR/... 这种取不到的地址。
if i := strings.Index(d, "/"); i >= 0 {
d = d[:i]
}

8
apps/services/comm/svcruntime.go

@ -15,16 +15,16 @@ import (
//
// 目标:业务服务启动时——
// 1. 先用本地 yaml/env 连上 console 共享库(postgres);
// 2. 若库中没有本(应用,区域,服务)的配置,则把当前文件里的基础设施配置(DSN/Redis/NATS/COS…) seed 进库(source=file),
// 2. 若库中没有本(应用,区域,服务)的配置,则把当前文件里的基础设施配置(DSN/Redis/NATS…) seed 进库(source=file),
// 这样 console「应用的服务配置」页立刻能看到默认值;
// 3. 若库中已有,则以库为准,覆盖内存里的 Sys 配置(后台改过的值生效),再继续初始化 mysql/redis/cos 等。
// 3. 若库中已有,则以库为准,覆盖内存里的 Sys 配置(后台改过的值生效),再继续初始化 mysql/redis 等。
//
// 注意:postgres 自身是 bootstrap(要先连它才能读配置),故它不纳入托管;其覆盖也不会在本次启动生效。
// 密钥字段(DSN/密码/secret/token)以 AES-256-GCM 密文落库(comm.Encrypt,密钥 ${FIELD_ENCRYPT_KEY}),
// console 与业务服务必须配同一把 key。
// 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)。
// 存 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)
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…
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…
Value string `gorm:"column:value;type:text" json:"value"` // 值(密钥字段存 AES-GCM 密文;非标量存 JSON)
Encrypted bool `gorm:"column:encrypted" json:"encrypted"` // 是否密钥字段(前端脱敏、落库加密)

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

@ -9,7 +9,7 @@ import (
)
const (
baseURL = "https://voitrans.ideapsound.com"
baseURL = "https://ym-dev.ymaikj.com"
appID = "app_346bbeb6dc3bd37c"
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返回效率
func Test_API_AI(t *testing.T) {
// 测试参数
apiURL := "http://ideapsound.com/ai/funcchat" // 替换为你的实际地址
apiURL := "http://127.0.0.1:8080/ai/funcchat" // 替换为你的实际地址
testMessage := &AIChatReq{
Msg: "今天的天气如何",
Contexts: []string{

19
apps/services/modules/console/api_apprelease.go

@ -84,6 +84,8 @@ func (this *serverComp) saveAppVersion(c *gin.Context) {
Changelog string `json:"changelog"`
Address string `json:"address"`
ReviewVersion string `json:"review_version"`
IosVersion string `json:"ios_version"`
IosMinVersion string `json:"ios_min_version"`
}
if err := c.ShouldBindJSON(&req); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
@ -92,15 +94,22 @@ func (this *serverComp) saveAppVersion(c *gin.Context) {
req.Version = strings.TrimSpace(req.Version)
req.MinVersion = strings.TrimSpace(req.MinVersion)
req.ReviewVersion = strings.TrimSpace(req.ReviewVersion)
req.IosVersion = strings.TrimSpace(req.IosVersion)
req.IosMinVersion = strings.TrimSpace(req.IosMinVersion)
// 强更必须有最低支持版本,否则「开了强更却挡不住任何人」,是配置期就能发现的错。
if req.ForceUpdate && req.MinVersion == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "开启强制更新时必须填「最低支持版本」")
// 2026-09-23 起最低支持版本可以留空:留空 = 线上版本,即「开了强更就对所有低版本强更」。
// 但两个平台的线上版本都没填时强更没有任何效果,这是配置期就能发现的错。
if req.ForceUpdate && req.Version == "" && req.IosVersion == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "开启强制更新时至少要填一个平台的「当前线上版本」")
return
}
// 最低支持版本不该高于线上版本,否则线上用户全被判定为需强更却又没有更高版本可更。
if req.MinVersion != "" && req.Version != "" && comm.CompareVersion(req.MinVersion, req.Version) > 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "「最低支持版本」不能高于「当前线上版本」")
writeErr(c, pb.ErrorCode_ReqParameterError, "Android「最低支持版本」不能高于「当前线上版本」")
return
}
if req.IosMinVersion != "" && req.IosVersion != "" && comm.CompareVersion(req.IosMinVersion, req.IosVersion) > 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "iOS「最低支持版本」不能高于「当前线上版本」")
return
}
@ -116,6 +125,8 @@ func (this *serverComp) saveAppVersion(c *gin.Context) {
m.Changelog = req.Changelog
m.Address = strings.TrimSpace(req.Address)
m.ReviewVersion = req.ReviewVersion
m.IosVersion = req.IosVersion
m.IosMinVersion = req.IosMinVersion
this.saveAppRelease(c, m)
}

9
apps/services/modules/console/api_config.go

@ -93,6 +93,15 @@ func ensureConfigTables() error {
if err := pg.CreateTable(comm.TableAppRelease, &comm.AppRelease{}); err != nil {
return err
}
// 2026-09-23 加的 iOS 单独版本号两列:CreateTable 对已存在表跳过 AutoMigrate,故显式 ALTER(幂等)。
for _, s := range []string{
"ALTER TABLE " + comm.TableAppRelease + " ADD COLUMN IF NOT EXISTS ios_version varchar(32) DEFAULT ''",
"ALTER TABLE " + comm.TableAppRelease + " ADD COLUMN IF NOT EXISTS ios_min_version varchar(32) DEFAULT ''",
} {
if res := postgres.Exec(s); res.Error != nil {
return res.Error
}
}
// 按应用分离的新表(app_name=''为全局默认,'X'为应用X覆盖):第三方服务配置 / 区域覆盖 / Agent 配置。
// 独立于老的 third_svc_config / third_svc_region_override / agent 表,避免影响现有服务接口。
if err := pg.CreateTable(comm.TableSvcConfig, &ThirdSvcConfig{}); err != nil {

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

@ -352,9 +352,14 @@ func builtinSvcTemplates() []ThirdSvcTemplate {
// 必须仍是 OSS 源站,签名是按源站 host 算的,换域名会被判签名不符。
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("domain", "自定义域名(可选,如 cdn.ymaikj.com;留空用 OSS 源站地址)", false)),
tpl("storage_tencent_cos", "tencent_cos", "腾讯云 COS", svcCatStorage, 1,
sf("secret_id", "SecretId", true), sf("secret_key", "SecretKey", true), sf("bucket", "存储桶", false), sf("region", "地域,如 ap-guangzhou", false)),
sf("domain", "自定义域名(可选,如 oss.ymaikj.com;留空用 OSS 源站地址)", false),
// 分发桶:放对外公开下载的 App 安装包,与放录音/固件的主桶分开。凭据复用上面那套。
// 留空则用 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 启用工具白名单。
// 各区域的实际 url/tools 通过「区域覆盖分叉」维护,运行时按客户端区域解析。

109
apps/services/modules/console/api_upload.go

@ -11,6 +11,7 @@ package console
// 3. 预签名 PUT 会把 Content-Type 计入签名,故前端 PUT 必须带同样的 Content-Type(uploadFile 已对齐)。
import (
"fmt"
"path"
"strings"
@ -56,15 +57,92 @@ func buildObjectKey(dir, name string) string {
return key
}
// getUploadURL 前端固件/图片直传:读取阿里云 OSS 配置,为目标对象签发一个预签名 PUT URL。
// distContentTypes 分发桶里按扩展名强制的 Content-Type。
//
// ⚠️ 不能信前端传上来的 content_type:它计入预签名,一旦签成 text/plain 之类,
// 安卓点下载会变成在浏览器里打开一堆乱码,而且要等真机装包时才发现。
// apk 用官方类型即可,浏览器不会尝试预览它,所以不需要再折腾 Content-Disposition。
var distContentTypes = map[string]string{
".apk": "application/vnd.android.package-archive",
}
// distAppSeg 把应用名清洗成可安全放进 object key 的一段。
//
// 值来自 X-App-Id 查库(不是前端传的),但仍然要洗:库里的名字是人填的,
// 出现 "/" 或 ".." 就能把包传到别的目录去。只留字母数字和 -_,其余换成 _。
func distAppSeg(app string) string {
app = strings.TrimSpace(app)
var b strings.Builder
for _, r := range app {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
b.WriteRune(r)
default:
b.WriteRune('_')
}
}
// 洗完只剩下划线(应用名是 "//" 之类)时当作空:否则会造出 "__" 这种垃圾目录,
// 而且它非空、能过下面的判断,等于白洗了。
seg := b.String()
if strings.Trim(seg, "_") == "" {
return ""
}
return seg
}
// buildDistObjectKey 生成「分发桶」里的对象 key:<目录>/<应用>/<文件名>。
//
// 与主桶不同,这里**不加 EAIMAR 根前缀**:分发桶直接挂在 dl.ymaikj.com 上,
// 传到 android/EAIMAR/x.apk 就是 https://dl.ymaikj.com/android/EAIMAR/x.apk,
// 多一层固定前缀会让下载地址凭空多出一段。目录只允许 DistUploadDirs 里的那几个。
//
// ⚠️ **应用名这一段是必须的**:一个桶要放多个应用(EAIMAR / deepGlass …)的安装包,
// 不按应用分目录的话,两个应用只要打出同名文件就会互相覆盖——而且覆盖是静默的,
// 要等用户下到别家的包才会发现。
func buildDistObjectKey(app, dir, name string) (string, error) {
seg := distAppSeg(app)
if seg == "" {
return "", fmt.Errorf("上传安装包必须先选择应用(请求缺少 X-App-Id)")
}
dir = strings.Trim(path.Clean("/"+strings.TrimSpace(dir)), "/")
if dir == "" || dir == "." {
return "", fmt.Errorf("缺少上传目录")
}
// 只认第一段,且必须在白名单里:path.Clean 已经解掉了 ..,这里再卡一次目录名
top := dir
if i := strings.Index(dir, "/"); i >= 0 {
top = dir[:i]
}
if !comm.DistUploadDirs[top] {
return "", fmt.Errorf("不支持的上传目录: %s", top)
}
name = path.Base(strings.TrimSpace(name))
if name == "" || name == "." || name == "/" {
return "", fmt.Errorf("文件名无效")
}
return path.Join(dir, seg, name), nil
}
// getUploadURL 前端固件/图片/安装包直传:读取阿里云 OSS 配置,为目标对象签发一个预签名 PUT URL。
//
// 路由沿用 api_getcostoken(前端 useApi.uploadFile 用该名,避免前端改路由)。
// 请求体 { path, filename, content_type };返回 { upload_url(预签名PUT), url(去签名的公网URL), key }。
// 请求体 { path, filename, content_type, target };返回 { upload_url(预签名PUT), url(去签名的公网URL), key }。
//
// target="dist" 时传到「分发桶」(dl.ymaikj.com),用于 App 安装包;缺省走主桶(固件/产品图)。
func (this *serverComp) getUploadURL(c *gin.Context) {
var req struct {
Path string `json:"path"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Target string `json:"target"`
// AppName 页面上选中的应用名(app_registry 的 name / app_name)。
//
// ⚠️ 只在没有 X-App-Id 时才用它。X-App-Id 来自登录态里的 currentAppId,
// 只有运营/代理这种「绑定了应用」的账号才有;超管在「版本与分发」页顶部选了应用,
// 那个选择只是页面里的一个 app_name 字符串,根本不会进请求头——于是超管传安装包
// 必然被拒成「必须先选择应用」,且看起来像是没选。前端现在把页面上选的应用名
// 一并放进请求体,这里按名字反查注册表补上作用域。
AppName string `json:"app_name"`
}
_ = c.ShouldBindJSON(&req)
if strings.TrimSpace(req.Filename) == "" {
@ -78,18 +156,38 @@ func (this *serverComp) getUploadURL(c *gin.Context) {
appName = app.Name
}
}
if appName == "" {
if want := strings.TrimSpace(req.AppName); want != "" {
if app, err := this.module.model.getAppByNameOrAppName(want); err == nil && app != nil {
appName = app.Name
}
}
}
conf, err := this.loadAliyunOSSConf(appName)
if err != nil {
writeErr(c, pb.ErrorCode_SystemError, err.Error())
return
}
var key string
if strings.EqualFold(strings.TrimSpace(req.Target), "dist") {
conf = conf.ForDist()
if key, err = buildDistObjectKey(appName, req.Path, req.Filename); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
} else {
key = buildObjectKey(req.Path, req.Filename)
}
presigner, err := oss.NewPresigner(conf.Endpoint, conf.AccessKeyId, conf.AccessSecret, conf.Bucket)
if err != nil {
writeErr(c, pb.ErrorCode_SystemError, "初始化阿里云 OSS 失败: "+err.Error())
return
}
key := buildObjectKey(req.Path, req.Filename)
ct := strings.TrimSpace(req.ContentType)
// 扩展名认识就以服务端为准,别信前端(理由见 distContentTypes)
if forced := distContentTypes[strings.ToLower(path.Ext(key))]; forced != "" {
ct = forced
}
if ct == "" {
ct = "application/octet-stream"
}
@ -101,5 +199,8 @@ func (this *serverComp) getUploadURL(c *gin.Context) {
// 公网访问 URL(存进 updatepackageaddress 的裸地址):
// 配了自定义域名 → https://<domain>/<key>(走 CDN);否则去掉预签名 query 的裸 OSS 地址。
publicURL := conf.PublicURL(uploadURL, key)
writeOK(c, gin.H{"upload_url": uploadURL, "url": publicURL, "key": key})
// ⚠️ 必须把实际签名用的 content_type 回带:服务端会按扩展名强制改写它(见 distContentTypes),
// 而浏览器对 .apk 给出的 File.type 往往是**空字符串**。前端若仍按 file.type 发 PUT,
// 与签名不符,OSS 直接回 403 SignatureDoesNotMatch——且报错里完全看不出是类型对不上。
writeOK(c, gin.H{"upload_url": uploadURL, "url": publicURL, "key": key, "content_type": ct})
}

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"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
@ -32,7 +31,6 @@ import (
"time"
ossSdk "github.com/aliyun/aliyun-oss-go-sdk/oss"
cosSdk "github.com/tencentyun/cos-go-sdk-v5"
"golang.org/x/oauth2/google"
"yunyan/comm"
@ -82,8 +80,6 @@ func probeService(ctx context.Context, provider string, cats map[int32]bool, fie
return probeGoogle(ctx, fields)
case "aliyun_oss":
return probeAliyunOSS(ctx, fields)
case "tencent_cos":
return probeTencentCOS(ctx, fields)
case "alibaba":
return probeAlibaba(ctx, fields)
case "doubao":
@ -431,26 +427,6 @@ func probeAliyunOSS(ctx context.Context, fields map[string]string) probeResult {
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 签名。

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)【加密】
// ── 私有镜像仓库 + 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"` //
RegistryPass string `gorm:"column:registry_pass;size:255" json:"registry_pass"` // 【加密】
DockerNetwork string `gorm:"column:docker_network;size:64" json:"docker_network"` // 如 1panel-network

10
apps/services/modules/console/model_registry.go

@ -133,6 +133,16 @@ func (this *modelComp) getAppByName(name string) (model *AppRegistry, err error)
return
}
// getAppByNameOrAppName 按「部署名 name」或「应用名 app_name」任一命中查注册项。
//
// 后台页面上的应用选择器给的是 app_name(EAIMAR / deepGlass),而 getAppByName 只查 name;
// 两列在现网虽然同值,但按 CLAUDE.md 的口径它们语义不同,这里两列都认,别依赖它们恰好相等。
func (this *modelComp) getAppByNameOrAppName(name string) (model *AppRegistry, err error) {
model = &AppRegistry{}
err = postgres.FindOne(comm.TableAppRegistry, model, "name=? OR app_name=?", name, name)
return
}
// addApp 新增注册项(Insert 后 model.Id 被回填)。
func (this *modelComp) addApp(model *AppRegistry) (err error) {
err = postgres.Insert(comm.TableAppRegistry, model)

199
apps/services/modules/echomeet/api_sharelink.go

@ -0,0 +1,199 @@
package echomeet
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"os"
"strings"
"time"
"yunyan/comm"
"yunyan/pb"
)
/*
会议纪要分享:服务端签发的只读票据。
⚠️ 2026-09-23 之前,客户端把**用户的登录 JWT**(GetStorage 里的 logintoken)直接拼进
分享 URL 的最后一段。谁拿到那条链接就拿到了整个账号——能调任何业务接口、看全部纪要、
消费算力,而且除了改密码没有任何失效手段。链接一旦转发进群或被截图就收不回来。
现在换成票据:一条票据只对应**一条记录**、只能读、有有效期,且不含任何可用于其它接口的凭据。
票据格式(自包含,不落库):
<payload>.<sig>
payload = base64url(JSON{"i":记录id,"u":owner uid,"e":到期unix秒})
sig = base64url(HMAC-SHA256(shareKey, payload))
为什么不落库:分享是读多写少、量小且可失效期自然收敛的场景,落库要多一张表 + 清理任务,
而票据本身已经带了全部信息。代价是**签发后无法单独撤销**(只能等过期或换密钥),
这是刻意接受的取舍——真要撤销能力时再补一张吊销表即可,票据格式不用变。
*/
const (
// shareTicketDefaultDays 不指定时的有效期。
shareTicketDefaultDays = 7
// shareTicketMaxDays 有效期上限。链接是公开可转发的,无限期等于永久泄露。
shareTicketMaxDays = 30
// shareKeyContext 密钥派生的域分隔串。换它等于让全部存量票据立刻失效。
shareKeyContext = "echomeet.share.v1"
)
var errShareTicketInvalid = errors.New("分享链接无效或已过期")
// sharePayload 票据载荷。字段名压到一个字母:它要进 URL,越短越不容易被中间环节截断。
type sharePayload struct {
Id uint64 `json:"i"`
Uid string `json:"u"`
Expire int64 `json:"e"`
}
// shareKey 从 FIELD_ENCRYPT_KEY 派生分享票据的签名密钥。
//
// 为什么派生而不是直接用:FIELD_ENCRYPT_KEY 是 svc_config 里第三方凭据的加密密钥,
// 拿它直接签票据会让两个用途共用同一份秘密。HMAC 是单向的,从派生key 推不回原 key。
//
// 为什么选 FIELD_ENCRYPT_KEY 而不是新加一个配置项:它已经在每台机的 .env 里
// (console 与各业务服务本就必须配同一把),新增配置项意味着上线时漏配一处就整个功能不可用,
// 而且漏配的表现是「分享按钮点了报错」这种要到真机才发现的问题。
func shareKey() ([]byte, error) {
base := strings.TrimSpace(os.Getenv("FIELD_ENCRYPT_KEY"))
if base == "" {
// 刻意不退化成空密钥签名:那样任何人都能自己造票据读任意记录。
return nil, errors.New("缺少 FIELD_ENCRYPT_KEY,无法签发分享票据")
}
m := hmac.New(sha256.New, []byte(base))
m.Write([]byte(shareKeyContext))
return m.Sum(nil), nil
}
// signShareTicket 签发票据。
func signShareTicket(p sharePayload) (string, error) {
key, err := shareKey()
if err != nil {
return "", err
}
raw, err := json.Marshal(p)
if err != nil {
return "", err
}
body := base64.RawURLEncoding.EncodeToString(raw)
m := hmac.New(sha256.New, key)
m.Write([]byte(body))
return body + "." + base64.RawURLEncoding.EncodeToString(m.Sum(nil)), nil
}
// parseShareTicket 校验并解出票据载荷。
//
// ⚠️ 「签名不对」「格式不对」「已过期」一律回同一个 errShareTicketInvalid。
// 分开回等于告诉攻击者「这条票据是真的、只是过期了」,同 recordErr 的理由。
func parseShareTicket(ticket string) (*sharePayload, error) {
ticket = strings.TrimSpace(ticket)
i := strings.LastIndex(ticket, ".")
if i <= 0 || i == len(ticket)-1 {
return nil, errShareTicketInvalid
}
body, sig := ticket[:i], ticket[i+1:]
key, err := shareKey()
if err != nil {
return nil, err
}
m := hmac.New(sha256.New, key)
m.Write([]byte(body))
want := m.Sum(nil)
got, err := base64.RawURLEncoding.DecodeString(sig)
// hmac.Equal 是常数时间比较:用 == 比会让攻击者靠计时逐字节试出正确签名。
if err != nil || !hmac.Equal(want, got) {
return nil, errShareTicketInvalid
}
raw, err := base64.RawURLEncoding.DecodeString(body)
if err != nil {
return nil, errShareTicketInvalid
}
p := &sharePayload{}
if err = json.Unmarshal(raw, p); err != nil {
return nil, errShareTicketInvalid
}
if p.Id == 0 || p.Uid == "" || p.Expire <= 0 || time.Now().Unix() > p.Expire {
return nil, errShareTicketInvalid
}
return p, nil
}
// @Summary 创建会议纪要分享链接
// @Description 为自己的一条会议记录签发只读分享票据,客户端拼成落地页 URL
// @Tags Echomeet
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param user body pb.EchomeetCreateShareLinkReq true "请求数据"
// @Success 200 {object} comm.HttpResult{data=pb.EchomeetCreateShareLinkResp} "响应数据"
// @Router /api/home/echomeet_createsharelink [post]
func (this *apiComp) CreateShareLink(session comm.IUserSession, req *pb.EchomeetCreateShareLinkReq) (resp *pb.EchomeetCreateShareLinkResp, errdata *pb.ErrorData) {
uid, errdata := requireUID(session)
if errdata != nil {
return
}
// 先确认这条记录确实是本人的:签发前不校验的话,改个 id 就能给别人的纪要签出公开链接。
if _, err := this.module.model.getrecordforuid(uid, req.Id); err != nil {
errdata = recordErr(err)
return
}
days := int(req.Expiredays)
if days <= 0 {
days = shareTicketDefaultDays
}
if days > shareTicketMaxDays {
days = shareTicketMaxDays
}
expire := time.Now().AddDate(0, 0, days).Unix()
ticket, err := signShareTicket(sharePayload{Id: req.Id, Uid: uid, Expire: expire})
if err != nil {
errdata = &pb.ErrorData{Code: pb.ErrorCode_SystemError, Message: err.Error()}
return
}
resp = &pb.EchomeetCreateShareLinkResp{Ticket: ticket, Expiretime: expire}
return
}
// @Summary 按分享票据读取会议纪要
// @Description 落地页调用,**免登录**,须在 gateway 的 WhiteList 里
// @Tags Echomeet
// @Accept json
// @Produce json
// @Param user body pb.EchomeetGetSharedRecordReq true "请求数据"
// @Success 200 {object} comm.HttpResult{data=pb.EchomeetGetSharedRecordResp} "响应数据"
// @Router /api/home/echomeet_getsharedrecord [post]
func (this *apiComp) GetSharedRecord(session comm.IUserSession, req *pb.EchomeetGetSharedRecordReq) (resp *pb.EchomeetGetSharedRecordResp, errdata *pb.ErrorData) {
// 注意:这里**不能**调 requireUID —— 本接口的全部意义就是让没登录的人也能打开链接。
p, err := parseShareTicket(req.Ticket)
if err != nil {
errdata = &pb.ErrorData{Code: pb.ErrorCode_StateInvalid, Message: err.Error()}
return
}
// 仍按 (uid, id) 查:票据签发时绑定了 owner,记录易主或被删后票据自然失效。
model, err := this.module.model.getrecordforuid(p.Uid, p.Id)
if err != nil {
// 记录已删时同样回「链接无效」,不区分——链接是公开的,不该成为探测记录存在性的入口。
errdata = &pb.ErrorData{Code: pb.ErrorCode_StateInvalid, Message: errShareTicketInvalid.Error()}
return
}
resp = &pb.EchomeetGetSharedRecordResp{
Id: model.Id,
Title: model.Title,
Creationtime: model.Creationtime,
Seconds: model.Seconds,
Audiourl: model.Audiourl,
Original: model.Original,
Translate: model.Translate,
Overview: model.Overview,
Summary: model.Summary,
Formlanguage: model.Formlanguage,
Tolanguage: model.Tolanguage,
Expiretime: p.Expire,
}
return
}

107
apps/services/modules/echomeet/api_sharelink_test.go

@ -0,0 +1,107 @@
package echomeet
import (
"encoding/base64"
"encoding/json"
"os"
"strings"
"testing"
"time"
)
// 票据是公开链接里的唯一凭据,下面每条都是「错了就等于任何人能读任意纪要」级别的性质,
// 所以全部钉死在测试里。
func TestShareTicketRoundTrip(t *testing.T) {
os.Setenv("FIELD_ENCRYPT_KEY", "test-field-key-0123456789")
defer os.Unsetenv("FIELD_ENCRYPT_KEY")
exp := time.Now().Add(time.Hour).Unix()
ticket, err := signShareTicket(sharePayload{Id: 42, Uid: "1234567890", Expire: exp})
if err != nil {
t.Fatalf("签发失败: %v", err)
}
got, err := parseShareTicket(ticket)
if err != nil {
t.Fatalf("校验失败: %v", err)
}
if got.Id != 42 || got.Uid != "1234567890" || got.Expire != exp {
t.Fatalf("载荷不一致: %+v", got)
}
}
func TestShareTicketRejects(t *testing.T) {
os.Setenv("FIELD_ENCRYPT_KEY", "test-field-key-0123456789")
defer os.Unsetenv("FIELD_ENCRYPT_KEY")
good, _ := signShareTicket(sharePayload{Id: 42, Uid: "u1", Expire: time.Now().Add(time.Hour).Unix()})
body := good[:strings.LastIndex(good, ".")]
// 改载荷不改签名:这是最直接的攻击——把 id 换成别人的记录。
tampered := base64.RawURLEncoding.EncodeToString([]byte(`{"i":43,"u":"u1","e":9999999999}`))
cases := map[string]string{
"空串": "",
"没有分隔点": body,
"签名被截断": body + ".",
"签名乱填": body + ".AAAA",
"载荷被篡改": tampered + good[strings.LastIndex(good, "."):],
"整段不是base64": "!!!.@@@",
}
for name, tk := range cases {
if _, err := parseShareTicket(tk); err == nil {
t.Errorf("%s: 本该被拒,却通过了", name)
}
}
// 过期票据必须拒绝:签名是对的,只有时间不对。
expired, _ := signShareTicket(sharePayload{Id: 42, Uid: "u1", Expire: time.Now().Add(-time.Second).Unix()})
if _, err := parseShareTicket(expired); err == nil {
t.Error("过期票据本该被拒")
}
}
// 换密钥必须让旧票据全部失效——否则「换密钥」这个应急手段是假的。
func TestShareTicketKeyIsolation(t *testing.T) {
os.Setenv("FIELD_ENCRYPT_KEY", "key-A")
ticket, err := signShareTicket(sharePayload{Id: 1, Uid: "u", Expire: time.Now().Add(time.Hour).Unix()})
if err != nil {
t.Fatal(err)
}
os.Setenv("FIELD_ENCRYPT_KEY", "key-B")
defer os.Unsetenv("FIELD_ENCRYPT_KEY")
if _, err = parseShareTicket(ticket); err == nil {
t.Fatal("换密钥后旧票据仍然有效")
}
}
// 没有 FIELD_ENCRYPT_KEY 时必须报错,绝不能退化成用空密钥签名(那样谁都能自己造票据)。
func TestShareTicketRefusesEmptyKey(t *testing.T) {
old := os.Getenv("FIELD_ENCRYPT_KEY")
os.Unsetenv("FIELD_ENCRYPT_KEY")
defer os.Setenv("FIELD_ENCRYPT_KEY", old)
if _, err := signShareTicket(sharePayload{Id: 1, Uid: "u", Expire: 1}); err == nil {
t.Fatal("缺密钥时本该拒绝签发")
}
}
// 票据里不该出现任何登录凭据的痕迹:它进 URL、会被转发、会进浏览器历史。
func TestShareTicketCarriesOnlyScopedPayload(t *testing.T) {
os.Setenv("FIELD_ENCRYPT_KEY", "test-field-key-0123456789")
defer os.Unsetenv("FIELD_ENCRYPT_KEY")
ticket, _ := signShareTicket(sharePayload{Id: 7, Uid: "u7", Expire: time.Now().Add(time.Hour).Unix()})
raw, err := base64.RawURLEncoding.DecodeString(ticket[:strings.LastIndex(ticket, ".")])
if err != nil {
t.Fatal(err)
}
m := map[string]any{}
if err = json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
if len(m) != 3 {
t.Fatalf("载荷字段数变了(%d),新增字段前先确认它可以公开: %v", len(m), m)
}
for _, k := range []string{"i", "u", "e"} {
if _, ok := m[k]; !ok {
t.Fatalf("载荷缺字段 %s: %v", k, m)
}
}
}

37
apps/services/modules/gateway/share_page.go

@ -0,0 +1,37 @@
package gateway
import (
_ "embed"
"net/http"
"github.com/gin-gonic/gin"
)
// 会议纪要分享落地页。
//
// 为什么嵌进二进制而不是当静态文件下发:
// 这个页面必须跟着 echomeet_getsharedrecord 的协议走,两者版本对不上就是白页。
// 嵌进来意味着「部署了新镜像 = 页面也是新的」,服务器上不需要维护任何文件,
// prod-deploy.sh 也不用多下发一份(confs/*.yaml 那套人工维护的坑已经够多了)。
//
// 上线只剩一步:NPM 上把 share.ymaikj.com 反代到 ym-a11:7100(即本 gateway),申请证书。
//
//go:embed share_page.html
var sharePageHTML []byte
// sharePage 把落地页返回给任意 /share/... 路径。
//
// ⚠️ 必须是 catch-all:真实链接形如 /share/ym.ymaikj.com/ymaikj.com/42/<ticket>,
// 路径里的段是**数据**不是文件路径。用 gin.Static 会去磁盘上找同名文件,一律 404。
// 解析工作全在页面里的 JS 做(它要按 location.pathname 取 serverHost/id/ticket)。
func (this *WSServiceComp) sharePage(c *gin.Context) {
// 不缓存:页面里写死了接口协议,缓存住旧版会在协议变更后变成白页,而用户
// 完全没有手段让它刷新(分享链接是别人发来的,不会有人去硬刷)。
c.Header("Cache-Control", "no-cache, must-revalidate")
// 落地页不该被别的站点嵌进 iframe 里套壳。
c.Header("X-Frame-Options", "SAMEORIGIN")
c.Header("X-Content-Type-Options", "nosniff")
// 别把带票据的完整 URL 经 Referer 泄漏给第三方(音频在 OSS 上,是跨站请求)。
c.Header("Referrer-Policy", "no-referrer")
c.Data(http.StatusOK, "text/html; charset=utf-8", sharePageHTML)
}

316
apps/services/modules/gateway/share_page.html

@ -0,0 +1,316 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="referrer" content="no-referrer">
<title>会议纪要</title>
<style>
:root{
--bg:#f5f6f8; --card:#fff; --fg:#1b1d21; --muted:#6b7280; --line:#e5e7eb;
--accent:#2563eb; --accent-soft:#eff4ff; --danger:#b42318;
}
@media (prefers-color-scheme:dark){
:root{
--bg:#101215; --card:#191c21; --fg:#e8eaed; --muted:#9aa1ab; --line:#2a2f37;
--accent:#6ea0ff; --accent-soft:#1b2436; --danger:#ff8a80;
}
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{
background:var(--bg); 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%;
}
.wrap{max-width:760px;margin:0 auto;padding:16px 16px 40px}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;margin-bottom:12px}
h1{font-size:19px;line-height:1.4;margin:0 0 8px;word-break:break-word}
.meta{color:var(--muted);font-size:13px;display:flex;flex-wrap:wrap;gap:4px 14px}
audio{width:100%;margin-top:14px}
/* 标签栏:内容少时只显示有内容的那几个 */
.tabs{display:flex;gap:6px;border-bottom:1px solid var(--line);margin:0 0 14px;overflow-x:auto;-webkit-overflow-scrolling:touch}
.tab{
flex:0 0 auto;padding:9px 14px;border:0;background:none;color:var(--muted);
font-size:14px;font-family:inherit;cursor:pointer;border-bottom:2px solid transparent;
}
.tab[aria-selected="true"]{color:var(--accent);border-bottom-color:var(--accent);font-weight:600}
.panel[hidden]{display:none}
/* 转写:每句一行,带说话人与时间戳 */
.line{display:flex;gap:10px;padding:7px 0;border-bottom:1px solid var(--line)}
.line:last-child{border-bottom:0}
.line .who{flex:0 0 auto;min-width:74px;color:var(--accent);font-size:13px;font-weight:600}
.line .t{flex:0 0 auto;color:var(--muted);font-size:12px;font-variant-numeric:tabular-nums;padding-top:2px}
.line .txt{flex:1 1 auto;word-break:break-word}
/* Markdown 渲染出来的总结 */
.md h1,.md h2,.md h3,.md h4{line-height:1.4;margin:18px 0 8px}
.md h1{font-size:18px} .md h2{font-size:17px} .md h3{font-size:15px} .md h4{font-size:14px}
.md>:first-child{margin-top:0}
.md p{margin:8px 0}
.md ul,.md ol{margin:8px 0;padding-left:22px}
.md li{margin:3px 0}
.md code{background:var(--accent-soft);padding:1px 5px;border-radius:4px;font-size:13px}
.md pre{background:var(--accent-soft);padding:12px;border-radius:8px;overflow-x:auto}
.md pre code{background:none;padding:0}
.md blockquote{margin:8px 0;padding:2px 0 2px 12px;border-left:3px solid var(--line);color:var(--muted)}
.md hr{border:0;border-top:1px solid var(--line);margin:16px 0}
.md table{border-collapse:collapse;width:100%;margin:10px 0;font-size:14px;display:block;overflow-x:auto}
.md th,.md td{border:1px solid var(--line);padding:7px 10px;text-align:left}
.md th{background:var(--accent-soft);font-weight:600}
.state{text-align:center;padding:52px 16px;color:var(--muted)}
.state strong{display:block;color:var(--fg);font-size:16px;margin-bottom:6px}
.state.err strong{color:var(--danger)}
.foot{text-align:center;color:var(--muted);font-size:12px;margin-top:18px;line-height:1.8}
.foot a{color:var(--accent);text-decoration:none}
.spin{width:22px;height:22px;margin:0 auto 12px;border:2px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:r .8s linear infinite}
@keyframes r{to{transform:rotate(360deg)}}
</style>
</head>
<body>
<div class="wrap" id="root">
<div class="state"><div class="spin"></div>正在打开…</div>
</div>
<script>
(function () {
'use strict';
// ⚠️ 后端域名白名单。serverHost 是从 URL 路径里读出来的,**不校验就等于**:
// 任何人构造一条 /share/<攻击者域名>/.../<票据> 的链接,就能让打开它的人的浏览器
// 向攻击者的服务器发请求(顺带把票据送过去)。既是钓鱼跳板也是票据泄漏。
// 新增环境要在这里加一行,否则那个环境分享出来的链接打不开。
var ALLOWED_HOSTS = [
'ym.ymaikj.com', // 正式
'ym-dev.ymaikj.com' // 阿龙测试
];
var DOWNLOAD_URL = 'https://dl.ymaikj.com'; // 页脚的 App 下载引导
var root = document.getElementById('root');
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function show(html) { root.innerHTML = html; }
function fail(title, detail) {
show('<div class="state err"><strong>' + esc(title) + '</strong>' + esc(detail || '') + '</div>'
+ foot(''));
}
function foot(extra) {
return '<div class="foot">' + (extra ? extra + '<br>' : '')
+ '由 EAIMAR 生成 · <a href="' + DOWNLOAD_URL + '">下载 App</a></div>';
}
// ── 路径解析 ──────────────────────────────────────────────
// /share/{serverHost}/{brand}/{id}/{ticket}
// brand 段是品牌标识,落地页不使用,但保留在路径里以兼容已发出去的链接形状。
// ⚠️ 票据是 base64url,本身不含 '/',所以按 '/' 切是安全的;真要变格式记得回来改这里。
function parsePath() {
var seg = location.pathname.split('/').filter(Boolean); // ['share', host, brand, id, ticket]
if (seg.length < 5 || seg[0] !== 'share') return null;
return { host: seg[1], id: seg[3], ticket: seg.slice(4).join('/') };
}
// ── 极简 Markdown 渲染(总结是 Markdown,见客户端 _exportMarkdownPdf)────
// 不引第三方库:这个页面要在国内手机上、可能很差的网络下秒开,为渲染几段
// 标题和列表去拉一个 CDN 脚本不值得,而且 CDN 被墙时整页就废了。
// ⚠️ 先 esc 再套正则,顺序不能反 —— 内容来自用户录音与模型输出,反了就是 XSS。
function md(src) {
var lines = esc(src).replace(/\r\n?/g, '\n').split('\n');
var out = [], i = 0;
function inline(t) {
return t
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
}
while (i < lines.length) {
var ln = lines[i];
if (/^\s*$/.test(ln)) { i++; continue; }
// 代码块
if (/^```/.test(ln)) {
var buf = []; i++;
while (i < lines.length && !/^```/.test(lines[i])) { buf.push(lines[i]); i++; }
i++;
out.push('<pre><code>' + buf.join('\n') + '</code></pre>');
continue;
}
// 标题
var h = ln.match(/^(#{1,4})\s+(.*)$/);
if (h) { var lv = h[1].length; out.push('<h' + lv + '>' + inline(h[2]) + '</h' + lv + '>'); i++; continue; }
// 分隔线
if (/^\s*([-*_])\s*\1\s*\1[\s\-*_]*$/.test(ln)) { out.push('<hr>'); i++; continue; }
// 表格:|a|b| 后跟 |---|---|
if (/^\s*\|/.test(ln) && i + 1 < lines.length && /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1])) {
var cells = function (r) {
return r.trim().replace(/^\||\|$/g, '').split('|').map(function (c) { return inline(c.trim()); });
};
var head = cells(ln); i += 2;
var body = [];
while (i < lines.length && /^\s*\|/.test(lines[i])) { body.push(cells(lines[i])); i++; }
out.push('<table><thead><tr>' + head.map(function (c) { return '<th>' + c + '</th>'; }).join('')
+ '</tr></thead><tbody>'
+ body.map(function (r) { return '<tr>' + r.map(function (c) { return '<td>' + c + '</td>'; }).join('') + '</tr>'; }).join('')
+ '</tbody></table>');
continue;
}
// 引用
// ⚠️ 这里匹配的是 &gt; 不是 >:上面已经整段 esc() 过了,原文的 '>' 此刻是 '&gt;'。
// 写成 /^\s*>/ 会永远匹配不上,引用块整个退化成普通段落(而且不报错)。
if (/^\s*&gt;\s?/.test(ln)) {
var q = [];
while (i < lines.length && /^\s*&gt;\s?/.test(lines[i])) { q.push(lines[i].replace(/^\s*&gt;\s?/, '')); i++; }
out.push('<blockquote>' + inline(q.join('<br>')) + '</blockquote>');
continue;
}
// 列表(有序/无序)
var isOl = /^\s*\d+[.)]\s+/.test(ln);
if (isOl || /^\s*[-*+]\s+/.test(ln)) {
var items = [];
var re = isOl ? /^\s*\d+[.)]\s+/ : /^\s*[-*+]\s+/;
while (i < lines.length && re.test(lines[i])) { items.push(inline(lines[i].replace(re, ''))); i++; }
var tag = isOl ? 'ol' : 'ul';
out.push('<' + tag + '>' + items.map(function (t) { return '<li>' + t + '</li>'; }).join('') + '</' + tag + '>');
continue;
}
// 普通段落:连续非空行合成一段
var p = [];
while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^(#{1,4}\s|```|\s*[-*+]\s|\s*\d+[.)]\s|\s*&gt;|\s*\|)/.test(lines[i])) {
p.push(lines[i]); i++;
}
if (p.length) out.push('<p>' + inline(p.join('<br>')) + '</p>');
else i++; // 兜底,避免任何情况下的死循环
}
return out.join('');
}
// ── 转写/译文:JSON 数组 [{speaker,content,starttime,endtime}] ────
// ⚠️ 不能假定它一定是合法 JSON:老记录、导入的外部音频里躺着纯文本。
// 解析失败就原样当文本显示,总比整页报错强。
function renderLines(raw) {
if (!raw) return '';
var arr = null;
try { arr = JSON.parse(raw); } catch (e) { /* 落到纯文本分支 */ }
if (!Array.isArray(arr)) return '<p>' + esc(raw).replace(/\n/g, '<br>') + '</p>';
return arr.map(function (it) {
var who = it.speaker ? '<span class="who">' + esc(it.speaker) + '</span>' : '';
var t = (typeof it.starttime === 'number' && it.starttime > 0)
? '<span class="t">' + hms(Math.floor(it.starttime / 1000)) + '</span>' : '';
return '<div class="line">' + who + t + '<span class="txt">' + esc(it.content || '') + '</span></div>';
}).join('');
}
function hms(sec) {
sec = Math.max(0, sec | 0);
var h = (sec / 3600) | 0, m = ((sec % 3600) / 60) | 0, s = sec % 60;
var p = function (n) { return n < 10 ? '0' + n : '' + n; };
return (h ? h + ':' : '') + p(m) + ':' + p(s);
}
function duration(sec) {
if (!sec) return '';
var m = Math.floor(sec / 60), s = sec % 60;
return m ? (m + ' 分 ' + s + ' 秒') : (s + ' 秒');
}
function ymd(unix) {
if (!unix) return '';
var d = new Date(unix * 1000), p = function (n) { return n < 10 ? '0' + n : n; };
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate())
+ ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
}
// ── 渲染 ───────────────────────────────────────────────────
function render(r) {
document.title = r.title || '会议纪要';
var metas = [];
if (r.creationtime) metas.push('<span>' + esc(ymd(r.creationtime)) + '</span>');
if (r.seconds) metas.push('<span>时长 ' + esc(duration(r.seconds)) + '</span>');
var head = '<div class="card"><h1>' + esc(r.title || '未命名会议') + '</h1>'
+ (metas.length ? '<div class="meta">' + metas.join('') + '</div>' : '')
+ (r.audiourl ? '<audio controls preload="none" src="' + esc(r.audiourl) + '"></audio>' : '')
+ '</div>';
// 只放有内容的标签页:空标签点进去一片白,比不显示更让人以为是坏了
var tabs = [];
if (r.summary) tabs.push({ k: 'sum', n: '总结', h: '<div class="md">' + md(r.summary) + '</div>' });
else if (r.overview) tabs.push({ k: 'sum', n: '总结', h: '<div class="md">' + md(r.overview) + '</div>' });
if (r.original) tabs.push({ k: 'org', n: '转写', h: renderLines(r.original) });
if (r.translate) tabs.push({ k: 'trs', n: '译文', h: renderLines(r.translate) });
var body;
if (!tabs.length) {
body = '<div class="card"><div class="state">这条纪要还没有内容<br><span style="font-size:13px">可能仍在转写中,稍后再打开试试</span></div></div>';
} else {
body = '<div class="card">'
+ (tabs.length > 1
? '<div class="tabs" role="tablist">' + tabs.map(function (t, i) {
return '<button class="tab" role="tab" data-k="' + t.k + '" aria-selected="' + (i === 0) + '">' + t.n + '</button>';
}).join('') + '</div>'
: '')
+ tabs.map(function (t, i) {
return '<div class="panel" data-k="' + t.k + '"' + (i === 0 ? '' : ' hidden') + '>' + t.h + '</div>';
}).join('')
+ '</div>';
}
var exp = r.expiretime ? ('本链接于 ' + esc(ymd(r.expiretime)) + ' 失效') : '';
show(head + body + foot(exp));
Array.prototype.forEach.call(document.querySelectorAll('.tab'), function (btn) {
btn.addEventListener('click', function () {
Array.prototype.forEach.call(document.querySelectorAll('.tab'), function (b) {
b.setAttribute('aria-selected', b === btn);
});
Array.prototype.forEach.call(document.querySelectorAll('.panel'), function (p) {
p.hidden = p.getAttribute('data-k') !== btn.getAttribute('data-k');
});
});
});
}
// ── 主流程 ─────────────────────────────────────────────────
var q = parsePath();
if (!q) { fail('链接不完整', '请确认复制了完整的分享链接'); return; }
if (ALLOWED_HOSTS.indexOf(q.host) < 0) { fail('链接无效', '来源不在允许列表中'); return; }
var xhr = new XMLHttpRequest();
xhr.open('POST', 'https://' + q.host + '/api/home/echomeet_getsharedrecord', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.timeout = 20000;
xhr.onload = function () {
var res;
try { res = JSON.parse(xhr.responseText); } catch (e) {
fail('打开失败', '服务返回了无法识别的内容'); return;
}
// 业务码非 0 一律按「链接失效」呈现:服务端刻意不区分「票据错」「已过期」
// 「记录已删」,这里再自作主张分类只会给出不准确的说法。
// ⚠️ 错误消息的字段名是 msg 不是 message(comm.HttpResult 的 json tag)。
if (!res || res.code !== 0 || !res.data) {
fail('链接已失效', '分享链接可能已过期,或已被创建者删除');
return;
}
render(res.data);
};
xhr.onerror = function () { fail('网络异常', '请检查网络后重试'); };
xhr.ontimeout = function () { fail('网络超时', '请检查网络后重试'); };
xhr.send(JSON.stringify({ ticket: q.ticket }));
})();
</script>
</body>
</html>

2
apps/services/modules/gateway/wservice_comp.go

@ -63,6 +63,8 @@ func (this *WSServiceComp) Init(service core.IService, module core.IModule, comp
this.gin.Static("/avatar", "./avatar")
this.gin.Static("/console", "./console")
this.gin.Static("/docs", "./docs")
// 会议纪要分享落地页(share.ymaikj.com 反代到本端口)。catch-all,理由见 share_page.go。
this.gin.GET("/share/*any", this.sharePage)
this.gin.Any("/api/:param1/:param2", this.api)
this.gin.Any("/web/:param1/:param2", this.web)
this.gin.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler))

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_hefeng_weather))
this.RegisterComp(new(tool_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 模块,
// 在这里复制一份写路径的校验/幂等/提醒重算必然漂移。

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
}

36
apps/services/modules/user/api_getchannelapp.go

@ -19,7 +19,7 @@ import (
// 升级判定一律由服务端算:版本号比较要逐段按整数比("1.10.0" > "1.9.0",按字符串比会判反),
// 各客户端各写一遍必然有实现不一致,故统一收敛到 comm.CompareVersion / NeedForceUpdate。
func (this *apiComp) GetChannelApp(session comm.IUserSession, req *pb.UserGetChannelAppReq) (resp *pb.UserGetChannelAppResp, errdata *pb.ErrorData) {
cfg, err := this.module.model.getChannelAppCfg(req.Channel)
rows, err := this.module.model.loadChannelApps()
if err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
@ -35,29 +35,47 @@ func (this *apiComp) GetChannelApp(session comm.IUserSession, req *pb.UserGetCha
}
return
}
resp = &pb.UserGetChannelAppResp{App: toPbChannelApp(req.Channel, cfg, rel, req.Version)}
resp.Needupdate, resp.Forceupdate, resp.Reviewing = evalChannelUpgrade(rel, req.Version)
cfg := pickChannelAppCfg(rows, req.Channel)
app := toPbChannelApp(req.Channel, cfg, rel, req.Version, officialChannelAddress(rows))
resp = &pb.UserGetChannelAppResp{App: app}
address := ""
if app != nil {
address = app.Address
}
resp.Needupdate, resp.Forceupdate, resp.Reviewing = evalChannelUpgrade(rel, cfg, req.Channel, req.Version, address)
return
}
// evalChannelUpgrade 由应用发版配置与客户端版本算出三个升级判定。
// evalChannelUpgrade 由应用发版配置、本渠道行、客户端版本算出三个升级判定(2026-09-23 口径)。
//
// 判定只看 [comm.AppRelease](按应用、不分渠道),与渠道行无关 —— 版本号本来就是整个
// 应用一套的,早先按渠道各存一份导致同一个开关有 8 处能改、客户端只读一处。
// - 版本号按平台取(iOS 渠道读 ios_version / ios_min_version),见 comm.PlatformOfChannel。
// - 推荐更新 = 客户端 < 该平台线上版本;强制更新 = 开关开 且 客户端 < 该平台最低版本
// (最低版本留空 = 线上版本);强更必然意味着有更新。
// - 渠道行状态不是「已上架」(审核中 / 已下架)时**不推送推荐更新**:商店里还是旧包,提示了
// 用户也下不到新版。强更不受这条限制——它的地址已经由回退链换成官方包。
// - address 是最终算出的下载地址(toPbChannelApp 的 Address):为空说明没有任何可去的地方,
// 两种更新都不提示。强更也不例外:一个点了没反应又关不掉的弹窗等于把用户锁死。
//
// 未配置或客户端没上报版本时全部为 false —— 客户端照旧走「无更新」分支,
// 绝不能因为拿不到版本就判成强更,那会把所有老客户端挡在门外。
func evalChannelUpgrade(rel *comm.AppRelease, clientVer string) (need, force, reviewing bool) {
func evalChannelUpgrade(rel *comm.AppRelease, ch *comm.ChannelApp, channel int32, clientVer, address string) (need, force, reviewing bool) {
if rel == nil || strings.TrimSpace(clientVer) == "" {
return false, false, false
}
need = rel.NeedUpdate(clientVer)
force = rel.NeedForceUpdate(clientVer)
platform := comm.PlatformOfChannel(channel)
need = rel.NeedUpdateOn(clientVer, platform)
force = rel.NeedForceUpdateOn(clientVer, platform)
// 强更必然意味着有更新可用:即便运营忘了把线上版本填得比最低支持版本高,也不该出现
// 「必须更新但没有更新」这种客户端无法处理的组合。
if force {
need = true
}
if ch != nil && ch.Status != comm.ChannelStatusOnline && !force {
need = false
}
if strings.TrimSpace(address) == "" {
need, force = false, false
}
reviewing = rel.IsReviewing(clientVer)
return
}

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

@ -1,14 +1,21 @@
package user
import (
"fmt"
"io"
"path"
"strings"
"yunyan/comm"
"yunyan/lego/sys/mysql"
"yunyan/lego/sys/wordfilter"
"yunyan/pb"
"yunyan/sys/tencentyun/cos"
"yunyan/utils"
"yunyan/sys/aliyun/oss"
ossSDK "github.com/aliyun/aliyun-oss-go-sdk/oss"
"os"
"path/filepath"
"yunyan/utils"
)
// @Summary 用户设置接口
@ -72,7 +79,7 @@ func (this *apiComp) Setting(session comm.IUserSession, req *pb.UserSettingReq)
// 获取文件扩展名
ext := filepath.Ext(filename)
// 上传 - 使用用户ID+扩展名作为文件名避免冲突
publicURL, err = cos.Put("/userAvatar/"+session.GetUserId()+ext, file)
publicURL, err = putAvatar(session.GetUserId(), ext, file)
if err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_StorageError,
@ -98,3 +105,37 @@ func (this *apiComp) Setting(session comm.IUserSession, req *pb.UserSettingReq)
}
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
}

46
apps/services/modules/user/model_user.go

@ -459,24 +459,27 @@ func (this *modelUserComp) loadAppRelease() (*comm.AppRelease, error) {
// 「这一版客户端到底该不该显示游客登录入口」——下发的 Tourists 是结论而非配置,
// 客户端直接照着显隐即可,不需要也不应该再拿 Touristsversion 自己比一遍。
// Touristsversion 仍原样下发,仅供排查「为什么这版看不到入口」。
func toPbChannelApp(channel int32, c *comm.ChannelApp, rel *comm.AppRelease, clientVer string) *pb.DBChannelApp {
//
// officialAddr 是官方渠道(100) 生效的下载地址,Android 渠道没填自己的地址时先回退到它,
// 再回退应用默认地址;iOS / Google 不参与回退。完整回退链见 comm.AppRelease.ResolveDownloadAddress。
//
// Version / Minversion 按渠道所属平台下发(iOS 渠道给 iOS 那套),客户端拿到的就是「自己这个平台」的线上版本。
func toPbChannelApp(channel int32, c *comm.ChannelApp, rel *comm.AppRelease, clientVer string, officialAddr string) *pb.DBChannelApp {
if c == nil && rel == nil {
return nil
}
out := &pb.DBChannelApp{Channel: channel}
channelAddress := ""
if c != nil {
out.Description = c.Description
out.Allowskipdevicebinding = c.AllowSkipDeviceBinding
out.Paychannels = c.PayChannels
out.Status = c.Status
channelAddress = c.Address
}
// 渠道填了本渠道商店链接就用它,否则回退应用默认地址。
out.Address = rel.DownloadAddress(channelAddress)
out.Address = rel.ResolveDownloadAddress(channel, c, officialAddr)
if rel != nil {
out.Version = rel.Version
out.Minversion = rel.MinVersion
platform := comm.PlatformOfChannel(channel)
out.Version = rel.VersionFor(platform)
out.Minversion = rel.MinVersionFor(platform)
out.Forceupdate = rel.ForceUpdate
out.Changelog = rel.Changelog
out.Reviewversion = rel.ReviewVersion
@ -493,15 +496,29 @@ func (this *modelUserComp) getChannelAppCfg(channel int32) (*comm.ChannelApp, er
if err != nil {
return nil, err
}
return pickChannelAppCfg(rows, channel), nil
}
// pickChannelAppCfg 从已加载的渠道行里挑某渠道生效的一条(应用行覆盖全局行);未配置、停用或草稿返回 nil。
func pickChannelAppCfg(rows []*comm.ChannelApp, channel int32) *comm.ChannelApp {
picked := comm.PickChannelApp(rows, channel)
if picked == nil || !picked.Enable || picked.Status == comm.ChannelStatusDraft {
return nil, nil
return nil
}
return picked, nil
return picked
}
// officialChannelAddress 官方渠道(100) 生效的下载地址:Android 渠道没填自己地址时的第一级回退。
// 官方渠道放的是我们自己的包(网页下载页或 apk 直链),不看上架状态——它不经过任何商店审核。
func officialChannelAddress(rows []*comm.ChannelApp) string {
if o := pickChannelAppCfg(rows, comm.ChannelOfficial); o != nil {
return o.Address
}
return ""
}
func (this *modelUserComp) getChannelApp(channel int32, clientVer string) (model *pb.DBChannelApp, err error) {
cfg, err := this.getChannelAppCfg(channel)
rows, err := this.loadChannelApps()
if err != nil {
return nil, err
}
@ -509,7 +526,7 @@ func (this *modelUserComp) getChannelApp(channel int32, clientVer string) (model
if err != nil {
return nil, err
}
return toPbChannelApp(channel, cfg, rel, clientVer), nil
return toPbChannelApp(channel, pickChannelAppCfg(rows, channel), rel, clientVer, officialChannelAddress(rows)), nil
}
// getChannelApps 下发全部渠道:同一渠道应用行覆盖全局行;草稿状态不下发。
@ -526,17 +543,18 @@ func (this *modelUserComp) getChannelApps(clientVer string) (models []*pb.DBChan
return nil, err
}
seen := map[int32]bool{}
officialAddr := officialChannelAddress(rows)
models = make([]*pb.DBChannelApp, 0, len(rows))
for _, r := range rows {
if seen[r.Channel] {
continue
}
seen[r.Channel] = true
picked := comm.PickChannelApp(rows, r.Channel)
if picked == nil || !picked.Enable || picked.Status == comm.ChannelStatusDraft {
picked := pickChannelAppCfg(rows, r.Channel)
if picked == nil {
continue
}
models = append(models, toPbChannelApp(picked.Channel, picked, rel, clientVer))
models = append(models, toPbChannelApp(picked.Channel, picked, rel, clientVer, officialAddr))
}
return models, nil
}

78
apps/services/modules/user/module_test.go

@ -425,7 +425,7 @@ func TestEvalChannelUpgrade(t *testing.T) {
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
need, force, reviewing := evalChannelUpgrade(c.cfg, c.client)
need, force, reviewing := evalChannelUpgrade(c.cfg, nil, comm.ChannelChinaXiaomi, c.client, "https://dl/app.apk")
if need != c.need || force != c.force || reviewing != c.reviewing {
t.Fatalf("client=%q 得到 need=%v force=%v reviewing=%v,期望 need=%v force=%v reviewing=%v",
c.client, need, force, reviewing, c.need, c.force, c.reviewing)
@ -437,7 +437,7 @@ func TestEvalChannelUpgrade(t *testing.T) {
// 运营把最低支持版本填得比线上版本还高时,不能出现「必须更新但没有更新可用」这种客户端无解的组合。
func TestEvalChannelUpgradeForceImpliesNeed(t *testing.T) {
cfg := &comm.AppRelease{Version: "1.0.0", MinVersion: "2.0.0", ForceUpdate: true}
need, force, _ := evalChannelUpgrade(cfg, "1.5.0")
need, force, _ := evalChannelUpgrade(cfg, nil, comm.ChannelChinaXiaomi, "1.5.0", "https://dl/app.apk")
if !force {
t.Fatal("低于最低支持版本应强更")
}
@ -448,11 +448,42 @@ func TestEvalChannelUpgradeForceImpliesNeed(t *testing.T) {
// 后台还没配发版信息时 model 层给的是 nil,这里确认 nil 不会 panic 也不会误判。
func TestEvalChannelUpgradeNilSafe(t *testing.T) {
if need, force, reviewing := evalChannelUpgrade(nil, "1.0.0"); need || force || reviewing {
if need, force, reviewing := evalChannelUpgrade(nil, nil, comm.ChannelChinaXiaomi, "1.0.0", "https://dl/app.apk"); need || force || reviewing {
t.Fatal("nil 配置必须全 false")
}
}
// 2026-09-23 口径:版本号按平台分、渠道未上架不推、没地址不提示。每条都是线上会直接踩到的组合。
func TestEvalChannelUpgradePlatformStatusAddress(t *testing.T) {
rel := &comm.AppRelease{Version: "1.0.3", IosVersion: "1.0.2", ForceUpdate: true}
apk := "https://dl/app.apk"
// iOS 渠道读 ios_version:Android 已发 1.0.3、iOS 商店还是 1.0.2 时,iOS 的 1.0.2 用户不该被提示
if need, _, _ := evalChannelUpgrade(rel, nil, comm.ChannelGlobalApple, "1.0.2", "https://apps.apple.com/x"); need {
t.Fatal("iOS 客户端已是 iOS 线上版本,不该提示更新(不能拿 Android 的版本号判)")
}
if need, force, _ := evalChannelUpgrade(rel, nil, comm.ChannelChinaXiaomi, "1.0.2", apk); !need || !force {
t.Fatal("Android 1.0.2 低于 Android 线上 1.0.3,且最低版本留空 = 线上版本,应强更")
}
// 最低版本留空 = 线上版本:填了最低版本后,介于两者之间的只推荐不强制
withMin := &comm.AppRelease{Version: "1.0.3", MinVersion: "1.0.1", ForceUpdate: true}
if need, force, _ := evalChannelUpgrade(withMin, nil, comm.ChannelChinaXiaomi, "1.0.2", apk); !need || force {
t.Fatal("高于最低版本、低于线上版本:只推荐不强制")
}
// 渠道审核中:商店里还是旧包,推荐更新不推;强更不受影响
reviewing := &comm.ChannelApp{Channel: comm.ChannelChinaXiaomi, Status: comm.ChannelStatusReviewing, Address: "https://xiaomi/store"}
if need, _, _ := evalChannelUpgrade(withMin, reviewing, comm.ChannelChinaXiaomi, "1.0.2", apk); need {
t.Fatal("渠道审核中时不该推送推荐更新")
}
if need, force, _ := evalChannelUpgrade(withMin, reviewing, comm.ChannelChinaXiaomi, "1.0.0", apk); !need || !force {
t.Fatal("渠道审核中也挡不住强更(地址已由回退链换成官方包)")
}
// 没有任何可去的地址:两种更新都不提示,强更也不例外
if need, force, _ := evalChannelUpgrade(rel, nil, comm.ChannelChinaXiaomi, "1.0.0", ""); need || force {
t.Fatal("没有下载地址时不该提示任何更新,否则弹出一个点了没反应又关不掉的框")
}
}
// ---------------------------------------------------------------------------
// ⑤ 老客户端兼容字段
// ---------------------------------------------------------------------------
@ -838,7 +869,7 @@ func TestToPbChannelAppSurvivesMissingChannelRow(t *testing.T) {
Address: "https://default/app.apk",
}
got := toPbChannelApp(comm.ChannelChinaHonor, nil, rel, "1.0.1")
got := toPbChannelApp(comm.ChannelChinaHonor, nil, rel, "1.0.1", "")
if got == nil {
t.Fatal("渠道行缺失但发版配置存在时必须照常下发,否则应用级开关会被渠道行卡死")
}
@ -863,7 +894,7 @@ func TestToPbChannelAppSurvivesMissingRelease(t *testing.T) {
Channel: comm.ChannelChinaXiaomi, Address: "https://xiaomi/store",
PayChannels: "wxpay", AllowSkipDeviceBinding: true, Status: comm.ChannelStatusOnline,
}
got := toPbChannelApp(comm.ChannelChinaXiaomi, ch, nil, "1.0.1")
got := toPbChannelApp(comm.ChannelChinaXiaomi, ch, nil, "1.0.1", "")
if got == nil {
t.Fatal("发版配置未配时仍应下发渠道信息")
}
@ -881,15 +912,44 @@ func TestToPbChannelAppSurvivesMissingRelease(t *testing.T) {
// 渠道地址优先于应用默认地址:各商店链接不同,一套地址会把用户引到别家商店。
func TestToPbChannelAppAddressPrecedence(t *testing.T) {
rel := &comm.AppRelease{Address: "https://default/app.apk"}
ch := &comm.ChannelApp{Channel: comm.ChannelChinaHuawei, Address: "https://huawei/store"}
if got := toPbChannelApp(comm.ChannelChinaHuawei, ch, rel, "1.0.1"); got.Address != "https://huawei/store" {
t.Fatalf("渠道地址应覆盖应用默认地址,实际 %q", got.Address)
ch := &comm.ChannelApp{Channel: comm.ChannelChinaHuawei, Address: "https://huawei/store", Status: comm.ChannelStatusOnline}
if got := toPbChannelApp(comm.ChannelChinaHuawei, ch, rel, "1.0.1", "https://official/app.apk"); got.Address != "https://huawei/store" {
t.Fatalf("渠道地址应覆盖官方渠道与应用默认地址,实际 %q", got.Address)
}
}
// 回退链(2026-09-23):Android 渠道 → 官方渠道(100) → 默认地址;未上架的渠道地址视同没有;iOS 只认自己的商店链接。
func TestToPbChannelAppAddressFallbackChain(t *testing.T) {
rel := &comm.AppRelease{Address: "https://default/app.apk", Version: "1.0.3", IosVersion: "1.0.2", MinVersion: "1.0.1"}
// 渠道没填地址 → 官方渠道
if got := toPbChannelApp(comm.ChannelChinaXiaomi, nil, rel, "1.0.1", "https://official/app.apk"); got.Address != "https://official/app.apk" {
t.Fatalf("渠道缺失时应先回退官方渠道地址,实际 %q", got.Address)
}
// 官方渠道也没有 → 默认地址
if got := toPbChannelApp(comm.ChannelChinaXiaomi, nil, rel, "1.0.1", ""); got.Address != "https://default/app.apk" {
t.Fatalf("官方渠道也没有时应回退默认地址,实际 %q", got.Address)
}
// 渠道审核中:它的商店链接视同没有
reviewing := &comm.ChannelApp{Channel: comm.ChannelChinaXiaomi, Address: "https://xiaomi/store", Status: comm.ChannelStatusReviewing}
if got := toPbChannelApp(comm.ChannelChinaXiaomi, reviewing, rel, "1.0.1", "https://official/app.apk"); got.Address != "https://official/app.apk" {
t.Fatalf("渠道未上架时不该用它的地址,实际 %q", got.Address)
}
// iOS:没填 App Store 链接就是空,绝不能回退到 apk
if got := toPbChannelApp(comm.ChannelGlobalApple, nil, rel, "1.0.1", "https://official/app.apk"); got.Address != "" {
t.Fatalf("iOS 没有商店链接时地址必须为空,实际 %q", got.Address)
}
// iOS 的版本号来自 ios_version / ios_min_version(留空 = 线上版本)
if got := toPbChannelApp(comm.ChannelGlobalApple, nil, rel, "1.0.1", ""); got.Version != "1.0.2" || got.Minversion != "1.0.2" {
t.Fatalf("iOS 渠道应下发 iOS 那套版本号,实际 version=%q min=%q", got.Version, got.Minversion)
}
if got := toPbChannelApp(comm.ChannelChinaXiaomi, nil, rel, "1.0.1", ""); got.Version != "1.0.3" || got.Minversion != "1.0.1" {
t.Fatalf("Android 渠道应下发 Android 那套版本号,实际 version=%q min=%q", got.Version, got.Minversion)
}
}
// 两张表都没配才返回 nil。
func TestToPbChannelAppBothNil(t *testing.T) {
if got := toPbChannelApp(comm.ChannelChinaXiaomi, nil, nil, "1.0.1"); got != nil {
if got := toPbChannelApp(comm.ChannelChinaXiaomi, nil, nil, "1.0.1", ""); got != nil {
t.Fatalf("两边都没配时应返回 nil,实际 %+v", got)
}
}

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

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

370
apps/services/pb/echomeet_msg.pb.go

@ -2133,6 +2133,296 @@ func (*EchomeetAliCallbackResp) Descriptor() ([]byte, []int) {
return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{40}
}
// ============================== 会议纪要分享 ==============================
//
// 分享链接形如 https://share.ymaikj.com/share/{serverHost}/{brand}/{id}/{ticket}
//
// ⚠️ 2026-09-23 之前客户端把**用户的登录 JWT**直接拼进这个 URL,谁拿到链接就拿到了
//
// 整个账号。现在改成服务端签发的分享票据:只含一条记录、只读、有有效期。
type EchomeetCreateShareLinkReq struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id"` //@go_tags(`json:"id"`) 会议记录id
Expiredays int32 `protobuf:"varint,2,opt,name=expiredays,proto3" json:"expiredays"` //@go_tags(`json:"expiredays"`) 有效天数,<=0 用默认(7),上限 30
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *EchomeetCreateShareLinkReq) Reset() {
*x = EchomeetCreateShareLinkReq{}
mi := &file_echomeet_echomeet_msg_proto_msgTypes[41]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *EchomeetCreateShareLinkReq) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*EchomeetCreateShareLinkReq) ProtoMessage() {}
func (x *EchomeetCreateShareLinkReq) ProtoReflect() protoreflect.Message {
mi := &file_echomeet_echomeet_msg_proto_msgTypes[41]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use EchomeetCreateShareLinkReq.ProtoReflect.Descriptor instead.
func (*EchomeetCreateShareLinkReq) Descriptor() ([]byte, []int) {
return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{41}
}
func (x *EchomeetCreateShareLinkReq) GetId() uint64 {
if x != nil {
return x.Id
}
return 0
}
func (x *EchomeetCreateShareLinkReq) GetExpiredays() int32 {
if x != nil {
return x.Expiredays
}
return 0
}
type EchomeetCreateShareLinkResp struct {
state protoimpl.MessageState `protogen:"open.v1"`
Ticket string `protobuf:"bytes,1,opt,name=ticket,proto3" json:"ticket"` //@go_tags(`json:"ticket"`) 分享票据,拼进落地页 URL 的最后一段
Expiretime int64 `protobuf:"varint,2,opt,name=expiretime,proto3" json:"expiretime"` //@go_tags(`json:"expiretime"`) 到期时间 unix 秒
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *EchomeetCreateShareLinkResp) Reset() {
*x = EchomeetCreateShareLinkResp{}
mi := &file_echomeet_echomeet_msg_proto_msgTypes[42]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *EchomeetCreateShareLinkResp) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*EchomeetCreateShareLinkResp) ProtoMessage() {}
func (x *EchomeetCreateShareLinkResp) ProtoReflect() protoreflect.Message {
mi := &file_echomeet_echomeet_msg_proto_msgTypes[42]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use EchomeetCreateShareLinkResp.ProtoReflect.Descriptor instead.
func (*EchomeetCreateShareLinkResp) Descriptor() ([]byte, []int) {
return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{42}
}
func (x *EchomeetCreateShareLinkResp) GetTicket() string {
if x != nil {
return x.Ticket
}
return ""
}
func (x *EchomeetCreateShareLinkResp) GetExpiretime() int64 {
if x != nil {
return x.Expiretime
}
return 0
}
// 落地页按票据取只读内容。**免登录**,须在 gateway 的 WhiteList 里。
type EchomeetGetSharedRecordReq struct {
state protoimpl.MessageState `protogen:"open.v1"`
Ticket string `protobuf:"bytes,1,opt,name=ticket,proto3" json:"ticket"` //@go_tags(`json:"ticket"`) 分享票据
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *EchomeetGetSharedRecordReq) Reset() {
*x = EchomeetGetSharedRecordReq{}
mi := &file_echomeet_echomeet_msg_proto_msgTypes[43]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *EchomeetGetSharedRecordReq) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*EchomeetGetSharedRecordReq) ProtoMessage() {}
func (x *EchomeetGetSharedRecordReq) ProtoReflect() protoreflect.Message {
mi := &file_echomeet_echomeet_msg_proto_msgTypes[43]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use EchomeetGetSharedRecordReq.ProtoReflect.Descriptor instead.
func (*EchomeetGetSharedRecordReq) Descriptor() ([]byte, []int) {
return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{43}
}
func (x *EchomeetGetSharedRecordReq) GetTicket() string {
if x != nil {
return x.Ticket
}
return ""
}
// 只回落地页要展示的字段。刻意不含 uid / filepath / taskid / logid / *_svc_id:
// 这是一个任何人凭链接就能打开的接口,多回一个字段就是多泄一份内部信息。
type EchomeetGetSharedRecordResp struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id"` //@go_tags(`json:"id"`) 会议记录id
Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title"` //@go_tags(`json:"title"`) 标题
Creationtime int64 `protobuf:"varint,3,opt,name=creationtime,proto3" json:"creationtime"` //@go_tags(`json:"creationtime"`) 创建时间 unix 秒
Seconds int32 `protobuf:"varint,4,opt,name=seconds,proto3" json:"seconds"` //@go_tags(`json:"seconds"`) 时长 秒
Audiourl string `protobuf:"bytes,5,opt,name=audiourl,proto3" json:"audiourl"` //@go_tags(`json:"audiourl"`) 音频url
Original string `protobuf:"bytes,6,opt,name=original,proto3" json:"original"` //@go_tags(`json:"original"`) 听写内容
Translate string `protobuf:"bytes,7,opt,name=translate,proto3" json:"translate"` //@go_tags(`json:"translate"`) 翻译
Overview string `protobuf:"bytes,8,opt,name=overview,proto3" json:"overview"` //@go_tags(`json:"overview"`) 短总结
Summary string `protobuf:"bytes,9,opt,name=summary,proto3" json:"summary"` //@go_tags(`json:"summary"`) 总结
Formlanguage string `protobuf:"bytes,10,opt,name=formlanguage,proto3" json:"formlanguage"` //@go_tags(`json:"formlanguage"`) 源语言
Tolanguage string `protobuf:"bytes,11,opt,name=tolanguage,proto3" json:"tolanguage"` //@go_tags(`json:"tolanguage"`) 目标语言
Expiretime int64 `protobuf:"varint,12,opt,name=expiretime,proto3" json:"expiretime"` //@go_tags(`json:"expiretime"`) 本链接到期时间 unix 秒
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *EchomeetGetSharedRecordResp) Reset() {
*x = EchomeetGetSharedRecordResp{}
mi := &file_echomeet_echomeet_msg_proto_msgTypes[44]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *EchomeetGetSharedRecordResp) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*EchomeetGetSharedRecordResp) ProtoMessage() {}
func (x *EchomeetGetSharedRecordResp) ProtoReflect() protoreflect.Message {
mi := &file_echomeet_echomeet_msg_proto_msgTypes[44]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use EchomeetGetSharedRecordResp.ProtoReflect.Descriptor instead.
func (*EchomeetGetSharedRecordResp) Descriptor() ([]byte, []int) {
return file_echomeet_echomeet_msg_proto_rawDescGZIP(), []int{44}
}
func (x *EchomeetGetSharedRecordResp) GetId() uint64 {
if x != nil {
return x.Id
}
return 0
}
func (x *EchomeetGetSharedRecordResp) GetTitle() string {
if x != nil {
return x.Title
}
return ""
}
func (x *EchomeetGetSharedRecordResp) GetCreationtime() int64 {
if x != nil {
return x.Creationtime
}
return 0
}
func (x *EchomeetGetSharedRecordResp) GetSeconds() int32 {
if x != nil {
return x.Seconds
}
return 0
}
func (x *EchomeetGetSharedRecordResp) GetAudiourl() string {
if x != nil {
return x.Audiourl
}
return ""
}
func (x *EchomeetGetSharedRecordResp) GetOriginal() string {
if x != nil {
return x.Original
}
return ""
}
func (x *EchomeetGetSharedRecordResp) GetTranslate() string {
if x != nil {
return x.Translate
}
return ""
}
func (x *EchomeetGetSharedRecordResp) GetOverview() string {
if x != nil {
return x.Overview
}
return ""
}
func (x *EchomeetGetSharedRecordResp) GetSummary() string {
if x != nil {
return x.Summary
}
return ""
}
func (x *EchomeetGetSharedRecordResp) GetFormlanguage() string {
if x != nil {
return x.Formlanguage
}
return ""
}
func (x *EchomeetGetSharedRecordResp) GetTolanguage() string {
if x != nil {
return x.Tolanguage
}
return ""
}
func (x *EchomeetGetSharedRecordResp) GetExpiretime() int64 {
if x != nil {
return x.Expiretime
}
return 0
}
var File_echomeet_echomeet_msg_proto protoreflect.FileDescriptor
const file_echomeet_echomeet_msg_proto_rawDesc = "" +
@ -2270,7 +2560,37 @@ const file_echomeet_echomeet_msg_proto_rawDesc = "" +
"\aEndTime\x18\x03 \x01(\x03R\aEndTime\x12\x1c\n" +
"\tSpeakerId\x18\x04 \x01(\tR\tSpeakerId\x12\x1c\n" +
"\tChannelId\x18\x05 \x01(\x05R\tChannelId\"\x19\n" +
"\x17EchomeetAliCallbackRespB\x06Z\x04.;pbb\x06proto3"
"\x17EchomeetAliCallbackResp\"L\n" +
"\x1aEchomeetCreateShareLinkReq\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x04R\x02id\x12\x1e\n" +
"\n" +
"expiredays\x18\x02 \x01(\x05R\n" +
"expiredays\"U\n" +
"\x1bEchomeetCreateShareLinkResp\x12\x16\n" +
"\x06ticket\x18\x01 \x01(\tR\x06ticket\x12\x1e\n" +
"\n" +
"expiretime\x18\x02 \x01(\x03R\n" +
"expiretime\"4\n" +
"\x1aEchomeetGetSharedRecordReq\x12\x16\n" +
"\x06ticket\x18\x01 \x01(\tR\x06ticket\"\xf1\x02\n" +
"\x1bEchomeetGetSharedRecordResp\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x04R\x02id\x12\x14\n" +
"\x05title\x18\x02 \x01(\tR\x05title\x12\"\n" +
"\fcreationtime\x18\x03 \x01(\x03R\fcreationtime\x12\x18\n" +
"\aseconds\x18\x04 \x01(\x05R\aseconds\x12\x1a\n" +
"\baudiourl\x18\x05 \x01(\tR\baudiourl\x12\x1a\n" +
"\boriginal\x18\x06 \x01(\tR\boriginal\x12\x1c\n" +
"\ttranslate\x18\a \x01(\tR\ttranslate\x12\x1a\n" +
"\boverview\x18\b \x01(\tR\boverview\x12\x18\n" +
"\asummary\x18\t \x01(\tR\asummary\x12\"\n" +
"\fformlanguage\x18\n" +
" \x01(\tR\fformlanguage\x12\x1e\n" +
"\n" +
"tolanguage\x18\v \x01(\tR\n" +
"tolanguage\x12\x1e\n" +
"\n" +
"expiretime\x18\f \x01(\x03R\n" +
"expiretimeB\x06Z\x04.;pbb\x06proto3"
var (
file_echomeet_echomeet_msg_proto_rawDescOnce sync.Once
@ -2284,7 +2604,7 @@ func file_echomeet_echomeet_msg_proto_rawDescGZIP() []byte {
return file_echomeet_echomeet_msg_proto_rawDescData
}
var file_echomeet_echomeet_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 41)
var file_echomeet_echomeet_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 45)
var file_echomeet_echomeet_msg_proto_goTypes = []any{
(*EchomeetGetTemplatesReq)(nil), // 0: EchomeetGetTemplatesReq
(*EchomeetGetTemplatesResp)(nil), // 1: EchomeetGetTemplatesResp
@ -2327,30 +2647,34 @@ var file_echomeet_echomeet_msg_proto_goTypes = []any{
(*EchomeetAliCallbackResult)(nil), // 38: EchomeetAliCallbackResult
(*EchomeetAliSentence)(nil), // 39: EchomeetAliSentence
(*EchomeetAliCallbackResp)(nil), // 40: EchomeetAliCallbackResp
(*DBEchoMeetTemplate)(nil), // 41: DBEchoMeetTemplate
(*DBEchoMeetRecord)(nil), // 42: DBEchoMeetRecord
(*ContextStruct)(nil), // 43: ContextStruct
(*RecognitionResult)(nil), // 44: RecognitionResult
(*AudioInfo)(nil), // 45: AudioInfo
(*EchomeetCreateShareLinkReq)(nil), // 41: EchomeetCreateShareLinkReq
(*EchomeetCreateShareLinkResp)(nil), // 42: EchomeetCreateShareLinkResp
(*EchomeetGetSharedRecordReq)(nil), // 43: EchomeetGetSharedRecordReq
(*EchomeetGetSharedRecordResp)(nil), // 44: EchomeetGetSharedRecordResp
(*DBEchoMeetTemplate)(nil), // 45: DBEchoMeetTemplate
(*DBEchoMeetRecord)(nil), // 46: DBEchoMeetRecord
(*ContextStruct)(nil), // 47: ContextStruct
(*RecognitionResult)(nil), // 48: RecognitionResult
(*AudioInfo)(nil), // 49: AudioInfo
}
var file_echomeet_echomeet_msg_proto_depIdxs = []int32{
41, // 0: EchomeetGetTemplatesResp.templates:type_name -> DBEchoMeetTemplate
41, // 1: EchomeetAddTemplateReq.template:type_name -> DBEchoMeetTemplate
41, // 2: EchomeetAddTemplateResp.template:type_name -> DBEchoMeetTemplate
41, // 3: EchomeetUpdateTemplateReq.template:type_name -> DBEchoMeetTemplate
41, // 4: EchomeetUpdateTemplateResp.template:type_name -> DBEchoMeetTemplate
42, // 5: EchomeetGetAllRecordsResp.records:type_name -> DBEchoMeetRecord
42, // 6: EchomeetGetRecordsResp.records:type_name -> DBEchoMeetRecord
42, // 7: EchomeetGetRecordResp.record:type_name -> DBEchoMeetRecord
42, // 8: EchomeetPutRecordsReq.records:type_name -> DBEchoMeetRecord
43, // 9: EchomeetAddRecordReq.original:type_name -> ContextStruct
42, // 10: EchomeetAddRecordResp.record:type_name -> DBEchoMeetRecord
42, // 11: EchomeetUpRecordResp.record:type_name -> DBEchoMeetRecord
44, // 12: EchomeetCallbackReq.result:type_name -> RecognitionResult
45, // 13: EchomeetCallbackReq.audio_info:type_name -> AudioInfo
45, // 0: EchomeetGetTemplatesResp.templates:type_name -> DBEchoMeetTemplate
45, // 1: EchomeetAddTemplateReq.template:type_name -> DBEchoMeetTemplate
45, // 2: EchomeetAddTemplateResp.template:type_name -> DBEchoMeetTemplate
45, // 3: EchomeetUpdateTemplateReq.template:type_name -> DBEchoMeetTemplate
45, // 4: EchomeetUpdateTemplateResp.template:type_name -> DBEchoMeetTemplate
46, // 5: EchomeetGetAllRecordsResp.records:type_name -> DBEchoMeetRecord
46, // 6: EchomeetGetRecordsResp.records:type_name -> DBEchoMeetRecord
46, // 7: EchomeetGetRecordResp.record:type_name -> DBEchoMeetRecord
46, // 8: EchomeetPutRecordsReq.records:type_name -> DBEchoMeetRecord
47, // 9: EchomeetAddRecordReq.original:type_name -> ContextStruct
46, // 10: EchomeetAddRecordResp.record:type_name -> DBEchoMeetRecord
46, // 11: EchomeetUpRecordResp.record:type_name -> DBEchoMeetRecord
48, // 12: EchomeetCallbackReq.result:type_name -> RecognitionResult
49, // 13: EchomeetCallbackReq.audio_info:type_name -> AudioInfo
27, // 14: EchomeetGetSummaryServicesResp.services:type_name -> EchomeetSummaryServiceItem
27, // 15: EchomeetGetCapabilitiesResp.summary_services:type_name -> EchomeetSummaryServiceItem
42, // 16: EchomeetModifyRecordsResp.record:type_name -> DBEchoMeetRecord
46, // 16: EchomeetModifyRecordsResp.record:type_name -> DBEchoMeetRecord
38, // 17: EchomeetAliCallbackReq.Result:type_name -> EchomeetAliCallbackResult
39, // 18: EchomeetAliCallbackResult.Sentences:type_name -> EchomeetAliSentence
19, // [19:19] is the sub-list for method output_type
@ -2372,7 +2696,7 @@ func file_echomeet_echomeet_msg_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_echomeet_echomeet_msg_proto_rawDesc), len(file_echomeet_echomeet_msg_proto_rawDesc)),
NumEnums: 0,
NumMessages: 41,
NumMessages: 45,
NumExtensions: 0,
NumServices: 0,
},

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

@ -9,7 +9,6 @@ import (
"yunyan/services"
"yunyan/sys/email"
"yunyan/sys/microsoft/translate"
"yunyan/sys/tencentyun/cos"
"flag"
"fmt"
"os"
@ -69,7 +68,7 @@ func (this *Service) InitSys() {
// 服务配置托管:库优先、无则读文件回写(seed)。应用标识取部署身份(comm.AppName/AppRegion,
// 由 .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")
if seeded, err := comm.LoadOrSeedServiceConfig(encKey, appName, region, "api", this.GetSettings().Sys); err != nil {
log.Warnf("init service config 托管失败(降级为纯文件配置): %s", err.Error())
@ -100,11 +99,6 @@ func (this *Service) InitSys() {
} else {
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 配好后可热重载生效。
// 不能 panic —— api 在 5-in-1 容器里一死,entrypoint 会连坐杀掉其余服务(测试服常不配发信凭据)。

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

@ -9,7 +9,6 @@ import (
"yunyan/modules/analyze"
"yunyan/modules/echomeet"
"yunyan/modules/memory"
musicmodule "yunyan/modules/music"
"yunyan/modules/pay"
"yunyan/modules/svccfg"
"yunyan/modules/user"
@ -23,7 +22,6 @@ import (
"yunyan/sys/email"
"yunyan/sys/google"
"yunyan/sys/ipinfo"
"yunyan/sys/musicobj"
"yunyan/sys/nats"
"yunyan/sys/pay/alipay"
"yunyan/sys/pay/appleiap"
@ -31,7 +29,6 @@ import (
"yunyan/sys/pay/paypal"
"yunyan/sys/pay/wechat"
"yunyan/sys/sms"
"yunyan/sys/tencentyun/cos"
"yunyan/sys/websearch/bochasearch"
"flag"
"fmt"
@ -79,7 +76,6 @@ func main() {
agents.NewModule(),
pay.NewModule(),
echomeet.NewModule(),
musicmodule.NewModule(),
allhelp.NewModule(),
memory.NewModule(), //记忆中心(拾忆):闹钟/待办/灵感/花销 + 周报月报
// bailianmodule.NewModule(),
@ -146,11 +142,6 @@ func (this *Service) InitSys() {
} else {
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 配好后可热重载生效。
// 不能 panic —— home 在 5-in-1 容器里一死,entrypoint 会连坐杀掉其余服务(测试服常不配短信凭据)。
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!")
}
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* 返回错误)。
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"
)
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。
// 传 "vi-VN" 会被 CreateTask 的语种校验直接拒掉。
@ -121,7 +121,7 @@ func TestCreateAndPollTask(t *testing.T) {
}
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",
true,
"",

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

@ -86,7 +86,7 @@ func TestCreateTask(t *testing.T) {
if err != nil {
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 {
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 {
orderid := sys.GenerateOrderNo("APP")
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 {
t.Fatalf("CreateAppOrder 失败: %v", err)
}

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

@ -8,7 +8,7 @@ import (
func TestNewSys(t *testing.T) {
sys, err := appleiap.NewSys(
appleiap.SetAppStoreBundleID("com.saitong.voitrans"),
appleiap.SetAppStoreBundleID("com.yimai.eaimar"),
appleiap.SetAppStoreIssuerID("96c4b3eb-5e5c-4719-8b07-db9cc4dc1f89"),
appleiap.SetAppStoreKeyID("VS538D3ZR5"),
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"
kid := "VS538D3ZR5"
// StoreKit 要求 JWT 载荷包含 bid(bundleId),与交易所属 App 一致
bundleID := "com.saitong.voitrans"
bundleID := "com.yimai.eaimar"
// 方式A:把 .p8 全文粘贴到下面的变量(包含完整头尾与换行)
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)
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 {
t.Errorf("VerifyProductPurchase() error = %v", err)
return

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save