Compare commits

...

9 Commits
main ... along

Author SHA1 Message Date
Rodger-Wang f2dfa09d97 client:EMAI 耳机与车载共用位置日期,定位只有市名时用高德补路名;Xcode 打开前把生成的 SPM 包对齐到 iOS 16.0,避免反复报平台版本不够。构建号 1.0.2+26 2 days ago
Rodger-Wang ed73e96376 fix(console):固件和产品图按产品绑定的应用分目录,不再写死 EAIMAR 7 days ago
Rodger-Wang 4a6d9c6cae client(iOS):加原生崩溃记录写入 Documents/logs/native_crash.log;BES FileUtils 改名 BesFileUtils 避免类名冲突;补运动权限说明;构建号 1.0.2+25 2 weeks ago
Rodger-Wang 4356e7e997 fix(client):iOS 扫描 EaiCar 优先用广播本地名,修固件改名后 iPhone 缓存旧名导致扫不到 2 weeks ago
Rodger-Wang 10779843e9 client:EaiCar 车载助手手机麦改半双工——AI 播报时静音上行,播完再开;被插话时先收尾上一轮,修字幕串行 2 weeks ago
Rodger-Wang c898acbf07 fix(client):语音纪要「总结」页的编辑按钮改为编辑总结正文,不再跳到命名发言者 2 weeks ago
Rodger-Wang 48c47657ef feat:用户反馈写入业务库,管理后台可按时间与用户查询 2 weeks ago
Rodger-Wang 229ed2254b fix(services):改昵称时加载内置敏感词库,拦住毒品类名称 2 weeks ago
Rodger-Wang 803bfbc9ee client:「选择设备」页暂时下架 AI手机支架;构建号 1.0.2+23 2 weeks ago
  1. 8
      apps/admin/app/composables/useApi.ts
  2. 298
      apps/admin/app/pages/feedback.vue
  3. 45
      apps/admin/app/pages/product.vue
  4. 1
      apps/admin/app/utils/menus.ts
  5. 19
      apps/client/ios/Podfile
  6. 24
      apps/client/ios/Runner.xcodeproj/project.pbxproj
  7. 2
      apps/client/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
  8. 119
      apps/client/ios/Runner/AppDelegate.swift
  9. 2
      apps/client/ios/Runner/Info.plist
  10. 118
      apps/client/ios/sync_spm_platform.sh
  11. 32
      apps/client/lib/data/models/device_category.dart
  12. 48
      apps/client/lib/data/services/bailian_multimodal_service.dart
  13. 20
      apps/client/lib/data/services/bailian_native_dialog.dart
  14. 43
      apps/client/lib/data/services/device_location_service.dart
  15. 2
      apps/client/lib/modules/eaicar/bindings/eaicar_chat_binding.dart
  16. 4
      apps/client/lib/modules/eaicar/module.dart
  17. 135
      apps/client/lib/modules/emai/controllers/emai_controller.dart
  18. 8
      apps/client/lib/modules/emai/module.dart
  19. 31
      apps/client/lib/modules/meeting/views/tabs/summary_tab.dart
  20. 5
      apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  21. 54
      apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/BailianDialogClient.kt
  22. 7
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift
  23. 57
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/BailianDialogClient.swift
  24. 2
      apps/client/local_plugins/bes_ota_manager/ios/Classes/bessdk/connect/BesBTConnector.m
  25. 2
      apps/client/local_plugins/bes_ota_manager/ios/Classes/bessdk/service/TotaDemo/BesTotaDemoService.m
  26. 2
      apps/client/local_plugins/bes_ota_manager/ios/Classes/bessdk/utils/FileUtils.h
  27. 4
      apps/client/local_plugins/bes_ota_manager/ios/Classes/bessdk/utils/FileUtils.m
  28. 15
      apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/SmartcarBleCentral.swift
  29. 4
      apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/SmartcarPeripheral.swift
  30. 48
      apps/client/local_plugins/location_service/android/build.gradle
  31. 100
      apps/client/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/PlaceLocator.kt
  32. 36
      apps/client/local_plugins/location_service/ios/location_service/Sources/location_service/PlaceLocator.swift
  33. 5
      apps/client/local_plugins/location_service/lib/location_service.dart
  34. 488
      apps/client/pubspec.lock
  35. 2
      apps/client/pubspec.yaml
  36. 24
      apps/client/test/bailian_native_parity_test.dart
  37. 33
      apps/client/test/device_location_date_test.dart
  38. 1
      apps/services/comm/const.go
  39. 7
      apps/services/comm/ossconf.go
  40. 25
      apps/services/lego/sys/wordfilter/embed.go
  41. 11
      apps/services/lego/sys/wordfilter/sys.go
  42. 25
      apps/services/lego/sys/wordfilter/sys_test.go
  43. 116
      apps/services/modules/console/api_feedback.go
  44. 28
      apps/services/modules/console/api_feedback_test.go
  45. 186
      apps/services/modules/console/api_upload.go
  46. 78
      apps/services/modules/console/api_upload_test.go
  47. 4
      apps/services/modules/console/server.go
  48. 35
      apps/services/modules/user/api_feedback.go
  49. 23
      apps/services/modules/user/api_feedback_test.go
  50. 2
      apps/services/modules/user/api_setting.go
  51. 20
      apps/services/modules/user/api_sgin.go
  52. 92
      apps/services/modules/user/model_feedback.go
  53. 24
      apps/services/modules/user/module.go
  54. 3
      deploy/app/confs/home.yaml.example

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

@ -99,10 +99,10 @@ export function useApi() {
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 的话超管传包必然被后端拒成「必须先选择应用」。
// opts.appName:安装包是页面上选中的应用;固件/产品图是产品绑定的应用名。
// 安装包这条:X-App-Id 只有绑定了应用的运营/代理账号才有,超管页面上选的应用
// 不会进请求头,不带 app_name 就会被拒成「必须先选择应用」。
// 固件/产品图的目录由服务端按产品 appnames 决定,不看登录态的 X-App-Id。
const token = await webApi<{
upload_url?: string
url?: string

298
apps/admin/app/pages/feedback.vue

@ -0,0 +1,298 @@
<template>
<div class="space-y-5">
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-4">
<div class="flex items-end gap-3 flex-wrap">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">应用 *</span></label>
<select v-model="qApp" class="select select-bordered select-sm" style="min-width:180px;" @change="onAppChange">
<option v-if="!appNames.length" value="">无可用应用</option>
<option v-for="n in appNames" :key="n" :value="n">{{ n }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">区域 *</span></label>
<select v-model="qRegion" class="select select-bordered select-sm" style="min-width:130px;" @change="reload">
<option v-if="!regionItems.length" value="">无可用区域</option>
<option v-for="a in regionItems" :key="a.id" :value="a.id">{{ regionLabel(a.region) }}</option>
</select>
</div>
<div class="form-control flex-1" style="min-width:220px;">
<label class="label py-1"><span class="label-text text-xs text-slate-500">用户</span></label>
<input
v-model="q.user" type="text" class="input input-bordered input-sm w-full"
placeholder="UID(精确) / 手机号 / 邮箱 / 用户名 / 登录账号" @keydown.enter.prevent="reload" />
</div>
<div class="form-control flex-1" style="min-width:180px;">
<label class="label py-1"><span class="label-text text-xs text-slate-500">内容</span></label>
<input
v-model="q.content" type="text" class="input input-bordered input-sm w-full"
placeholder="反馈正文关键词" @keydown.enter.prevent="reload" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">提交起</span></label>
<input v-model="q.startDate" type="date" class="input input-bordered input-sm" @change="reload" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">提交止</span></label>
<input v-model="q.endDate" type="date" class="input input-bordered input-sm" @change="reload" />
</div>
<button class="btn btn-primary btn-sm text-white" @click="reload">查询</button>
<button class="btn btn-ghost btn-sm" @click="resetFilters">重置</button>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100">
<div class="flex items-center justify-between px-5 py-3 border-b border-slate-100">
<h3 class="text-sm font-semibold text-slate-700">
用户反馈<span class="text-xs text-slate-400 font-normal ml-2">按提交时间倒序,点一行看全文</span>
</h3>
<div class="flex items-center gap-2">
<span class="text-xs text-slate-500">共 {{ total }} 条</span>
<select v-model.number="q.pageSize" class="select select-bordered select-xs" @change="reload">
<option :value="20">20/页</option>
<option :value="50">50/页</option>
<option :value="100">100/页</option>
</select>
</div>
</div>
<div class="overflow-x-auto">
<table class="table table-zebra text-sm">
<thead>
<tr>
<th>时间</th><th>用户</th><th>内容</th><th>版本</th><th>机型</th><th>渠道</th>
</tr>
</thead>
<tbody>
<tr v-if="loading"><td colspan="6" class="text-center py-10"><span class="loading loading-spinner loading-sm"></span></td></tr>
<tr v-else-if="!qRegion"><td colspan="6" class="text-center text-slate-400 py-10">请先选择应用与区域</td></tr>
<tr v-else-if="listError"><td colspan="6" class="text-center text-red-400 py-10">{{ listError }}</td></tr>
<tr v-else-if="!rows.length"><td colspan="6" class="text-center text-slate-400 py-10">没有符合条件的反馈</td></tr>
<tr v-for="r in rows" v-else :key="r.id" class="hover cursor-pointer" @click="openDetail(r)">
<td class="text-xs text-slate-500 whitespace-nowrap">{{ fmtTs(r.createtime) }}</td>
<td class="text-xs">
<div class="font-medium text-slate-700">{{ r.name || '(未设置昵称)' }}</div>
<div class="text-slate-400 font-mono">{{ r.uid }}</div>
<div v-if="r.phone" class="text-slate-500">{{ r.phone }}</div>
<div v-if="r.mail" class="text-slate-500 truncate max-w-[180px]" :title="r.mail">{{ r.mail }}</div>
</td>
<td class="text-xs text-slate-700 max-w-[360px]">
<div class="line-clamp-2 whitespace-pre-wrap break-words">{{ r.message }}</div>
</td>
<td class="text-xs text-slate-500 whitespace-nowrap">{{ r.version || '-' }}</td>
<td class="text-xs text-slate-500 max-w-[140px] truncate" :title="r.phonemodel">{{ r.phonemodel || '-' }}</td>
<td class="text-xs text-slate-500 whitespace-nowrap">{{ channelName(r.channel) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between px-5 py-3 border-t border-slate-100">
<span class="text-xs text-slate-500">{{ pageInfo }}</span>
<div class="flex items-center gap-2">
<button class="btn btn-ghost btn-xs" :disabled="q.page <= 0 || loading" @click="changePage(-1)">上一页</button>
<span class="text-xs text-slate-500">第 {{ q.page + 1 }} / {{ totalPages }} 页</span>
<button class="btn btn-ghost btn-xs" :disabled="q.page + 1 >= totalPages || loading" @click="changePage(1)">下一页</button>
</div>
</div>
</div>
<dialog ref="detailModal" class="modal">
<div class="modal-box max-w-2xl">
<h3 class="font-semibold text-slate-800">反馈全文</h3>
<div v-if="current" class="mt-3 space-y-3">
<div class="text-xs text-slate-500 flex flex-wrap gap-x-4 gap-y-1">
<span>{{ fmtTs(current.createtime) }}</span>
<span>{{ current.name || '(未设置昵称)' }}</span>
<span class="font-mono">{{ current.uid }}</span>
<span v-if="current.phone">{{ current.phone }}</span>
<span v-if="current.mail">{{ current.mail }}</span>
<span v-if="current.account">账号 {{ current.account }}</span>
<span>版本 {{ current.version || '-' }}</span>
<span>{{ current.phonemodel || '-' }}</span>
<span>{{ channelName(current.channel) }}</span>
</div>
<div class="text-sm text-slate-800 whitespace-pre-wrap break-words bg-slate-50 rounded-lg p-4">{{ current.message }}</div>
</div>
<div class="modal-action">
<button class="btn btn-sm" @click="closeDetail">关闭</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useAuthStore } from '~/stores/auth'
import { regionLabel } from '~/utils/regions'
definePageMeta({ title: '用户反馈' })
interface AppItem {
id: number
name: string
app_name?: string
region: string
enabled?: boolean
}
interface FeedbackRow {
id: number
uid: string
message: string
version: string
phonemodel: string
channel: number
createtime: number
name: string
phone: string
mail: string
account: string
}
const CHANNELS: Record<number, string> = {
1: '华为', 2: '小米', 3: 'OPPO', 4: 'VIVO', 5: '荣耀',
100: '官方包', 101: 'App Store', 102: 'Google Play',
}
function channelName(v: number) {
if (!v) return '-'
return CHANNELS[v] ?? '渠道' + v
}
const { webApi, consoleApi } = useApi()
const auth = useAuthStore()
const apps = ref<AppItem[]>([])
const qApp = ref('')
const qRegion = ref<number | ''>('')
const rows = ref<FeedbackRow[]>([])
const total = ref(0)
const loading = ref(false)
const listError = ref('')
const q = reactive({ user: '', content: '', startDate: '', endDate: '', page: 0, pageSize: 20 })
const current = ref<FeedbackRow | null>(null)
const detailModal = ref<HTMLDialogElement | null>(null)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / q.pageSize)))
const pageInfo = computed(() => {
if (!total.value) return '-'
const from = q.page * q.pageSize + 1
return `第 ${from} - ${Math.min(from + rows.value.length - 1, total.value)} 条 / 共 ${total.value} 条`
})
const appNames = computed(() => {
const names: string[] = []
apps.value.forEach((a) => {
const n = a.app_name || a.name
if (n && !names.includes(n)) names.push(n)
})
return names
})
const regionItems = computed(() => apps.value.filter((a) => (a.app_name || a.name) === qApp.value))
function onAppChange() {
const items = regionItems.value
qRegion.value = items.length ? items[0].id : ''
reload()
}
function fmtTs(ts: number) {
if (!ts) return '-'
return new Date(ts * 1000).toLocaleString()
}
function dateToUnix(s: string, endOfDay: boolean): number {
if (!s) return 0
const d = new Date(s + 'T00:00:00')
if (isNaN(d.getTime())) return 0
return Math.floor(d.getTime() / 1000) + (endOfDay ? 86400 : 0)
}
async function loadList() {
if (!qApp.value || !qRegion.value) {
rows.value = []
total.value = 0
return
}
auth.setCurrentApp(Number(qRegion.value))
loading.value = true
listError.value = ''
try {
const d = await webApi<{ items: FeedbackRow[]; total: number }>('getuserfeedbacks', {
user: q.user.trim(),
content: q.content.trim(),
start: dateToUnix(q.startDate, false),
end: dateToUnix(q.endDate, true),
page: q.page,
page_size: q.pageSize,
})
rows.value = d?.items ?? []
total.value = d?.total ?? 0
} catch (e: any) {
listError.value = e?.message || '查询失败'
rows.value = []
total.value = 0
} finally {
loading.value = false
}
}
function reload() {
q.page = 0
loadList()
}
function changePage(d: number) {
const next = q.page + d
if (next < 0 || next >= totalPages.value) return
q.page = next
loadList()
}
function resetFilters() {
q.user = ''
q.content = ''
q.startDate = ''
q.endDate = ''
reload()
}
function openDetail(row: FeedbackRow) {
current.value = row
detailModal.value?.showModal()
}
function closeDetail() {
detailModal.value?.close()
}
function buildAppSelect() {
const names = appNames.value
if (!names.length) {
qApp.value = ''
qRegion.value = ''
return
}
qApp.value = names[0]
const items = regionItems.value
qRegion.value = items.length ? items[0].id : ''
}
async function loadApps() {
if (!auth.isAdmin) {
try {
const d = await webApi<{ apps: AppItem[] }>('getmyapps', {})
apps.value = d?.apps ?? []
} catch {
apps.value = (auth.appbinds || []).map((a) => ({
id: a.id, name: a.name, app_name: a.app_name, region: a.region, enabled: true,
}))
}
buildAppSelect()
loadList()
return
}
try {
const list = await consoleApi<AppItem[]>('apps/list', {})
apps.value = (list || []).filter((a) => a.enabled)
} catch {
apps.value = []
}
buildAppSelect()
loadList()
}
onMounted(loadApps)
</script>

45
apps/admin/app/pages/product.vue

@ -216,6 +216,9 @@
<input v-model="form.productimage" type="text" class="input input-bordered w-full input-sm mb-2" placeholder="图片URL(可手动输入或上传)" />
<div class="flex items-center gap-2">
<input type="file" accept="image/*" class="file-input file-input-bordered file-input-sm flex-1" @change="onImagePick" />
<select v-if="form.appnames.length > 1" v-model="imageApp" class="select select-bordered select-sm w-36">
<option v-for="n in form.appnames" :key="n" :value="n">{{ n }}</option>
</select>
<button type="button" class="btn btn-outline btn-sm btn-primary" :disabled="imgUploading" @click="uploadImage">
<span v-if="imgUploading" class="loading loading-spinner loading-xs"></span>上传
</button>
@ -370,6 +373,12 @@
<label class="label"><span class="label-text text-xs">OTA 更新包 *</span></label>
<input type="file" class="file-input file-input-bordered file-input-sm w-full" accept=".bin,.hex,.zip,.gz,.tar,.ota,.ufw,.fot,.upd" @change="onOtaPick" />
</div>
<div v-if="productApps.length > 1" class="form-control mb-3 max-w-xs">
<label class="label"><span class="label-text text-xs">上传到应用 *</span></label>
<select v-model="firmwareApp" class="select select-bordered select-sm w-full">
<option v-for="n in productApps" :key="n" :value="n">{{ n }}</option>
</select>
</div>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 cursor-pointer"><input v-model="ver.isuse" type="checkbox" class="checkbox checkbox-primary checkbox-sm" /><span class="text-sm">设为当前使用版本</span></label>
<button type="submit" class="btn btn-primary btn-sm text-white ml-auto" :disabled="!canAddVersion || versionSubmitting">
@ -412,7 +421,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { useAuthStore } from '~/stores/auth'
@ -610,6 +619,7 @@ function showForm(p?: Product) {
form.devicecodecount = p.devicecodecount === 2 ? 2 : 1
form.productimage = p.productimage || ''
form.appnames = appNamesOf(p)
imageApp.value = form.appnames[0] || ''
} else {
isEditing.value = false
form.id = 0
@ -622,6 +632,7 @@ function showForm(p?: Product) {
form.devicecodecount = 1
form.productimage = ''
form.appnames = []
imageApp.value = ''
}
view.value = 'form'
}
@ -808,15 +819,36 @@ function delProduct(p: Product) {
// ── 图片上传 ──
const imgFile = ref<File | null>(null)
const imgUploading = ref(false)
const imageApp = ref('')
watch(() => form.appnames.slice(), (names) => {
if (!names.includes(imageApp.value)) imageApp.value = names[0] || ''
})
function onImagePick(e: Event) {
imgFile.value = (e.target as HTMLInputElement).files?.[0] ?? null
}
// 上传目录用产品绑定的应用名。只绑一个时不用再选;绑了多个必须点名,否则服务端拒绝。
function pickUploadApp(names: string[], chosen: string): string | null {
const list = names.map((s) => s.trim()).filter(Boolean)
if (!list.length) {
warn('请先绑定应用,再上传')
return null
}
if (list.length === 1) return list[0]
const pick = chosen.trim()
if (!list.includes(pick)) {
warn('该产品绑定了多个应用,请选择要上传到哪个应用')
return null
}
return pick
}
async function uploadImage() {
if (!imgFile.value) { warn('请先选择图片'); return }
const appName = pickUploadApp(form.appnames, imageApp.value)
if (!appName) return
imgUploading.value = true
try {
const path = form.id ? `product/${form.id}/images/` : 'product/images/'
const url = await uploadFile(imgFile.value, path)
const url = await uploadFile(imgFile.value, path, { appName })
form.productimage = url
success('上传成功')
} catch (e: any) {
@ -832,6 +864,8 @@ const versions = ref<Version[]>([])
const versionSubmitting = ref(false)
const ver = reactive({ version: '', desc: '', isuse: false })
const otaFile = ref<File | null>(null)
const firmwareApp = ref('')
const productApps = computed(() => appNamesOf(currentProduct.value ?? { appnames: '' } as Product))
const canAddVersion = computed(() => !!ver.version.trim() && !!otaFile.value)
@ -846,6 +880,7 @@ async function showVersions(p: Product) {
const d = await webApi<{ product: Product }>('getproduct', { id: p.id })
currentProduct.value = d?.product ?? p
} catch { currentProduct.value = p }
firmwareApp.value = appNamesOf(currentProduct.value)[0] || ''
await loadVersions(p.id)
}
@ -910,10 +945,12 @@ async function addVersion() {
if (!pid) { error('产品ID不存在'); return }
if (!ver.version.trim()) { warn('请输入版本号'); return }
if (!otaFile.value) { warn('请选择固件文件'); return }
const appName = pickUploadApp(productApps.value, firmwareApp.value)
if (!appName) return
versionSubmitting.value = true
try {
// 固件包:目录传 product/<产品id>/,后端强制置于 EAIMAR/ 根下 → EAIMAR/product/<id>/<文件名>
const url = await uploadFile(otaFile.value, `product/${pid}/`)
// 目录根是产品绑定的应用名,由服务端按 appnames 写入,不再固定 EAIMAR/
const url = await uploadFile(otaFile.value, `product/${pid}/`, { appName })
const d = await webApi<{ version: Version }>('addproductversion', {
version: { productid: pid, version: ver.version.trim(), description: ver.desc, updatepackageaddress: url },
isuse: false,

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

@ -55,6 +55,7 @@ export const MENU_GROUPS: MenuGroup[] = [
label: '用户管理',
items: [
{ id: 'user', label: '用户查询', to: '/users', icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z' },
{ id: 'userfeedback', label: '用户反馈', to: '/feedback', icon: 'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z' },
],
},
{

19
apps/client/ios/Podfile

@ -63,20 +63,15 @@ post_install do |installer|
end
end
# ---- Auto patch: bump FlutterGeneratedPluginSwiftPackage iOS platform ----
# Flutter 生成的中介包总是写死 .iOS("12.0"),与 Podfile 中的
# platform :ios, '16.0' 不一致,会报
# "requires minimum platform version 15.0/16.0 ... but this target supports 12.0"。
# 这里自动同步为 16.0。
# ---- Auto patch: bump generated SPM packages to the app deployment target ----
# Flutter 生成的中介包写死 .iOS("13.0"),本地插件要求 16.0,会报
# "requires minimum platform version 16.0 ... but this target supports 13.0/15.0"。
# 与 Xcode scheme 里同一份脚本,pod install 时也对齐一次。
generated_pkg = File.expand_path(
'Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift', __dir__)
if File.exist?(generated_pkg)
content = File.read(generated_pkg)
new_content = content.gsub(/\.iOS\("\d+\.\d+"\)/, '.iOS("16.0")')
if new_content != content
File.write(generated_pkg, new_content)
puts '[Podfile patch] Bumped FlutterGeneratedPluginSwiftPackage iOS platform to 16.0'
end
sync_spm = File.expand_path('sync_spm_platform.sh', __dir__)
if File.exist?(generated_pkg) && File.exist?(sync_spm)
system(sync_spm) or raise '[Podfile patch] sync_spm_platform.sh 失败'
end
# ---- Auto patch: comment out illegal `<netinet6/in6.h>` imports in all Pods sources ----

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

@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objectVersion = 60;
objects = {
/* Begin PBXBuildFile section */
@ -338,7 +338,7 @@
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
@ -389,10 +389,14 @@
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";
@ -427,7 +431,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
shellScript = "\"$SRCROOT/sync_spm_platform.sh\"\n/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
B6CA63E72817925CE1A152C3 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
@ -437,10 +441,14 @@
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";
@ -641,7 +649,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 20;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = B8AJQ7ACU7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -835,7 +843,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 20;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = B8AJQ7ACU7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -869,7 +877,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 20;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = B8AJQ7ACU7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -988,7 +996,7 @@
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
@ -997,7 +1005,7 @@
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
package = 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */;
package = 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */

2
apps/client/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme

@ -10,7 +10,7 @@
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
scriptText = "&quot;$SRCROOT/sync_spm_platform.sh&quot;&#10;/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"

119
apps/client/ios/Runner/AppDelegate.swift

@ -1,4 +1,5 @@
import Flutter
import MachO
import UIKit
@main
@ -8,7 +9,11 @@ import UIKit
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
application.applicationIconBadgeNumber = 0
NativeCrashRecorder.install()
GeneratedPluginRegistrant.register(with: self)
// 第三方 SDK(高德/杰理等)初始化时可能装自己的信号处理器把我们顶掉,
// 过一会儿再装一次;装的时候会把当时在位的处理器记下来链式调用,两边都不丢。
DispatchQueue.main.asyncAfter(deadline: .now() + 15) { NativeCrashRecorder.install() }
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
@ -33,3 +38,117 @@ import UIKit
return false
}
}
// MARK: - 原生崩溃记录(2026-09-26)
//
// 起因:车载香薰对话里 App 两次 SIGSEGV 退出,但这台 iPhone 上**一份系统崩溃报告都没留下**
// (「分析数据」里最新的还是 9-21),devicectl 只能看到 "terminated due to signal 11",
// 没有调用栈就没法定位。怀疑是某个 SDK 装了信号处理器却没把信号交还系统。
//
// 这里在崩溃那一刻把调用栈写进 Documents/logs/native_crash.log(和 App 日志同目录,
// devicectl 能直接拉),然后把信号交还给之前在位的处理器 / 系统默认行为,不改变崩溃本身。
// 调用栈是「镜像 + 偏移」形式,拿同一次构建的 dSYM 用 atos 还原成函数名。
//
// 信号处理函数里只用 write / backtrace_symbols_fd 这类 async-signal-safe 的调用,
// 不分配内存、不碰 Swift 运行时的锁。
private var crashFD: Int32 = -1
/// 调用栈缓冲:安装时就分配好,信号处理里不再申请内存
private let crashFrames = UnsafeMutablePointer<UnsafeMutableRawPointer?>.allocate(capacity: 128)
private let crashSignals: [Int32] = [SIGSEGV, SIGBUS, SIGABRT, SIGILL, SIGFPE, SIGTRAP]
/// 安装时在位的旧处理器(按信号号索引),崩溃时链式调用
private let previousHandlers = UnsafeMutablePointer<sig_t?>.allocate(capacity: 32)
private var previousExceptionHandler: (@convention(c) (NSException) -> Void)?
private func crashWrite(_ s: StaticString) {
guard crashFD >= 0 else { return }
_ = s.withUTF8Buffer { write(crashFD, $0.baseAddress, $0.count) }
}
private func crashWriteInt(_ value: Int) {
guard crashFD >= 0 else { return }
// 用栈上的元组当缓冲,不走堆分配
var buf: (UInt64, UInt64, UInt64) = (0, 0, 0)
withUnsafeMutableBytes(of: &buf) { raw in
let p = raw.bindMemory(to: UInt8.self)
var v = value < 0 ? -value : value
var i = p.count
repeat { i -= 1; p[i] = UInt8(48 + v % 10); v /= 10 } while v > 0 && i > 0
if value < 0 && i > 0 { i -= 1; p[i] = 45 }
_ = write(crashFD, p.baseAddress! + i, p.count - i)
}
}
private let crashSignalHandler: @convention(c) (Int32) -> Void = { sig in
crashWrite("\n=== native crash signal=")
crashWriteInt(Int(sig))
crashWrite(" time=")
crashWriteInt(Int(time(nil)))
crashWrite(" ===\n")
let n = backtrace(crashFrames, 128)
backtrace_symbols_fd(crashFrames, n, crashFD)
crashWrite("=== images ===\n")
// 各镜像的加载基址:atos 还原时要用(-l <基址>)
let count = _dyld_image_count()
for idx in 0..<count {
if let name = _dyld_get_image_name(idx) {
let header = _dyld_get_image_header(idx)
crashWriteInt(Int(bitPattern: header))
crashWrite(" ")
_ = write(crashFD, name, strlen(name))
crashWrite("\n")
}
}
fsync(crashFD)
// 交还:之前有人装过处理器就调它(SIG_DFL=0 / SIG_IGN=1 不算),否则恢复默认并重发信号
if let prev = previousHandlers[Int(sig)] {
let raw = unsafeBitCast(prev, to: Int.self)
if raw != 0 && raw != 1 {
prev(sig)
}
}
signal(sig, SIG_DFL)
raise(sig)
}
enum NativeCrashRecorder {
private static var initialized = false
static func install() {
if !initialized {
initialized = true
previousHandlers.initialize(repeating: nil, count: 32)
}
if crashFD < 0 {
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let dir = docs.appendingPathComponent("logs")
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
crashFD = open(dir.appendingPathComponent("native_crash.log").path,
O_WRONLY | O_CREAT | O_APPEND, 0o644)
}
let mine = unsafeBitCast(crashSignalHandler, to: Int.self)
for sig in crashSignals {
let prev = signal(sig, crashSignalHandler)
// 在位的已经是我们自己(第二次 install):别记成「旧处理器」,否则崩溃时会调到自己、无限递归
if let p = prev, unsafeBitCast(p, to: Int.self) == mine { continue }
previousHandlers[Int(sig)] = prev
}
let current = NSGetUncaughtExceptionHandler()
if current.map({ unsafeBitCast($0, to: Int.self) }) != unsafeBitCast(exceptionHandler, to: Int.self) {
previousExceptionHandler = current
NSSetUncaughtExceptionHandler(exceptionHandler)
}
}
private static let exceptionHandler: @convention(c) (NSException) -> Void = { e in
let text = "\n=== uncaught exception \(Date()) \(e.name.rawValue): \(e.reason ?? "")\n"
+ e.callStackSymbols.joined(separator: "\n") + "\n"
if crashFD >= 0, let d = text.data(using: .utf8) {
d.withUnsafeBytes { _ = write(crashFD, $0.baseAddress, d.count) }
fsync(crashFD)
}
previousExceptionHandler?(e)
}
}

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

@ -140,6 +140,8 @@
<string>Location access enables the app to provide location-based voice translation services, such as translating local place names and providing contextual information relevant to your current location during conversations.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Our app's voice AI interaction features (such as voice wake-up for AI, issuing commands, and real-time conversations) require microphone access. With this permission, you can interact with AI directly through speech, improving operational efficiency. We strictly protect your voice data—only used for real-time processing of interaction requests, and raw audio will not be stored.</string>
<key>NSMotionUsageDescription</key>
<string>This app uses motion and fitness data to detect when you're walking or moving, which helps optimize voice recognition accuracy and automatically adjust microphone sensitivity for better audio recording quality during different activities.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Photo library permission is required to save exported images (mind maps, statistics) to your album</string>
<key>NSPhotoLibraryUsageDescription</key>

118
apps/client/ios/sync_spm_platform.sh

@ -24,10 +24,12 @@
# ./ios/sync_spm_platform.sh # 在 apps/client 下随时手动跑,幂等
# ./ios/sync_spm_platform.sh --check # 只检查不改写,不一致时退出码 1(给 CI 用)
#
# 挂进 Xcode(推荐,省得每次记着手动跑):
# Product → Scheme → Edit Scheme → Build → Pre-actions → + New Run Script Action
# Provide build settings from: Runner
# 脚本内容: "$SRCROOT/sync_spm_platform.sh"
# Xcode 在构建进程启动时就读完 Package.swift,scheme 里的 pre-action 改文件
# 已经晚了(当前 Xcode 还会把 Flutter 写的 13.0 抬成 15.0 再去比插件的 16.0)。
# 所以除了构建前再跑一遍,脚本还会装一个只盯这份生成包的 LaunchAgent:
# flutter pub get 一把文件写回 13.0,它就立刻改回工程的 deployment target。
# 标题必须保持 "Run Prepare Flutter Framework Script",Flutter 靠这串判断
# scheme 已迁移;改掉的话下次 flutter pub get 会再插一条、把这里冲乱。
#
# ⚠️ 不依赖 flutter 命令:Xcode 的 pre-action 环境 PATH 很干净,调 flutter 经常找不到。
# 这里只做纯文本改写,几毫秒完成。代价是生成包必须已经存在(跑过一次
@ -44,11 +46,65 @@ set -euo pipefail
IOS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CLIENT_DIR="$(cd "$IOS_DIR/.." && pwd)"
PBXPROJ="$IOS_DIR/Runner.xcodeproj/project.pbxproj"
MANIFEST="$IOS_DIR/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift"
MANIFESTS=(
"$IOS_DIR/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift"
"$IOS_DIR/Flutter/ephemeral/Packages/.packages/FlutterFramework/Package.swift"
)
CHECK_ONLY=0
[[ "${1:-}" == "--check" ]] && CHECK_ONLY=1
# flutter pub get 会把生成包写回 13.0。Xcode 读这份文件的时机早于任何构建脚本,
# 所以要在写回的那一下就改回来,而不是等下一次点构建。
install_watcher() {
[[ $CHECK_ONLY -eq 1 ]] && return 0
local uid dir label plist tmp
uid="$(id -u)"
dir="$IOS_DIR/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage"
[[ -d "$dir" ]] || return 0
label="com.yimai.eaimar.sync-spm-platform"
plist="$HOME/Library/LaunchAgents/${label}.plist"
mkdir -p "$HOME/Library/LaunchAgents"
tmp="$(mktemp)"
cat > "$tmp" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${label}</string>
<key>ProgramArguments</key>
<array>
<string>${IOS_DIR}/sync_spm_platform.sh</string>
<string>--from-watcher</string>
</array>
<key>WatchPaths</key>
<array>
<string>${dir}</string>
</array>
<key>ThrottleInterval</key>
<integer>1</integer>
</dict>
</plist>
EOF
if [[ -f "$plist" ]] && cmp -s "$tmp" "$plist"; then
rm -f "$tmp"
else
mv "$tmp" "$plist"
launchctl bootout "gui/${uid}/${label}" >/dev/null 2>&1 || true
launchctl bootstrap "gui/${uid}" "$plist" >/dev/null 2>&1 || true
fi
# 已经在跑就别再 bootstrap;没加载过则补一次
if ! launchctl print "gui/${uid}/${label}" >/dev/null 2>&1; then
launchctl bootstrap "gui/${uid}" "$plist" >/dev/null 2>&1 || true
fi
}
# 监视器回调自己时不要再去装卸监视器,否则写文件会把自己叫醒。
if [[ "${1:-}" != "--from-watcher" ]]; then
install_watcher || true
fi
red() { printf "\033[31m%s\033[0m\n" "$*"; }
green() { printf "\033[32m%s\033[0m\n" "$*"; }
blue() { printf "\033[34m%s\033[0m\n" "$*"; }
@ -95,6 +151,8 @@ if ! le "$FLOOR" "$CEILING"; then
fi
# ── 4) 对齐生成包 ───────────────────────────────────────────────────────────
# 插件包必须存在。FlutterFramework 是可选的,在才改。
MANIFEST="${MANIFESTS[0]}"
if [[ ! -f "$MANIFEST" ]]; then
red "✗ 生成包还不存在:${MANIFEST#"$CLIENT_DIR"/}"
red " 先在 apps/client 下跑一次 \`flutter pub get\`(或 \`flutter build ios --config-only\`)再执行本脚本。"
@ -123,24 +181,34 @@ ensure_local_plugin_links() {
}
ensure_local_plugin_links
CURRENT_RAW="$(grep -o '\.iOS([^)]*)' "$MANIFEST" | head -1 | sed 's/^\.iOS(//; s/)$//')"
CURRENT="$(normalize "$CURRENT_RAW")"
if [[ "$CURRENT" == "$CEILING" ]]; then
green "✓ 生成包已是 iOS ${CURRENT},无需改动"
exit 0
fi
if [[ $CHECK_ONLY -eq 1 ]]; then
red "✗ 生成包是 iOS ${CURRENT},应为 ${CEILING}(跑 ./ios/sync_spm_platform.sh 修)"
exit 1
fi
# 只替换 platforms 段里的第一个 .iOS(...),不碰文件其余内容
/usr/bin/sed -i '' "s/\.iOS($(printf '%s' "$CURRENT_RAW" | sed 's/[.[\*^$\/]/\\&/g'))/.iOS(\"$CEILING\")/" "$MANIFEST"
align_one() {
local manifest="$1"
[[ -f "$manifest" ]] || return 0
local current_raw current
current_raw="$(grep -o '\.iOS([^)]*)' "$manifest" | head -1 | sed 's/^\.iOS(//; s/)$//')"
current="$(normalize "$current_raw")"
local label="${manifest#"$CLIENT_DIR"/}"
if [[ "$current" == "$CEILING" ]]; then
green "✓ ${label} 已是 iOS ${current}"
return 0
fi
if [[ $CHECK_ONLY -eq 1 ]]; then
red "✗ ${label} 是 iOS ${current},应为 ${CEILING}(跑 ./ios/sync_spm_platform.sh 修)"
return 1
fi
# 只替换 platforms 段里的第一个 .iOS(...),不碰文件其余内容
/usr/bin/sed -i '' "s/\.iOS($(printf '%s' "$current_raw" | sed 's/[.[\*^$\/]/\\&/g'))/.iOS(\"$CEILING\")/" "$manifest"
local new
new="$(normalize "$(grep -o '\.iOS([^)]*)' "$manifest" | head -1 | sed 's/^\.iOS(//; s/)$//')")"
if [[ "$new" != "$CEILING" ]]; then
red "✗ 改写失败 ${label},现在是 ${new}"
return 1
fi
green "✓ ${label} 平台版本 ${current} → ${CEILING}"
}
NEW="$(normalize "$(grep -o '\.iOS([^)]*)' "$MANIFEST" | head -1 | sed 's/^\.iOS(//; s/)$//')")"
if [[ "$NEW" != "$CEILING" ]]; then
red "✗ 改写失败,现在是 ${NEW}"; exit 1
fi
green "✓ 生成包平台版本 ${CURRENT} → ${CEILING},可以回 Xcode 构建了"
fail=0
for m in "${MANIFESTS[@]}"; do
align_one "$m" || fail=1
done
exit "$fail"

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

@ -72,7 +72,8 @@ class DeviceCategories {
// 2026-09-08 废弃,下发的值恒为 0,再也匹配不到 1/2/3。留着字段只为不动
// 老数据结构,**新品类一律用 nameKeywords 圈定**。圈不到时 productsOf 返回空。
//
// 「选择设备」页当前上架三条:AI耳机(恒玄)、AI手机支架(EaiRec)、AI车载香薰(EaiCar)。
// 「选择设备」页当前上架两条:AI耳机(恒玄)、AI车载香薰(EaiCar)。
// AI手机支架(EaiRec)2026-09-26 暂时下架,定义在 [hidden] 里。
// 下架某条 = 把它挪回 [hidden]。
static const List<DeviceCategory> all = [
DeviceCategory(
@ -85,19 +86,6 @@ class DeviceCategories {
// deviceTypes 已失效(见上),耳机改由名字圈定
nameKeywords: ['echo-one', 'deepvoice', 'deepsound', 'buds', 'ai80', 'a80'],
),
// AI手机支架 = EaiRec(EAIMAR 圆盘)。2026-09-08 上架,取代原先挂在这个
// 品类下、已停售的EaiRec Echomeet 支架 —— 所以沿用 phoneHolder 这个 id:
// AI 页的默认功能编排(会议/现场录音/通话录音)本来就是按它配的,换 id 要一起改。
// ⚠️ 蓝牙链路还没接,点击不进扫描页(见 device_category_view._openCategory)。
DeviceCategory(
id: phoneHolder,
title: 'catPhoneHolder',
subtitle: 'catPhoneHolderDesc',
icon: Icons.stay_current_portrait,
gradient: [Color(0xFF42A5F5), Color(0xFF1E88E5)],
imageAsset: '${DeviceImages.dir}EaiRec.png',
nameKeywords: ['echomeet', 'eairec', 'eaimar', '支架', 'holder', 'stand'],
),
// AI车载香薰 = EaiCar(广播名,旧名 Smartcar 已于 2026-09-15 停止接纳)。
DeviceCategory(
id: carAroma,
@ -115,8 +103,22 @@ class DeviceCategories {
/// 只是不展示,定义都留着:AI 页的默认功能编排([AgentModules.defaultsByCategory])
/// 和主图映射还按 id 引用它们,老用户存下来的数据也要能查得到,
/// 所以 [byId] 仍然会在这里查。上架时把条目挪回 [all] 就行。
// 注:phoneHolder 已于 2026-09-08 挪到 [all] 上架(产品换成 EaiRec)。
// 注:phoneHolder 2026-09-08 上架(产品换成 EaiRec),2026-09-26 又暂时下架:
// 「选择设备」页不再出现,但已连过的支架照常回连、导入录音,默认编排也照旧按这个 id 走。
static const List<DeviceCategory> hidden = [
// AI手机支架 = EaiRec(EAIMAR 圆盘)。2026-09-08 上架,取代原先挂在这个
// 品类下、已停售的EaiRec Echomeet 支架 —— 所以沿用 phoneHolder 这个 id:
// AI 页的默认功能编排(会议/现场录音/通话录音)本来就是按它配的,换 id 要一起改。
// ⚠️ 蓝牙链路还没接,点击不进扫描页(见 device_category_view._openCategory)。
DeviceCategory(
id: phoneHolder,
title: 'catPhoneHolder',
subtitle: 'catPhoneHolderDesc',
icon: Icons.stay_current_portrait,
gradient: [Color(0xFF42A5F5), Color(0xFF1E88E5)],
imageAsset: '${DeviceImages.dir}EaiRec.png',
nameKeywords: ['echomeet', 'eairec', 'eaimar', '支架', 'holder', 'stand'],
),
DeviceCategory(
id: noteCard,
title: 'catNoteCard',

48
apps/client/lib/data/services/bailian_multimodal_service.dart

@ -202,10 +202,35 @@ class BailianMultimodalService extends GetxService {
/// 服务是单例、两条链路轮流用,所以在 [start] 时定,不由调用方每次传。
String directiveSource = 'emai';
/// 需要在 Start 时带上「位置 + 日期」的百炼应用 ID(由模块在 registerServices 里登记,
/// 目前只有车载香薰)。其它应用(EMAI 耳机)的 Start 报文一个字节不变。
/// 额外需要在 Start 时带上「位置 + 日期」的百炼应用 ID。
/// 车载助手在自己的模块里登记。EMAI 不进这个集合:它就是 [resolveAppId] 的默认应用
/// (含后台覆盖后的 id),由 [wantsClientContext] 认出来。
static final Set<String> clientContextAppIds = <String>{};
/// EMAI 与登记过的应用在 Start 时带位置、日期。其它应用一个字节不变。
static bool wantsClientContext(String appId) {
if (clientContextAppIds.contains(appId)) return true;
return appId.isNotEmpty && appId == resolveAppId();
}
/// `${location}` / `${date}` 与 `client_info.location`。只读缓存,不等定位。
/// 这个应用不需要时返回 null。没有缓存时地址是「未知」,日期照样给。
static Map<String, dynamic>? promptContext(String appId) {
if (!wantsClientContext(appId)) return null;
DeviceLocationService? loc;
try {
loc = DeviceLocationService.maybe;
} catch (_) {
loc = null;
}
final clientLocation = loc?.clientLocation();
return {
'location': loc?.placeText() ?? '未知',
'date': DeviceLocationService.dateText(),
if (clientLocation != null) 'clientLocation': clientLocation,
};
}
Future<bool> start({
String mode = 'push2talk',
String? voice,
@ -227,16 +252,15 @@ class BailianMultimodalService extends GetxService {
directiveSource = source;
_taskId = _uuid();
// 提示词「系统条件」里的 ${location} / ${date}:只读缓存,不等定位
final withContext = clientContextAppIds.contains(_appId);
final loc = withContext ? DeviceLocationService.maybe : null;
final clientLocation = loc?.clientLocation();
final promptParams = withContext
? <String, String>{
'location': loc?.placeText() ?? '未知',
'date': DeviceLocationService.dateText(),
}
: const <String, String>{};
if (withContext) {
final ctx = promptContext(_appId);
final clientLocation = ctx?['clientLocation'] as Map<String, String>?;
final promptParams = ctx == null
? const <String, String>{}
: <String, String>{
'location': '${ctx['location']}',
'date': '${ctx['date']}',
};
if (ctx != null) {
Logger.i(
_tag, '会话上下文: $promptParams, client_info.location=$clientLocation');
}

20
apps/client/lib/data/services/bailian_native_dialog.dart

@ -119,9 +119,22 @@ class BailianNativeDialog {
},
);
try {
final appId = BailianMultimodalService.resolveAppId(_appIdOverride);
// 地址和日期的文案只在 Dart 算一份。原生负责写进 Start,不在这里等定位。
final ctx = BailianMultimodalService.promptContext(appId);
final rawLoc = ctx?['clientLocation'];
final locMap = rawLoc is Map
? rawLoc.map((k, v) => MapEntry('$k', '$v'))
: null;
if (ctx != null) {
Logger.i(
_tag,
'会话上下文: location=${ctx['location']}, date=${ctx['date']}, '
'client_info.location=${locMap == null ? '无' : '有'}');
}
final ok = await _channel.invokeMethod<bool>('start', {
'apiKey': BailianMultimodalService.resolveApiKey(),
'appId': BailianMultimodalService.resolveAppId(_appIdOverride),
'appId': appId,
'workspaceId':
BailianMultimodalService.resolveWorkspaceId(_workspaceIdOverride),
'mode': mode,
@ -132,6 +145,11 @@ class BailianNativeDialog {
if (mcpToken != null && mcpToken.trim().isNotEmpty) 'mcpToken': mcpToken,
// 工具名只在 Dart 维护一份:服务端加工具时改那一处就够了
'mcpAuthTools': BailianMultimodalService.mcpAuthTools,
if (ctx != null) 'promptLocation': ctx['location'],
if (ctx != null) 'promptDate': ctx['date'],
if (locMap != null) 'latitude': locMap['latitude'],
if (locMap != null) 'longitude': locMap['longitude'],
if ((locMap?['city_name'] ?? '').isNotEmpty) 'cityName': locMap!['city_name'],
});
_running = ok == true;
if (!_running) await _cancelSub();

43
apps/client/lib/data/services/device_location_service.dart

@ -10,18 +10,20 @@ import '../../core/utils/logger.dart';
import '../../devices/device_hub.dart';
import '../../devices/device_vendors.dart';
/// 给百炼会话提供「用户在哪、现在几点」(2026-09-25,车载香薰)。
/// 给百炼会话提供「用户在哪、现在几点」(2026-09-25 车载香薰,2026-10-09 起 EMAI 耳机共用)。
///
/// 车载助手的提示词里有「系统条件」:`${location}`(用户当前位置)、`${date}`(今天的日期)。
/// 车载助手和 EMAI 的提示词里有「系统条件」:`${location}`(用户当前位置)、`${date}`(今天的日期)。
/// 以前一个都没传,AI 问天气只能反问「你要查哪里」。现在两条通道一起给
/// (见 [BailianMultimodalService.start]):
/// - `client_info.location` = {latitude, longitude, city_name}(lumi 也是这么传经纬度的);
/// - `biz_params.user_prompt_params` = {location: 省市区, date: 年月日 星期 时:分}。
/// - `biz_params.user_prompt_params` = {location: 完整地址, date: 年月日 星期 时:分}。
/// `${location}` 优先高德返回的详细地址(路名、门牌),没有再退回省市区加街道。
/// 经纬度只进 `client_info.location`,不进这段要念出来的文案。
///
/// 节奏照 lumi 的 LocationService:启动 / 回到前台时刷新,10 分钟内不重复,结果落盘;
/// **建会话时只读缓存,从不等定位**,所以不拖慢唤醒。
///
/// 权限:车载香薰第一次连上(且 App 在前台)时申请一次「使用期间」定位权限,
/// 权限:恒玄耳机或车载香薰第一次连上(且 App 在前台)时申请一次「使用期间」定位权限,
/// 用户拒绝后不再追问——没有权限就只是不带位置。
class DeviceLocationService extends GetxService with WidgetsBindingObserver {
static const String _tag = 'DeviceLocation';
@ -67,7 +69,8 @@ class DeviceLocationService extends GetxService with WidgetsBindingObserver {
}
void _checkSessions(List list) {
if (list.any((s) => s.vendor == DeviceVendors.eaicar)) {
if (list.any((s) =>
s.vendor == DeviceVendors.eaicar || s.vendor == DeviceVendors.bes)) {
unawaited(askPermissionOnce());
}
}
@ -92,7 +95,7 @@ class DeviceLocationService extends GetxService with WidgetsBindingObserver {
if (state == AppLifecycleState.resumed) refresh();
}
/// 车载香薰第一次连上时申请一次定位权限。只问一次,拒绝后不再打扰。
/// 耳机或车载香薰第一次连上时申请一次定位权限。只问一次,拒绝后不再打扰。
Future<void> askPermissionOnce() async {
if (_storage.read<bool>(_askedKey) == true) return;
// 权限框要在前台弹;在后台(被动回连)时先不问,下次前台连上再说
@ -103,7 +106,7 @@ class DeviceLocationService extends GetxService with WidgetsBindingObserver {
await _storage.write(_askedKey, true);
try {
final st = await Permission.locationWhenInUse.request();
Logger.i(_tag, '车载香薰首次连接,申请定位权限 → $st');
Logger.i(_tag, '设备首次连接,申请定位权限 → $st');
if (st.isGranted || st.isLimited) await refresh(force: true);
} catch (e) {
Logger.w(_tag, '申请定位权限失败: $e');
@ -144,24 +147,20 @@ class DeviceLocationService extends GetxService with WidgetsBindingObserver {
String _s(String k) => (_place?[k] ?? '').toString().trim();
/// `${location}` 的取值:省市区(去掉重复的直辖市名),拿不到就是「未知」。
String placeText() {
/// `${location}` 的取值。优先完整地址;没有再拼省市区加街道。不含经纬度。
String placeText() => formatPlaceText(_place);
/// 纯函数,方便单测。`place` 的键与 [LocationService.currentPlace] 一致。
static String formatPlaceText(Map<String, dynamic>? place) {
String field(String k) => (place?[k] ?? '').toString().trim();
final address = field('address');
if (address.isNotEmpty) return address;
final parts = <String>[];
for (final k in ['province', 'city', 'district']) {
final v = _s(k);
for (final k in ['province', 'city', 'district', 'street']) {
final v = field(k);
if (v.isNotEmpty && !parts.contains(v)) parts.add(v);
}
if (parts.isEmpty) {
final addr = _s('address');
if (addr.isNotEmpty) return addr;
// 反查不到地名时用坐标描述,模型/插件还能据此判断城市,比「未知」强
final lat = _place?['latitude'];
final lng = _place?['longitude'];
if (lat is num && lng is num) {
return '经纬度 ${lat.toStringAsFixed(4)},${lng.toStringAsFixed(4)}';
}
return '未知';
}
if (parts.isEmpty) return '未知';
return parts.join();
}

2
apps/client/lib/modules/eaicar/bindings/eaicar_chat_binding.dart

@ -29,6 +29,8 @@ class EaiCarChatBinding extends Bindings {
Get.lazyPut<EmaiController>(
() => EmaiController(
service: Get.find<BailianMultimodalService>(tag: kEaiCarTag),
// 手机麦也和设备一样半双工:AI 说话时关麦,说完才开
halfDuplexMic: true,
),
tag: kEaiCarTag,
);

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

@ -35,8 +35,8 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor(
entryRoute: Routes.eaicarChat,
// 设备按键唤醒时接的就是这个应用(不是 EMAI)——见 AgentModuleDescriptor.bailianAppId
bailianAppId: kEaiCarBailianAppId,
// 车载助手提示词要 ${location} / ${date}:登记本应用,Start 时带上位置与日期;
// 位置服务也只为它常驻(EMAI 耳机的会话不带、不受影响)。
// 车载助手提示词要 ${location} / ${date}:登记本应用,Start 时带上位置与日期。
// 位置服务两边共用;EMAI 自己的应用 ID 不进这个集合,由 wantsClientContext 认。
registerServices: () {
BailianMultimodalService.clientContextAppIds.add(kEaiCarBailianAppId);
if (!Get.isRegistered<DeviceLocationService>()) {

135
apps/client/lib/modules/emai/controllers/emai_controller.dart

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
@ -45,9 +46,17 @@ class EmaiController extends GetxController
/// 传了才用传的——给「同一套对话 UI、不同百炼应用」的 agent 复用(如 EaiCar
/// 车载助手)。两个 agent 各自 new 一个 service 实例,不能共用,理由见
/// [BailianMultimodalService] 的类注释。
EmaiController({BailianMultimodalService? service})
EmaiController({BailianMultimodalService? service, this.halfDuplexMic = false})
: _svc = service ?? Get.find<BailianMultimodalService>();
/// 手机麦半双工:AI 播报期间上行换成等长静音,本地播完再放开(2026-09-26)。
///
/// 只有车载香薰页开([EaiCarChatBinding] 传 true),与设备那条链路的门控口径一致:
/// 外放 + 两台设备互聊时,AEC 挡不住对方/自己的声音,服务端 VAD 会把它当成插话,
/// 一轮没说完就被打断,下一轮的识别和回复插进来,字幕气泡互相串。
/// EMAI 页保持原样(靠 AEC 全双工,用户可以随时插话)。
final bool halfDuplexMic;
static const String _tag = 'EmaiController';
final BailianMultimodalService _svc;
@ -129,6 +138,98 @@ class EmaiController extends GetxController
Timer? _handshakeTimer;
static const Duration _handshakeTimeout = Duration(seconds: 15);
// ---- 手机麦半双工门(仅 [halfDuplexMic])----
bool _micGated = false;
Timer? _micGateOpenTimer;
Timer? _micGateWatchdog;
/// 播完之后再等这么久才开麦:外放尾音与房间混响
static const Duration _micGateTail = Duration(milliseconds: 300);
/// 本轮开始收下行音频的时刻,与累计收到的音频秒数:用来估「这一轮应该什么时候播完」
DateTime? _roundAudioStart;
double _roundAudioSec = 0;
/// 预计播完时刻之后再宽限这么久,还没等到播放器的完成回调就判定卡住
static const Duration _playbackGrace = Duration(seconds: 8);
/// 回复开始后一包音频都没来(纯文字回复、或下行断了)时的兜底
static const Duration _noAudioTimeout = Duration(seconds: 30);
void _closeMicGate() {
_micGateOpenTimer?.cancel();
_micGateOpenTimer = null;
if (!_micGated) {
_micGated = true;
_roundAudioStart = null;
_roundAudioSec = 0;
Logger.i(_tag, '半双工:AI 开口,手机麦静音');
}
_armPlaybackWatchdog();
}
/// 每收到一包下行音频调一次:把这包的时长记进本轮
void _onDownlinkAudio(int bytes) {
_roundAudioStart ??= DateTime.now();
// 16bit 单声道:每秒 sampleRate*2 字节
_roundAudioSec += bytes / (BailianMultimodalService.downstreamSampleRate * 2);
_armPlaybackWatchdog();
}
/// 播放兜底:按「开始收音频 + 已收音频总时长 + 宽限」算截止时刻(没音频就按 [_noAudioTimeout])。
///
/// ⚠️ 不能按「多久没来新音频」算:百炼合成比实时快,一段 60 秒的长回复可能 10 秒就全到了,
/// 那样会在还在播的时候就误判卡住、把麦打开。
void _armPlaybackWatchdog() {
_micGateWatchdog?.cancel();
final start = _roundAudioStart;
final Duration wait;
if (start == null) {
wait = _noAudioTimeout;
} else {
final end = start.add(Duration(milliseconds: (_roundAudioSec * 1000).round()) + _playbackGrace);
final left = end.difference(DateTime.now());
wait = left.isNegative ? Duration.zero : left;
}
_micGateWatchdog = Timer(wait, _onPlaybackStuck);
}
/// 过了预计播完时刻还没等到播放器报完成:播放卡住了。
///
/// 只开麦是不够的——百炼 duplex 要等客户端发 LocalRespondingEnded 才回到聆听,
/// 播放器不报完成、这条通知就永远不发,服务端一直停在「回复中」,之后说什么都没反应
/// (2026-09-26 真机:第 50 轮左右卡住,再也不回复)。所以这里替播放器收尾:
/// 停掉播放、补发通知、再开麦。
void _onPlaybackStuck() {
if (!_micGated) return;
Logger.w(_tag,
'半双工:本轮音频 ${_roundAudioSec.toStringAsFixed(1)}s 已过预计播完时刻仍未收到播完回调,'
'判定播放卡住:停播放、补发本地播完、开麦');
unawaited(_player.stopNow());
_svc.notifyLocalRespondingEnded();
level.value = 0;
_openMicGate();
}
void _openMicGate({Duration after = Duration.zero}) {
_micGateOpenTimer?.cancel();
void open() {
_micGateOpenTimer = null;
_micGateWatchdog?.cancel();
_micGateWatchdog = null;
if (_micGated) {
_micGated = false;
Logger.i(_tag, '半双工:AI 说完,手机麦恢复');
}
}
if (after == Duration.zero) {
open();
} else {
_micGateOpenTimer = Timer(after, open);
}
}
/// 连续对话的空闲上限:这么久没有任何对话往来就自动结束,别让麦克风一直开着。
static const Duration _continuousIdleTimeout = Duration(seconds: 10);
@ -177,8 +278,17 @@ class EmaiController extends GetxController
}
_subs.add(_svc.events.listen(_onEvent));
_subs.add(_svc.audioOut.listen(_player.feed));
_subs.add(_mic.packets.listen(_svc.pushAudio));
_subs.add(_svc.audioOut.listen((pcm) {
if (halfDuplexMic) {
_closeMicGate();
_onDownlinkAudio(pcm.length);
}
_player.feed(pcm);
}));
_subs.add(_mic.packets.listen((pcm) {
// 门关着就推等长静音,不停推流:服务端的 VAD / 断句按时间走,断流会被当成网络问题
_svc.pushAudio(_micGated ? Uint8List(pcm.length) : pcm);
}));
// ⚠️ 音浪的门控不能只看 isTalking:连续对话下它恒为 false,
// 麦克风明明开着,界面上却一点动静都没有,用户完全看不出在不在收音。
_subs.add(_mic.level.listen((v) {
@ -191,6 +301,8 @@ class EmaiController extends GetxController
// 告诉服务端本地播报结束,它才会把对话状态推回 Listening/Idle
_svc.notifyLocalRespondingEnded();
level.value = 0;
// 真正播完再开麦,多等一点让外放的尾音散掉,别被自己的麦收回去
if (halfDuplexMic) _openMicGate(after: _micGateTail);
}));
_jumpToBottomOnEnter();
@ -225,6 +337,8 @@ class EmaiController extends GetxController
}
}
_handshakeTimer?.cancel();
_micGateOpenTimer?.cancel();
_micGateWatchdog?.cancel();
_cancelIdle();
for (final s in _subs) {
s.cancel();
@ -327,6 +441,7 @@ class EmaiController extends GetxController
_autoRetried = true; // 挡掉 closed 事件触发的自动重连,这次断开是有意的
await _abortTalk();
await _player.stopNow();
if (halfDuplexMic) _openMicGate();
await _svc.stop();
linkState.value = EmaiLinkState.onEarphone;
}
@ -539,6 +654,7 @@ class EmaiController extends GetxController
// 助手还在说话时按下 = 打断:先停本地播放,再让服务端交出发言权
if (isResponding) {
await _player.stopNow();
if (halfDuplexMic) _openMicGate();
_svc.interrupt();
}
if (gen != _talkGen) return;
@ -643,6 +759,7 @@ class EmaiController extends GetxController
if (isResponding) {
_player.stopNow();
if (halfDuplexMic) _openMicGate();
_svc.interrupt();
}
@ -687,6 +804,7 @@ class EmaiController extends GetxController
Future<void> interrupt() async {
if (!isResponding) return;
await _player.stopNow();
if (halfDuplexMic) _openMicGate();
_svc.interrupt();
_streamingAssistant?.isFinal = true;
_streamingAssistant = null;
@ -759,6 +877,15 @@ class EmaiController extends GetxController
break;
case BailianEventType.respondingStarted:
if (halfDuplexMic) _closeMicGate();
// 上一轮回复没等到 RespondingEnded 就被新一轮顶掉(被插话打断):先把它收尾,
// 否则它一直是「流式中」,新旧两轮的字幕会交错写进列表(2026-09-26 真机「字幕乱串」)
final prev = _streamingAssistant;
if (prev != null) {
_dropIfEmpty(prev);
prev.isFinal = true;
_store.finalize(prev);
}
_streamingAssistant = EmaiMessage(isUser: false, text: '');
_append(_streamingAssistant!);
break;
@ -799,6 +926,7 @@ class EmaiController extends GetxController
case BailianEventType.error:
Logger.e(_tag, '会话错误: ${e.code} ${e.text} fatal=${e.fatal}');
_abortTalk();
if (halfDuplexMic) _openMicGate();
if (e.fatal) {
// 会话真的废了(task-failed / 连不上 / 没配置):停掉握手看门狗,
// 状态条转成可重连的错误态
@ -835,6 +963,7 @@ class EmaiController extends GetxController
// 断线时麦克风还开着的话必须放掉,否则会一直占用录音设备
_abortTalk();
_player.stopNow();
if (halfDuplexMic) _openMicGate();
if (linkState.value == EmaiLinkState.ready) {
linkState.value = EmaiLinkState.idle;
// 网络抖一下就掉的情况很常见,自动补一次;再断才让用户手动点

8
apps/client/lib/modules/emai/module.dart

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../core/utils/ai_navigation.dart';
import '../../data/services/device_location_service.dart';
import '../../routes/app_routes.dart';
import '../module_descriptor.dart';
import 'bindings/emai_binding.dart';
@ -16,6 +17,13 @@ final AgentModuleDescriptor emaiModule = AgentModuleDescriptor(
gradient: const [Color(0xFF5B8DEF), Color(0xFF7E57C2)],
// 按 agentType 走移动精灵或 EMAI 页
onLaunch: navigateToAIAssistant,
// 位置缓存给手机页和耳机唤醒共用。手机页在 Dart Start 里读,
// 耳机按键唤醒由原生 Start 写入同一份缓存,这里只保证服务在。
registerServices: () {
if (!Get.isRegistered<DeviceLocationService>()) {
Get.put<DeviceLocationService>(DeviceLocationService(), permanent: true);
}
},
pages: [
GetPage(
name: Routes.emai,

31
apps/client/lib/modules/meeting/views/tabs/summary_tab.dart

@ -12,8 +12,6 @@ import '../../controllers/meeting_details_controller.dart';
import '../../utils/meeting_ui_utils.dart';
import '../bottomSheet/ai_bottom_sheet.dart';
import '../bottomSheet/generate_bottom_sheet.dart';
import '../bottomSheet/speaker_all_bottom_sheet.dart';
class SummaryTab extends StatefulWidget {
const SummaryTab({super.key});
@ -160,6 +158,17 @@ class _SummaryTabState extends State<SummaryTab> {
}
}
void _startEditSummary() {
final fc = _fleatherController;
if (fc == null) return;
final end = fc.document.length - 1;
fc.updateSelection(
TextSelection.collapsed(offset: end < 0 ? 0 : end),
source: ChangeSource.local,
);
_focusNode.requestFocus();
}
void _saveSummary() async {
if (_fleatherController == null) return;
final markdown =
@ -413,21 +422,9 @@ class _SummaryTabState extends State<SummaryTab> {
if (_controller.meetingDetails.value.tasktype >= 3 &&
_controller.meetingDetails.value.tasktype < 10000)
_button(
Icons.supervisor_account,
'editSpeaker'.tr, // 对应中文:编辑发言者
() {
Get.bottomSheet(
const SpeakerAllBottomSheet(),
isScrollControlled: true,
backgroundColor:
isDarkMode ? Colors.grey[800] : Colors.grey[100],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r),
),
),
);
},
Icons.edit,
'editSpeaker'.tr, // 对应中文:编辑
_startEditSummary,
),
if (_controller.meetingDetails.value.tasktype >= 3 &&
_controller.meetingDetails.value.tasktype < 10000)

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

@ -263,6 +263,11 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
downstreamRate = call.argument<Int>("downstreamRate") ?: 16000,
mcpToken = call.argument<String>("mcpToken"),
mcpAuthTools = call.argument<List<String>>("mcpAuthTools") ?: emptyList(),
promptLocation = call.argument<String>("promptLocation"),
promptDate = call.argument<String>("promptDate"),
latitude = call.argument<String>("latitude"),
longitude = call.argument<String>("longitude"),
cityName = call.argument<String>("cityName"),
)
val ok = client.start(cfg)
if (ok) bridge.enable()

54
apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/BailianDialogClient.kt

@ -73,6 +73,14 @@ class BailianDialogClient(
* 服务端加工具时只改 Dart 那一处,漏改的表现是那个工具永远「身份校验失败」。
*/
val mcpAuthTools: List<String>,
/** `${location}`:完整地址。空则不写 user_prompt_params.location。 */
val promptLocation: String? = null,
/** `${date}`:年月日 星期 时:分。空则不写。 */
val promptDate: String? = null,
/** `client_info.location`。纬度或经度空着就整块不发。 */
val latitude: String? = null,
val longitude: String? = null,
val cityName: String? = null,
)
private val exec = Executors.newSingleThreadExecutor { r -> Thread(r, "azure_speech.bailian") }
@ -212,6 +220,15 @@ class BailianDialogClient(
// 不传 voice:音色属于百炼应用的配置,硬编码只会跟应用实际用的 TTS 模型对不上
if (!c.voice.isNullOrBlank()) downstream.put("voice", c.voice)
val clientInfo = JSONObject().put("user_id", safeUserId(c.userId))
if (!c.latitude.isNullOrBlank() && !c.longitude.isNullOrBlank()) {
val loc = JSONObject()
.put("latitude", c.latitude)
.put("longitude", c.longitude)
if (!c.cityName.isNullOrBlank()) loc.put("city_name", c.cityName)
clientInfo.put("location", loc)
}
val parameters = JSONObject()
.put("upstream", JSONObject()
.put("type", "AudioOnly")
@ -219,23 +236,36 @@ class BailianDialogClient(
.put("audio_format", "pcm")
.put("sample_rate", UPSTREAM_SAMPLE_RATE))
.put("downstream", downstream)
.put("client_info", JSONObject().put("user_id", safeUserId(c.userId)))
.put("client_info", clientInfo)
val prompt = JSONObject()
if (!c.promptLocation.isNullOrBlank()) prompt.put("location", c.promptLocation)
if (!c.promptDate.isNullOrBlank()) prompt.put("date", c.promptDate)
// biz_params 是可选块,两件事都没有时整块不发——万一应用侧没开这些能力,
// biz_params 是可选块,三件事都没有时整块不发——万一应用侧没开这些能力,
// 多带一个字段可能让 Start 直接被拒,那样整个功能都起不来
val hasToken = !c.mcpToken.isNullOrBlank()
if (c.enableWebSearch || hasToken) {
val userDefined = JSONObject()
if (hasToken) {
// ⚠️ **必须按工具名嵌套**,平铺写 auth_token 会被静默丢弃
for (tool in c.mcpAuthTools) {
userDefined.put(tool, JSONObject().put("auth_token", c.mcpToken))
}
if (c.enableWebSearch || hasToken || prompt.length() > 0) {
val biz = JSONObject()
if (prompt.length() > 0) {
biz.put("user_prompt_params", prompt)
Log.i(TAG, "会话上下文 location=${c.promptLocation} date=${c.promptDate} " +
"coords=${!c.latitude.isNullOrBlank()}")
}
if (c.enableWebSearch) {
userDefined.put("extra_config", JSONObject().put("enable_web_search", true))
if (c.enableWebSearch || hasToken) {
val userDefined = JSONObject()
if (hasToken) {
// ⚠️ **必须按工具名嵌套**,平铺写 auth_token 会被静默丢弃
for (tool in c.mcpAuthTools) {
userDefined.put(tool, JSONObject().put("auth_token", c.mcpToken))
}
}
if (c.enableWebSearch) {
userDefined.put("extra_config", JSONObject().put("enable_web_search", true))
}
biz.put("user_defined_params", userDefined)
}
parameters.put("biz_params", JSONObject().put("user_defined_params", userDefined))
parameters.put("biz_params", biz)
}
return JSONObject()

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

@ -497,7 +497,12 @@ internal func handleBailianDialogCall(_ call: FlutterMethodCall, result: @escapi
enableWebSearch: (a["enableWebSearch"] as? Bool) ?? true,
downstreamRate: (a["downstreamRate"] as? Int) ?? 16000,
mcpToken: a["mcpToken"] as? String,
mcpAuthTools: (a["mcpAuthTools"] as? [String]) ?? []
mcpAuthTools: (a["mcpAuthTools"] as? [String]) ?? [],
promptLocation: a["promptLocation"] as? String,
promptDate: a["promptDate"] as? String,
latitude: a["latitude"] as? String,
longitude: a["longitude"] as? String,
cityName: a["cityName"] as? String
)
let ok = client.start(config: cfg)
if ok { bridge.enable() }

57
apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/BailianDialogClient.swift

@ -45,6 +45,14 @@ final class BailianDialogClient: NSObject {
/// 需要用户身份的 MCP 工具名。**由 Dart 传下来**,不在原生再抄一份:
/// 服务端加工具时只改 Dart 那一处,漏改的表现是那个工具永远「身份校验失败」。
let mcpAuthTools: [String]
/// `${location}`:完整地址。空则不写 user_prompt_params.location。
let promptLocation: String?
/// `${date}`:年月日 星期 时:分。空则不写。
let promptDate: String?
/// `client_info.location`。纬度或经度空着就整块不发。
let latitude: String?
let longitude: String?
let cityName: String?
}
private static let endpoint = "wss://dashscope.aliyuncs.com/api-ws/v1/inference"
@ -212,6 +220,16 @@ final class BailianDialogClient: NSObject {
downstream["voice"] = v
}
var clientInfo: [String: Any] = ["user_id": Self.safeUserId(c.userId)]
let lat = c.latitude?.trimmingCharacters(in: .whitespaces) ?? ""
let lng = c.longitude?.trimmingCharacters(in: .whitespaces) ?? ""
if !lat.isEmpty && !lng.isEmpty {
var loc: [String: Any] = ["latitude": lat, "longitude": lng]
let city = c.cityName?.trimmingCharacters(in: .whitespaces) ?? ""
if !city.isEmpty { loc["city_name"] = city }
clientInfo["location"] = loc
}
var parameters: [String: Any] = [
"upstream": [
"type": "AudioOnly",
@ -220,24 +238,39 @@ final class BailianDialogClient: NSObject {
"sample_rate": Self.upstreamSampleRate,
],
"downstream": downstream,
"client_info": ["user_id": Self.safeUserId(c.userId)],
"client_info": clientInfo,
]
// biz_params 是可选块,两件事都没有时整块不发——万一应用侧没开这些能力,
var prompt: [String: String] = [:]
let spoken = c.promptLocation?.trimmingCharacters(in: .whitespaces) ?? ""
let day = c.promptDate?.trimmingCharacters(in: .whitespaces) ?? ""
if !spoken.isEmpty { prompt["location"] = spoken }
if !day.isEmpty { prompt["date"] = day }
// biz_params 是可选块,三件事都没有时整块不发——万一应用侧没开这些能力,
// 多带一个字段可能让 Start 直接被拒,那样整个功能都起不来
let hasToken = !(c.mcpToken ?? "").trimmingCharacters(in: .whitespaces).isEmpty
if c.enableWebSearch || hasToken {
var userDefined: [String: Any] = [:]
if hasToken, let tok = c.mcpToken {
// ⚠️ **必须按工具名嵌套**,平铺写 `auth_token` 会被静默丢弃
for tool in c.mcpAuthTools {
userDefined[tool] = ["auth_token": tok]
}
if c.enableWebSearch || hasToken || !prompt.isEmpty {
var biz: [String: Any] = [:]
if !prompt.isEmpty {
biz["user_prompt_params"] = prompt
NSLog("[BailianDialog] 会话上下文 location=%@ date=%@ coords=%@",
spoken, day, lat.isEmpty ? "0" : "1")
}
if c.enableWebSearch {
userDefined["extra_config"] = ["enable_web_search": true]
if c.enableWebSearch || hasToken {
var userDefined: [String: Any] = [:]
if hasToken, let tok = c.mcpToken {
// ⚠️ **必须按工具名嵌套**,平铺写 `auth_token` 会被静默丢弃
for tool in c.mcpAuthTools {
userDefined[tool] = ["auth_token": tok]
}
}
if c.enableWebSearch {
userDefined["extra_config"] = ["enable_web_search": true]
}
biz["user_defined_params"] = userDefined
}
parameters["biz_params"] = ["user_defined_params": userDefined]
parameters["biz_params"] = biz
}
return [

2
apps/client/local_plugins/bes_ota_manager/ios/Classes/bessdk/connect/BesBTConnector.m

@ -334,7 +334,7 @@ typedef enum : NSUInteger {
[defaults setObject:BES_SAVE_DEFAULT_VALUE forKey:BES_SAVE_LOG_KEY];
}
if ([[defaults valueForKey:BES_SAVE_LOG_KEY] isEqual:@"YES"] && [defaults valueForKey:BES_SAVE_LOG_TITLE_KEY]) {
[FileUtils saveStringToDocumentWithName:[NSString stringWithFormat:@"%@.txt", [defaults valueForKey:BES_SAVE_LOG_TITLE_KEY]] text:text];
[BesFileUtils saveStringToDocumentWithName:[NSString stringWithFormat:@"%@.txt", [defaults valueForKey:BES_SAVE_LOG_TITLE_KEY]] text:text];
}
}

2
apps/client/local_plugins/bes_ota_manager/ios/Classes/bessdk/service/TotaDemo/BesTotaDemoService.m

@ -605,7 +605,7 @@
[defaults setObject:BES_SAVE_DEFAULT_VALUE forKey:BES_SAVE_LOG_KEY];
}
if ([[defaults valueForKey:BES_SAVE_LOG_KEY] isEqual:@"YES"] && [defaults valueForKey:BES_SAVE_LOG_TITLE_KEY]) {
[FileUtils saveStringToDocumentWithName:[NSString stringWithFormat:@"%@.txt", [defaults valueForKey:BES_SAVE_LOG_TITLE_KEY]] text:text];
[BesFileUtils saveStringToDocumentWithName:[NSString stringWithFormat:@"%@.txt", [defaults valueForKey:BES_SAVE_LOG_TITLE_KEY]] text:text];
}
}

2
apps/client/local_plugins/bes_ota_manager/ios/Classes/bessdk/utils/FileUtils.h

@ -9,7 +9,7 @@
NS_ASSUME_NONNULL_BEGIN
@interface FileUtils : NSObject
@interface BesFileUtils : NSObject
+ (NSString *)getDocumentPath;

4
apps/client/local_plugins/bes_ota_manager/ios/Classes/bessdk/utils/FileUtils.m

@ -1,5 +1,5 @@
//
// FileUtils.m
// BesFileUtils.m
// BesAll
//
// Created by 范羽 on 2021/2/25.
@ -7,7 +7,7 @@
#import "FileUtils.h"
@implementation FileUtils
@implementation BesFileUtils
+ (NSString *)getDocumentPath {
NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);

15
apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/SmartcarBleCentral.swift

@ -23,6 +23,8 @@ final class SmartcarBleCentral: NSObject {
private var central: CBCentralManager?
private var discovered: [UUID: CBPeripheral] = [:]
/// 扫描时广播里带的本地名(按设备 UUID)。设备名一律优先用它,见 didDiscover 的注释
private(set) var advertisedNames: [UUID: String] = [:]
private(set) var active: SmartcarPeripheral?
var onAdapterState: ((String) -> Void)?
@ -161,6 +163,7 @@ final class SmartcarBleCentral: NSObject {
return cur
}
let sp = SmartcarPeripheral(peripheral: p, central: central, delegate: delegate)
sp.advertisedName = advertisedNames[p.identifier]
active = sp
sp.connect()
return sp
@ -196,8 +199,16 @@ extension SmartcarBleCentral: CBCentralManagerDelegate {
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any], rssi RSSI: NSNumber) {
discovered[peripheral.identifier] = peripheral
let name = peripheral.name
?? (advertisementData[CBAdvertisementDataLocalNameKey] as? String)
// ⚠️ 先取广播本地名,再退回 peripheral.name(2026-09-26)。
// peripheral.name 是 iOS 缓存的 GAP 名:这台 iPhone 以前连过、那时固件还叫 Smartcar,
// 固件改名 EaiCar 之后缓存不会跟着更新,扫描上报的一直是旧名 → 准入白名单只认 EaiCar,
// 于是「厂商 ID、MAC 都对,iPhone 就是扫不到」,而安卓读的是广播名、一切正常。
let advName = (advertisementData[CBAdvertisementDataLocalNameKey] as? String)?
.trimmingCharacters(in: .whitespaces)
if let n = advName, !n.isEmpty { advertisedNames[peripheral.identifier] = n }
let name = (advName?.isEmpty == false ? advName : nil)
?? advertisedNames[peripheral.identifier]
?? peripheral.name
?? ""
let mfg = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
let svcs = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID])?

4
apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/SmartcarPeripheral.swift

@ -72,7 +72,9 @@ final class SmartcarPeripheral: NSObject {
private var jlFound = false
var id: String { cb.identifier.uuidString }
var name: String { cb.name ?? "" }
/// 扫描时的广播本地名。有就用它:cb.name 是 iOS 缓存的 GAP 名,固件改过名后会一直是旧名
var advertisedName: String?
var name: String { (advertisedName?.isEmpty == false ? advertisedName : nil) ?? cb.name ?? "" }
var isReady: Bool { state == .ready && cb.state == .connected }
init(peripheral: CBPeripheral, central: CBCentralManager, delegate: SmartcarPeripheralDelegate?) {

48
apps/client/local_plugins/location_service/android/build.gradle

@ -24,6 +24,52 @@ allprojects {
apply plugin: "com.android.library"
apply plugin: "kotlin-android"
// 搜索 SDK 和导航 SDK 各带一份 AMapUtilCoreApi,导航那份是超集。
// 直接 implementation search 会在打包时 Duplicate class。这里先剥掉那两个类,运行时用导航 SDK 的。
configurations {
amapSearch
}
dependencies {
amapSearch('com.amap.api:search:9.7.1')
}
def stripAmapSearchJar = tasks.register("stripAmapSearchJar") {
def cfg = configurations.amapSearch
inputs.files(cfg)
def out = layout.buildDirectory.file("amap/search-stripped.jar")
outputs.file(out)
doLast {
def jar = cfg.singleFile
def dest = out.get().asFile
dest.parentFile.mkdirs()
def drop = [
"com/amap/apis/utils/core/api/AMapUtilCoreApi.class",
"com/amap/apis/utils/core/api/NetProxy.class",
] as Set
def zin = new java.util.zip.ZipFile(jar)
dest.withOutputStream { os ->
def zout = new java.util.zip.ZipOutputStream(os)
zin.entries().each { entry ->
if (drop.contains(entry.name)) return
zout.putNextEntry(new java.util.zip.ZipEntry(entry.name))
if (!entry.isDirectory()) {
zin.getInputStream(entry).withCloseable { ins ->
byte[] buf = new byte[8192]
int n
while ((n = ins.read(buf)) > 0) {
zout.write(buf, 0, n)
}
}
}
zout.closeEntry()
}
zout.close()
}
zin.close()
}
}
android {
namespace = "com.yunqiinnovation.location_service"
@ -50,6 +96,8 @@ android {
// ✅ dependencies 应该写在这里(android {} 外面)
dependencies {
implementation('com.amap.api:navi-3dmap:latest.integration')
// 定位 SDK 在部分机型(华为无 GMS)只回坐标和市名,路名要单独走搜索 SDK 的逆地理。
implementation(files(stripAmapSearchJar))
testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("org.mockito:mockito-core:5.0.0")

100
apps/client/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/PlaceLocator.kt

@ -9,42 +9,82 @@ import android.util.Log
import androidx.core.content.ContextCompat
import com.amap.api.location.AMapLocationClient
import com.amap.api.location.AMapLocationClientOption
import com.amap.api.services.core.LatLonPoint
import com.amap.api.services.core.ServiceSettings
import com.amap.api.services.geocoder.GeocodeSearch
import com.amap.api.services.geocoder.RegeocodeQuery
/**
* 取一次「当前位置 + 省市区」,给百炼会话的 `${location}` 用(2026-09-25,车载香薰)。
* 取一次「当前位置 + 详细地址」,给百炼会话的 `${location}` 用(2026-09-25,车载香薰)。
*
* 刻意不复用 [LocationService]:那套只收 50 米内的高精度结果、超时 60 秒,是给导航/签到用的;
* 这里只要知道「在哪个城市哪个区」,网络定位就够,要的是快。
* 这里要路名和门牌,高德逆地理一次拿全,要的是快。
* `address` 优先用高德格式化地址;它为空时用省市区、街道、门牌拼一条,供上层直接念。
*
* - 没有定位权限直接回 `no_permission`,**不在这里弹权限框**(由 Dart 侧决定何时申请);
* - 高德定位自带逆地理(setNeedAddress),省/市/区一次拿全,华为无 GMS 的机器上也能用;
* - 坐标是高德的 GCJ-02,只用于城市级描述,偏移无关紧要。
* - 定位回调经常只有市名(华为 ALN-AL80 上是「深圳市」)。没有路名时,用手里的经纬度
* 再调一次高德逆地理,把格式化地址填进 `address`;
* - 坐标是高德的 GCJ-02,只放进 client_info,不放进要念出来的地址。
*/
object PlaceLocator {
/** 系统 Geocoder 反查省市区;机器不支持 / 没网 / 出错都返回 null。在后台线程调用。 */
private fun systemGeocode(context: Context, lat: Double, lng: Double): Map<String, Any?>? {
/** 定位结果里已经有路名或门牌,就不必再打一次逆地理。 */
private fun hasRoad(street: String?, streetNum: String?, address: String?): Boolean {
if (!street.isNullOrBlank() || !streetNum.isNullOrBlank()) return true
val a = address?.trim().orEmpty()
return a.contains('路') || a.contains('巷') || a.contains('号') || a.contains("大道")
}
/**
* 用定位拿到的经纬度向高德要格式化地址。
* 必须在后台线程调用:`getFromLocation` 是同步网络请求。
* 失败返回 null,调用方保留定位回调里已有的市名。
*/
private fun amapRegeocode(context: Context, lat: Double, lng: Double): Map<String, Any?>? {
return try {
if (!android.location.Geocoder.isPresent()) {
Log.w(TAG, "系统 Geocoder 不可用")
return null
}
@Suppress("DEPRECATION")
val list = android.location.Geocoder(context, java.util.Locale.CHINA).getFromLocation(lat, lng, 1)
val a = list?.firstOrNull() ?: return null
Log.i(TAG, "系统 Geocoder: 省=${a.adminArea} 市=${a.locality} 区=${a.subLocality} 地址=${a.getAddressLine(0)}")
ServiceSettings.updatePrivacyShow(context, true, true)
ServiceSettings.updatePrivacyAgree(context, true)
val search = GeocodeSearch(context)
val query = RegeocodeQuery(LatLonPoint(lat, lng), 200f, GeocodeSearch.AMAP)
val addr = search.getFromLocation(query) ?: return null
val sn = addr.streetNumber
val street = sn?.street?.trim().orEmpty()
val number = sn?.number?.trim().orEmpty()
val spoken = spokenAddress(
addr.formatAddress, addr.province, addr.city, addr.district, street, number,
)
if (spoken.isEmpty()) return null
Log.i(TAG, "高德逆地理补全: $spoken")
mapOf(
"province" to (a.adminArea ?: ""),
"city" to (a.locality ?: a.adminArea ?: ""),
"district" to (a.subLocality ?: ""),
"street" to (a.thoroughfare ?: ""),
"address" to (a.getAddressLine(0) ?: ""),
"province" to addr.province.orEmpty(),
"city" to addr.city.orEmpty().ifBlank { addr.province.orEmpty() },
"district" to addr.district.orEmpty(),
"street" to listOf(street, number).filter { it.isNotEmpty() }.joinToString(""),
"address" to spoken,
)
} catch (e: Exception) {
Log.w(TAG, "系统 Geocoder 失败: ${e.message}")
Log.w(TAG, "高德逆地理失败: ${e.message}")
null
}
}
/** 高德格式化地址优先;为空时用省市区、街道、门牌拼一条。不含经纬度。 */
private fun spokenAddress(
amapAddress: String?,
province: String?,
city: String?,
district: String?,
street: String?,
streetNum: String?,
): String {
val formatted = amapAddress?.trim().orEmpty()
if (formatted.isNotEmpty()) return formatted
return listOf(province, city, district, street, streetNum)
.map { it?.trim().orEmpty() }
.filter { it.isNotEmpty() }
.distinct()
.joinToString("")
}
private const val TAG = "PlaceLocator"
private val main = Handler(Looper.getMainLooper())
@ -59,6 +99,8 @@ object PlaceLocator {
main.post {
var done = false
var client: AMapLocationClient? = null
// 已经拿到坐标、正在补逆地理时,超时把这份粗地址交出去,而不是整次失败。
var held: Map<String, Any?>? = null
fun finish(result: Map<String, Any?>?, error: String?) {
if (done) return
done = true
@ -93,6 +135,9 @@ object PlaceLocator {
} else {
Log.i(TAG, "定位成功 type=${loc.locationType} lat=${loc.latitude} lng=${loc.longitude} " +
"省=${loc.province} 市=${loc.city} 区=${loc.district} 地址=${loc.address}")
val spoken = spokenAddress(
loc.address, loc.province, loc.city, loc.district, loc.street, loc.streetNum,
)
val base = mapOf<String, Any?>(
"latitude" to loc.latitude,
"longitude" to loc.longitude,
@ -100,21 +145,24 @@ object PlaceLocator {
"city" to loc.city,
"district" to loc.district,
"street" to loc.street,
"address" to loc.address,
"address" to spoken,
)
if (!loc.city.isNullOrBlank() || !loc.address.isNullOrBlank()) {
if (hasRoad(loc.street, loc.streetNum, loc.address)) {
finish(base, null)
} else {
// 2026-09-25 华为 ALN-AL80 实测:高德只回了坐标(type=12)、省市区全空。
// 退一步用系统 Geocoder 反查;再不行就只回坐标,由上层用坐标描述位置。
// 市名有了但没有路名(华为上常见只回「深圳市」)。
// 用同一对经纬度再向高德要一次格式化地址。
held = base
Thread {
val geo = systemGeocode(app, loc.latitude, loc.longitude)
val geo = amapRegeocode(app, loc.latitude, loc.longitude)
main.post { finish(if (geo != null) base + geo else base, null) }
}.start()
}
}
}
main.postDelayed({ finish(null, "timeout") }, timeoutMs)
main.postDelayed({
finish(held, if (held == null) "timeout" else null)
}, timeoutMs)
c.startLocation()
} catch (e: Exception) {
Log.w(TAG, "定位异常: ${e.message}")

36
apps/client/local_plugins/location_service/ios/location_service/Sources/location_service/PlaceLocator.swift

@ -1,14 +1,15 @@
import Foundation
import CoreLocation
/// 取一次「当前位置 + 省市区」,给百炼会话的 `${location}` 用(2026-09-25,车载香薰)。
/// 取一次「当前位置 + 详细地址」,给百炼会话的 `${location}` 用(2026-09-25,车载香薰)。
///
/// 刻意不复用 `LocationService`:那套带缓存/预热/权限请求,是给别的业务用的;
/// 这里只要城市级描述,要快、要可控。
/// 这里要路名和门牌,要快、要可控。
///
/// - 没有定位权限直接回 `no_permission`,**不在这里弹权限框**(由 Dart 侧决定何时申请);
/// - 逆地理用系统 `CLGeocoder`(国内是高德数据),省/市/区取 administrativeArea/locality/subLocality;
/// 直辖市 locality 可能为空,退回 administrativeArea;
/// - 逆地理用系统 `CLGeocoder`(国内是高德数据)。`address` 拼成省市区 + 路名 + 门牌,
/// 与 Android 高德 `address` 同一口径,上层直接拿来念;
/// - 直辖市 locality 可能为空,退回 administrativeArea;
/// - 逆地理失败时仍回经纬度(地名留空),经纬度本身对 client_info 也有用。
final class PlaceLocator: NSObject, CLLocationManagerDelegate {
static let shared = PlaceLocator()
@ -60,11 +61,17 @@ final class PlaceLocator: NSObject, CLLocationManagerDelegate {
DispatchQueue.main.async {
if let p = marks?.first {
let province = p.administrativeArea ?? ""
let city = (p.locality?.isEmpty == false) ? p.locality! : province
let district = p.subLocality ?? ""
let street = p.thoroughfare ?? ""
let number = p.subThoroughfare ?? ""
base["province"] = province
base["city"] = (p.locality?.isEmpty == false) ? p.locality! : province
base["district"] = p.subLocality ?? ""
base["street"] = p.thoroughfare ?? ""
base["address"] = p.name ?? ""
base["city"] = city
base["district"] = district
base["street"] = street
base["address"] = Self.spokenAddress(
province: province, city: city, district: district,
street: street, number: number)
} else if let err = err {
NSLog("[PlaceLocator] 逆地理失败: \(err.localizedDescription)")
}
@ -76,4 +83,17 @@ final class PlaceLocator: NSObject, CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
finish(nil, "location_error: \(error.localizedDescription)")
}
/// 省市区 + 路名 + 门牌,去掉重复的直辖市名。不含经纬度。
static func spokenAddress(
province: String, city: String, district: String, street: String, number: String
) -> String {
var parts: [String] = []
for raw in [province, city, district, street, number] {
let v = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if v.isEmpty || parts.contains(v) { continue }
parts.append(v)
}
return parts.joined()
}
}

5
apps/client/local_plugins/location_service/lib/location_service.dart

@ -55,11 +55,12 @@ class LocationService {
}
}
/// 取一次当前位置和省市区(2026-09-25 新增,给百炼 `${location}` 用)。
/// 取一次当前位置和详细地址(2026-09-25 新增,给百炼 `${location}` 用)。
///
/// **不申请权限**:没权限、超时、定位失败都返回 `{'error': 原因}`,由调用方决定何时申请权限。
/// 成功返回键:latitude / longitude(double)、province / city / district / street / address
/// (String,可能为空)。
/// (String,可能为空)。`address` 是路名加门牌的完整地址。Android 定位回调没有路名时,
/// 用经纬度再向高德逆地理要一次格式化地址;iOS 用省市区、路名、门牌拼成同一口径。
Future<Map<String, dynamic>> currentPlace({int timeoutMs = 15000}) async {
try {
final r = await _methodChannel.invokeMethod<Map>(

488
apps/client/pubspec.lock

File diff suppressed because it is too large

2
apps/client/pubspec.yaml

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

24
apps/client/test/bailian_native_parity_test.dart

@ -35,6 +35,30 @@ void main() {
expect(BailianMultimodalService.resolveWorkspaceId('ws-x'), 'ws-x');
});
test('EMAI 带位置和日期,未登记的应用不带', () {
final emai = BailianMultimodalService.resolveAppId();
expect(BailianMultimodalService.wantsClientContext(emai), isTrue);
expect(BailianMultimodalService.wantsClientContext('not-an-app'), isFalse);
final ctx = BailianMultimodalService.promptContext(emai);
expect(ctx, isNotNull);
expect(ctx!['location'], '未知');
expect(
ctx['date'],
matches(RegExp(r'^\d{4}年\d{1,2}月\d{1,2}日 星期[一二三四五六日] \d{2}:\d{2}$')),
);
expect(ctx.containsKey('clientLocation'), isFalse);
expect(BailianMultimodalService.promptContext('not-an-app'), isNull);
});
test('登记进 clientContextAppIds 的应用也带位置和日期', () {
const id = 'mm_test_context';
BailianMultimodalService.clientContextAppIds.add(id);
addTearDown(() => BailianMultimodalService.clientContextAppIds.remove(id));
expect(BailianMultimodalService.wantsClientContext(id), isTrue);
expect(BailianMultimodalService.promptContext(id)?['location'], '未知');
});
test('MCP 工具名只有一份,且非空', () {
// 原生不再抄一份:服务端新增需要身份的工具时只改这里。
// 漏了那个工具永远「身份校验失败」,而且客户端不报任何错。

33
apps/client/test/device_location_date_test.dart

@ -8,4 +8,37 @@ void main() {
expect(DeviceLocationService.dateText(DateTime(2026, 9, 27, 9, 0)),
'2026年9月27日 星期日 09:00');
});
test('百炼 \${location}:完整地址优先,否则省市区加街道,不带经纬度', () {
expect(
DeviceLocationService.formatPlaceText({
'province': '广东省',
'city': '深圳市',
'district': '福田区',
'street': '华强北路',
'address': '广东省深圳市福田区华强北路1002号',
'latitude': 22.5431,
'longitude': 114.0579,
}),
'广东省深圳市福田区华强北路1002号',
);
expect(
DeviceLocationService.formatPlaceText({
'province': '北京市',
'city': '北京市',
'district': '东城区',
'street': '王府井大街',
'address': '',
}),
'北京市东城区王府井大街',
);
expect(
DeviceLocationService.formatPlaceText({
'latitude': 22.54,
'longitude': 114.05,
}),
'未知',
);
expect(DeviceLocationService.formatPlaceText(null), '未知');
});
}

1
apps/services/comm/const.go

@ -45,6 +45,7 @@ const (
TableUserdevice = "userdevice" //用户设备列表
TableUserIdVerify = "useridverify" //用户实名认证记录(身份证号不落全文,只存掩码+加盐哈希)
TableUserIdVerifyLog = "useridverify_log" //实名绑定变更留痕(换人/注销),只存掩码+指纹,不存姓名
TableUserFeedback = "userfeedback" //App「用户反馈」提交记录(业务库,按应用分离)
TableRecord = "record" //记录表
TableAgent = "agent" //智能体id
TableMcp = "mcp" //Mcp服务

7
apps/services/comm/ossconf.go

@ -58,10 +58,11 @@ func (oc *AliyunOSSConf) ForDist() *AliyunOSSConf {
// AliyunOSSProvider svc_config / sys_service_config 中阿里云对象存储的 provider 值。
const AliyunOSSProvider = "aliyun_oss"
// OSSUploadRoot 所有上传对象强制置于该根前缀下(服务端兜底,调用方传什么路径都逃不出去)。
// OSSUploadRoot App 端用户上传(录音、头像)的对象根前缀。
//
// ⚠️ 改这个值只影响【之后】上传的文件:已经存进库里的历史地址是死的绝对 URL,
// 不会跟着搬家,OSS 上的旧目录也还在。要改就得连同库里那些 URL 一起改。
// 管理后台的固件和产品图不走这里:那些按产品绑定的应用名分目录
// (见 console.buildObjectKey)。改这个值只影响之后的 App 上传,
// 已经存进库里的历史地址是绝对 URL,不会跟着搬家。
const OSSUploadRoot = "EAIMAR"
// LoadAliyunOSSConf 读取阿里云 OSS 有效配置。

25
apps/services/lego/sys/wordfilter/embed.go

@ -0,0 +1,25 @@
package wordfilter
import (
_ "embed"
"strings"
)
// 内置词库随二进制打进 home。部署配置里的 WorldFile 默认全部注释,
// 不内置的话改昵称时词树是空的,Validate 恒通过。
//
//go:embed wordfilter.txt
var builtinWordDict string
func (s *Sys) loadBuiltin() (int, error) {
n := 0
for _, line := range strings.Split(builtinWordDict, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
s.trie.Add(line)
n++
}
return n, nil
}

11
apps/services/lego/sys/wordfilter/sys.go

@ -2,7 +2,6 @@ package wordfilter
import (
"bufio"
"yunyan/lego/sys/log"
"encoding/json"
"fmt"
"io"
@ -13,6 +12,7 @@ import (
"regexp"
"strings"
"time"
"yunyan/lego/sys/log"
)
func newSys(options *Options) (sys *Sys, err error) {
@ -21,6 +21,15 @@ func newSys(options *Options) (sys *Sys, err error) {
trie: NewTrie(),
noise: regexp.MustCompile(`[\|\s&%$@*]+`),
}
// 内置词库始终加载。WorldFile 只作追加;配了但文件不存在仍按原逻辑返回错误,
// 由 home 启动处 panic,避免「列表里写了路径、服务器上没有文件」时静默漏拦。
var n int
if n, err = sys.loadBuiltin(); err != nil {
return
}
if options.Log != nil {
options.Log.Infof("wordfilter: 已加载内置敏感词 %d 条", n)
}
if len(options.WorldFile) > 0 {
for _, v := range options.WorldFile {
ext := filepath.Ext(v)

25
apps/services/lego/sys/wordfilter/sys_test.go

@ -1,9 +1,10 @@
package wordfilter_test
import (
"yunyan/lego/sys/wordfilter"
"fmt"
"testing"
"yunyan/lego/sys/wordfilter"
)
// 国内
@ -15,3 +16,25 @@ func Test_sys_wordfilter(t *testing.T) {
fmt.Println(ok, str)
}
}
// 内置词库不依赖部署目录里的 txt。改名「售卖海洛因」必须被拦住。
func TestBuiltinDictRejectsDrugTradeName(t *testing.T) {
sys, err := wordfilter.NewSys()
if err != nil {
t.Fatal(err)
}
ok, hit := sys.Validate("售卖海洛因")
if ok {
t.Fatal("售卖海洛因 应被内置词库拒绝")
}
if hit != "海洛因" {
t.Fatalf("命中词 = %q,期望 海洛因", hit)
}
// 空格会被当成噪声去掉,插空格绕不过去。
if ok, _ = sys.Validate("售卖 海洛因"); ok {
t.Fatal("插空格后仍应拒绝")
}
if ok, hit = sys.Validate("小明"); !ok {
t.Fatalf("普通昵称不应命中,得到 %q", hit)
}
}

116
apps/services/modules/console/api_feedback.go

@ -0,0 +1,116 @@
package console
import (
"strings"
"yunyan/comm"
"yunyan/pb"
"github.com/gin-gonic/gin"
)
// userFeedbackListReq 用户反馈列表筛选。
//
// User 一栏多用:UID 精确,手机号 / 邮箱 / 用户名 / 登录账号模糊。
// 客服转来的线索形态不定,让人先选「按什么查」只是多一步。
// Content 只搜反馈正文。时间区间是 [start, end),结束日由前端按当天 24:00 换算。
type userFeedbackListReq struct {
User string `json:"user"`
Content string `json:"content"`
Start int64 `json:"start"`
End int64 `json:"end"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// userFeedbackItem 列表行。用户字段来自提交时仍在库里的 user 行;
// 账号已注销删掉时这几列为空,uid 仍在。
type userFeedbackItem struct {
Id uint64 `json:"id" gorm:"column:id"`
Uid string `json:"uid" gorm:"column:uid"`
Message string `json:"message" gorm:"column:message"`
Version string `json:"version" gorm:"column:version"`
Phonemodel string `json:"phonemodel" gorm:"column:phonemodel"`
Channel int32 `json:"channel" gorm:"column:channel"`
Createtime int64 `json:"createtime" gorm:"column:createtime"`
Name string `json:"name" gorm:"column:name"`
Phone string `json:"phone" gorm:"column:phone"`
Mail string `json:"mail" gorm:"column:mail"`
Account string `json:"account" gorm:"column:account"`
}
// buildUserFeedbackWhere 拼筛选条件。表别名 f = userfeedback,u = user。
func buildUserFeedbackWhere(req *userFeedbackListReq) (string, []interface{}) {
conds := make([]string, 0, 4)
args := make([]interface{}, 0, 8)
if kw := strings.TrimSpace(req.User); kw != "" {
like := "%" + kw + "%"
conds = append(conds, "(f.uid = ? OR u.phone LIKE ? OR u.mail LIKE ? OR u.name LIKE ? OR u.account LIKE ?)")
args = append(args, kw, like, like, like, like)
}
if kw := strings.TrimSpace(req.Content); kw != "" {
conds = append(conds, "f.message LIKE ?")
args = append(args, "%"+kw+"%")
}
if req.Start > 0 {
conds = append(conds, "f.createtime >= ?")
args = append(args, req.Start)
}
if req.End > 0 {
conds = append(conds, "f.createtime < ?")
args = append(args, req.End)
}
if len(conds) == 0 {
return "1=1", args
}
return strings.Join(conds, " AND "), args
}
// getUserFeedbacks 所选应用业务库里的用户反馈,按提交时间倒序。
func (this *serverComp) getUserFeedbacks(c *gin.Context) {
appId := parseAppId(c.GetHeader("X-App-Id"))
if !this.requireAppScope(c, appId) {
return
}
conn, err := this.module.registry.getServiceDB(appId)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
req := &userFeedbackListReq{}
_ = c.ShouldBindJSON(req)
if req.PageSize <= 0 {
req.PageSize = 20
}
if req.PageSize > userListPageMax {
req.PageSize = userListPageMax
}
if req.Page < 0 {
req.Page = 0
}
where, args := buildUserFeedbackWhere(req)
base := conn.Table(comm.TableUserFeedback+" AS f").
Joins("LEFT JOIN `"+comm.TableUser+"` AS u ON u.uid = f.uid").
Where(where, args...)
var total int64
if err := base.Count(&total).Error; err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
rows := make([]*userFeedbackItem, 0, req.PageSize)
if total > 0 {
// Count 已经用过这条链,再查要重新起一条,否则 Limit 会叠在已执行的语句上。
if err := conn.Table(comm.TableUserFeedback+" AS f").
Select("f.id, f.uid, f.message, f.version, f.phonemodel, f.channel, f.createtime, u.name, u.phone, u.mail, u.account").
Joins("LEFT JOIN `"+comm.TableUser+"` AS u ON u.uid = f.uid").
Where(where, args...).
Order("f.createtime DESC, f.id DESC").
Limit(req.PageSize).Offset(req.Page * req.PageSize).
Find(&rows).Error; err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
}
writeOK(c, gin.H{"items": rows, "total": total, "page": req.Page, "page_size": req.PageSize})
}

28
apps/services/modules/console/api_feedback_test.go

@ -0,0 +1,28 @@
package console
import (
"strings"
"testing"
)
func TestBuildUserFeedbackWhere(t *testing.T) {
where, args := buildUserFeedbackWhere(&userFeedbackListReq{})
if where != "1=1" || len(args) != 0 {
t.Fatalf("empty: where %q args %v", where, args)
}
where, args = buildUserFeedbackWhere(&userFeedbackListReq{
User: "138", Content: "耳机", Start: 10, End: 20,
})
for _, sub := range []string{"f.uid = ?", "u.phone LIKE ?", "f.message LIKE ?", "f.createtime >= ?", "f.createtime < ?"} {
if !strings.Contains(where, sub) {
t.Fatalf("where %q missing %q", where, sub)
}
}
if len(args) != 8 {
t.Fatalf("args len = %d, want 8: %v", len(args), args)
}
if args[0] != "138" || args[1] != "%138%" || args[5] != "%耳机%" || args[6] != int64(10) || args[7] != int64(20) {
t.Fatalf("args = %v", args)
}
}

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

@ -6,16 +6,20 @@ package console
// 1. OSS 凭证来自【第三方服务配置(svc_config) → 存储 → 阿里云 OSS】(provider=aliyun_oss),
// 即 serviceconfig.vue 里落库、并按应用分离(app_name='' 为全局默认)的那份配置。
// ——不再走腾讯云 COS / 也不读那份纯演示未落库的「系统配置」页。
// 2. object key 由服务端强制置于 uploadRoot("EAIMAR") 根前缀下,形如
// EAIMAR/product/<产品id>/<文件名>,并做防路径穿越清洗。
// 2. 固件 / 产品图的 object key 以「产品绑定的应用名」为根,形如
// <应用名>/product/<产品id>/<文件名>。应用名取自产品的 appnames,
// 不写死、也不跟登录态里的 X-App-Id。并做防路径穿越清洗。
// 3. 预签名 PUT 会把 Content-Type 计入签名,故前端 PUT 必须带同样的 Content-Type(uploadFile 已对齐)。
import (
"errors"
"fmt"
"path"
"strconv"
"strings"
"yunyan/comm"
"yunyan/lego/sys/postgres"
"yunyan/pb"
"yunyan/sys/aliyun/oss"
@ -26,9 +30,6 @@ import (
// storage_aliyun_oss 模板 provider 一致)。
const ossStorageProvider = comm.AliyunOSSProvider
// uploadRoot 定义已抽到 comm.OSSUploadRoot,与 App 上传(user_getuploadurl)共用。
const uploadRoot = comm.OSSUploadRoot
// aliyunOSSConf 定义已抽到 comm,供 console 后台直传与 App 上传(user_getuploadurl)
// 共用同一份读取逻辑——两边必须落在同一个桶里,否则后台传的固件和 App 传的录音会分家。
type aliyunOSSConf = comm.AliyunOSSConf
@ -38,23 +39,135 @@ func (this *serverComp) loadAliyunOSSConf(appName string) (*aliyunOSSConf, error
return comm.LoadAliyunOSSConf(appName, this.options.EncryptKey)
}
// buildObjectKey 生成对象存储 key,强制置于 uploadRoot 根下并做防路径穿越清洗。
// buildObjectKey 生成主桶对象 key:<应用名>/<目录>/<文件名>。
//
// dir 调用方给的逻辑目录(如 "product/12/"),可空。
// app 产品绑定的应用名(如 deepGlass),先经 distAppSeg 清洗。
// dir 调用方给的逻辑目录(如 "product/47632/")。
// name 文件名(取 base 去掉任何目录部分)。
//
// 例:buildObjectKey("product/12/", "fw_v1.bin") => "EAIMAR/product/12/fw_v1.bin"
func buildObjectKey(dir, name string) string {
name = path.Base(strings.TrimSpace(name)) // 去掉任何目录部分,防止 name 里夹带 ../
// 例:buildObjectKey("deepGlass", "product/47632/", "fw.bin")
//
// => "deepGlass/product/47632/fw.bin"
func buildObjectKey(app, dir, name string) (string, error) {
seg := distAppSeg(app)
if seg == "" {
return "", fmt.Errorf("上传必须指定应用")
}
dir = strings.Trim(path.Clean("/"+strings.TrimSpace(dir)), "/")
if dir == "" || dir == "." || strings.HasPrefix(dir, "..") {
return "", fmt.Errorf("上传目录无效")
}
name = path.Base(strings.TrimSpace(name))
if name == "" || name == "." || name == "/" {
name = "file"
return "", fmt.Errorf("文件名无效")
}
key := path.Clean(path.Join(uploadRoot, dir, name)) // Join+Clean 合并 //、解析 ..
// 兜底:若 Clean 后逃逸出根前缀(dir 里塞了过量 ../),强制退回根下只保留文件名。
if key != uploadRoot && !strings.HasPrefix(key, uploadRoot+"/") {
key = uploadRoot + "/" + name
key := path.Join(seg, dir, name)
if !strings.HasPrefix(key, seg+"/") {
return "", fmt.Errorf("上传目录无效")
}
return key
return key, nil
}
// parseMainUploadDir 清洗主桶目录,并认出 product/<产品id>/… 里的产品 id。
//
// 没有产品 id 的只有「新建产品、还没保存」时的产品图:product/images/。
// 其它目录一律拒绝——主桶这条入口现在只给固件和产品图。
func parseMainUploadDir(dir string) (clean string, productID uint32, err error) {
clean = strings.Trim(path.Clean("/"+strings.TrimSpace(dir)), "/")
if clean == "" || clean == "." || strings.HasPrefix(clean, "..") {
return "", 0, fmt.Errorf("上传目录无效")
}
parts := strings.Split(clean, "/")
if parts[0] != "product" {
return "", 0, fmt.Errorf("不支持的上传目录: %s", parts[0])
}
if len(parts) < 2 {
return "", 0, fmt.Errorf("上传目录无效")
}
if parts[1] == "images" {
return clean, 0, nil
}
id, nerr := strconv.ParseUint(parts[1], 10, 32)
if nerr != nil || id == 0 {
return "", 0, fmt.Errorf("产品目录无效")
}
return clean, uint32(id), nil
}
func splitAppNames(csv string) []string {
parts := strings.Split(csv, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
// pickBoundApp 从产品已绑定的应用里挑上传目录用的那一个。
//
// requested 非空时必须真在绑定列表里,避免前端把 deepGlass 的固件写进别的应用目录。
// 没指定时:只绑了一个就用它;一个都没有、或绑了多个却不说用哪个,都拒绝。
func pickBoundApp(bound []string, requested string) (string, error) {
req := strings.TrimSpace(requested)
if req != "" {
for _, a := range bound {
if a == req {
return a, nil
}
}
return "", fmt.Errorf("该产品未绑定应用 %s", req)
}
switch len(bound) {
case 0:
return "", fmt.Errorf("产品未绑定应用,请先绑定后再上传")
case 1:
return bound[0], nil
default:
return "", fmt.Errorf("该产品绑定了多个应用,上传时必须指定应用")
}
}
// resolveMainUploadApp 决定固件/产品图放在哪个应用目录下。
//
// 已有产品:读产品行上的 appnames(现查库,不走缓存,刚改的绑定马上生效)。
// 还没保存的新产品:目录是 product/images/,应用名必须是应用中心里真实存在的名字。
func (this *serverComp) resolveMainUploadApp(dir, requested string) (app, cleanDir string, err error) {
cleanDir, pid, err := parseMainUploadDir(dir)
if err != nil {
return "", "", err
}
if pid > 0 {
p, e := dvProduct(consoleDeviceConn(), pid)
if e != nil {
if errors.Is(e, postgres.ErrNoDocuments) {
return "", "", fmt.Errorf("产品不存在")
}
return "", "", e
}
if p == nil || p.Id == 0 {
return "", "", fmt.Errorf("产品不存在")
}
app, err = pickBoundApp(splitAppNames(p.Appnames), requested)
return app, cleanDir, err
}
req := strings.TrimSpace(requested)
if req == "" {
return "", "", fmt.Errorf("请先绑定应用,再上传产品图")
}
exist, e := this.module.model.getProductByName(req)
if e != nil {
if errors.Is(e, postgres.ErrNoDocuments) {
return "", "", fmt.Errorf("应用不存在: %s", req)
}
return "", "", e
}
if exist == nil || exist.Id == 0 || exist.Name == "" {
return "", "", fmt.Errorf("应用不存在: %s", req)
}
return exist.Name, cleanDir, nil
}
// distContentTypes 分发桶里按扩展名强制的 Content-Type。
@ -135,13 +248,10 @@ func (this *serverComp) getUploadURL(c *gin.Context) {
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 两种用法:
// - target=dist:页面上选中的应用(安装包目录)。没有 X-App-Id 时用它补作用域。
// - 主桶(固件/产品图):产品绑了多个应用时,点名文件放进哪一个应用的目录。
// 必须是该产品 appnames 里的名字;登录态的 X-App-Id 不参与这段路径。
AppName string `json:"app_name"`
}
_ = c.ShouldBindJSON(&req)
@ -163,20 +273,38 @@ func (this *serverComp) getUploadURL(c *gin.Context) {
}
}
}
conf, err := this.loadAliyunOSSConf(appName)
if err != nil {
writeErr(c, pb.ErrorCode_SystemError, err.Error())
return
}
var conf *aliyunOSSConf
var key string
var err error
if strings.EqualFold(strings.TrimSpace(req.Target), "dist") {
conf, err = this.loadAliyunOSSConf(appName)
if err != nil {
writeErr(c, pb.ErrorCode_SystemError, err.Error())
return
}
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)
// 目录根取产品绑定的应用名,不看登录态里的 X-App-Id。
// 请求体 app_name 只在产品绑了多个应用时用来点名用哪一个,
// 而且这个名字必须真在绑定列表里。
uploadApp, dir, rerr := this.resolveMainUploadApp(req.Path, req.AppName)
if rerr != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, rerr.Error())
return
}
conf, err = this.loadAliyunOSSConf(uploadApp)
if err != nil {
writeErr(c, pb.ErrorCode_SystemError, err.Error())
return
}
if key, err = buildObjectKey(uploadApp, dir, req.Filename); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
}
presigner, err := oss.NewPresigner(conf.Endpoint, conf.AccessKeyId, conf.AccessSecret, conf.Bucket)
if err != nil {

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

@ -47,6 +47,84 @@ func TestBuildDistObjectKey(t *testing.T) {
}
// apk 的 Content-Type 必须由服务端强制:它计入预签名,签错了要等真机装包才发现。
func TestBuildObjectKey(t *testing.T) {
ok := map[string][3]string{
"固件": {"deepGlass", "product/47632/", "fw.bin"},
"产品图": {"deepGlass", "product/47632/images/", "a.png"},
"未保存的产品图": {"EAIMAR", "product/images/", "a.png"},
"目录带斜杠": {"deepGlass", "/product/47632/", "fw.bin"},
"文件名夹带目录": {"deepGlass", "product/47632/", "../../etc/passwd"},
"应用名带斜杠": {"a/../b", "product/1/", "fw.bin"},
}
want := map[string]string{
"固件": "deepGlass/product/47632/fw.bin",
"产品图": "deepGlass/product/47632/images/a.png",
"未保存的产品图": "EAIMAR/product/images/a.png",
"目录带斜杠": "deepGlass/product/47632/fw.bin",
"文件名夹带目录": "deepGlass/product/47632/passwd",
"应用名带斜杠": "a____b/product/1/fw.bin",
}
for name, in := range ok {
got, err := buildObjectKey(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)
}
}
if _, err := buildObjectKey("", "product/1/", "a.bin"); err == nil {
t.Error("没指定应用本该被拒")
}
// .. 会被 path.Clean 吃掉,文件仍留在应用目录下,不会逃到桶根。
got, err := buildObjectKey("deepGlass", "../secret", "a.bin")
if err != nil || got != "deepGlass/secret/a.bin" {
t.Fatalf("目录里的 .. 应被折回应用目录下,得到 %q err=%v", got, err)
}
}
func TestParseMainUploadDir(t *testing.T) {
dir, id, err := parseMainUploadDir("product/47632/")
if err != nil || dir != "product/47632" || id != 47632 {
t.Fatalf("固件目录: dir=%q id=%d err=%v", dir, id, err)
}
dir, id, err = parseMainUploadDir("product/47632/images/")
if err != nil || dir != "product/47632/images" || id != 47632 {
t.Fatalf("产品图目录: dir=%q id=%d err=%v", dir, id, err)
}
dir, id, err = parseMainUploadDir("product/images/")
if err != nil || dir != "product/images" || id != 0 {
t.Fatalf("未保存产品图: dir=%q id=%d err=%v", dir, id, err)
}
if _, _, err = parseMainUploadDir("android"); err == nil {
t.Fatal("非 product 目录本该被拒")
}
if _, _, err = parseMainUploadDir("product/0/"); err == nil {
t.Fatal("产品 id 为 0 本该被拒")
}
}
func TestPickBoundApp(t *testing.T) {
got, err := pickBoundApp([]string{"deepGlass"}, "")
if err != nil || got != "deepGlass" {
t.Fatalf("只绑一个: got=%q err=%v", got, err)
}
got, err = pickBoundApp([]string{"EAIMAR", "deepGlass"}, "deepGlass")
if err != nil || got != "deepGlass" {
t.Fatalf("点名已绑定的应用: got=%q err=%v", got, err)
}
if _, err = pickBoundApp([]string{"deepGlass"}, "EAIMAR"); err == nil {
t.Fatal("点名未绑定的应用本该被拒")
}
if _, err = pickBoundApp(nil, ""); err == nil {
t.Fatal("未绑定本该被拒")
}
if _, err = pickBoundApp([]string{"EAIMAR", "deepGlass"}, ""); err == nil {
t.Fatal("绑了多个却不指定本该被拒")
}
}
func TestDistContentTypeForced(t *testing.T) {
if distContentTypes[".apk"] != "application/vnd.android.package-archive" {
t.Fatalf("apk 的 Content-Type 不对: %q", distContentTypes[".apk"])

4
apps/services/modules/console/server.go

@ -453,6 +453,10 @@ func (this *serverComp) handleWeb(c *gin.Context) {
case "api_getusers":
this.getUsers(c)
return
// 用户反馈:App「我的 → 用户反馈」写入业务库 userfeedback,按选中应用查询。
case "api_getuserfeedbacks":
this.getUserFeedbacks(c)
return
// 修改用户可运营参数(状态/VIP/积分余额)。仅超管/管理员——代理发资源必须走赠送那条扣余额的路。
case "api_updateuser":
this.updateUser(c)

35
apps/services/modules/user/api_feedback.go

@ -1,10 +1,30 @@
package user
import (
"strings"
"unicode/utf8"
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
)
// feedbackMessageMax 与客户端输入框上限(500)之间留出余量。
// 超限直接拒,不截断——截断后用户以为整段都交上去了。
const feedbackMessageMax = 2000
// normalizeFeedbackMessage 去掉首尾空白并校验长度。第二个返回值非空表示拒绝原因。
func normalizeFeedbackMessage(s string) (string, string) {
s = strings.TrimSpace(s)
if s == "" {
return "", "请填写反馈内容"
}
if utf8.RuneCountInString(s) > feedbackMessageMax {
return "", "反馈内容过长"
}
return s, ""
}
// @Summary 用户反馈
// @Description 用户反馈
// @Tags User
@ -16,5 +36,20 @@ import (
// @Router /api/home/user_feedback [post]
func (this *apiComp) Feedback(session comm.IUserSession, req *pb.UserFeedbackReq) (resp *pb.UserFeedbackResp, errdata *pb.ErrorData) {
resp = &pb.UserFeedbackResp{}
uid := session.GetUserId()
if uid == "" {
errdata = &pb.ErrorData{Code: pb.ErrorCode_NoLogin, Message: pb.ErrorCode_NoLogin.String()}
return
}
message, reason := normalizeFeedbackMessage(req.GetMessage())
if reason != "" {
errdata = &pb.ErrorData{Code: pb.ErrorCode_ReqParameterError, Message: reason}
return
}
if err := this.module.feedbackmodel.insert(uid, message); err != nil {
this.module.Error("用户反馈写入失败", log.Field{Key: "uid", Value: uid}, log.Field{Key: "err", Value: err.Error()})
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: pb.ErrorCode_DBError.String()}
return
}
return
}

23
apps/services/modules/user/api_feedback_test.go

@ -0,0 +1,23 @@
package user
import (
"strings"
"testing"
)
func TestNormalizeFeedbackMessage(t *testing.T) {
if got, reason := normalizeFeedbackMessage(" 耳机连不上 "); reason != "" || got != "耳机连不上" {
t.Fatalf("trim: got %q reason %q", got, reason)
}
if _, reason := normalizeFeedbackMessage(" "); reason == "" {
t.Fatal("blank should be rejected")
}
long := strings.Repeat("字", feedbackMessageMax+1)
if _, reason := normalizeFeedbackMessage(long); reason == "" {
t.Fatal("overlong should be rejected")
}
edge := strings.Repeat("a", feedbackMessageMax)
if got, reason := normalizeFeedbackMessage(edge); reason != "" || got != edge {
t.Fatalf("max length should pass, reason %q len %d", reason, len(got))
}
}

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

@ -42,7 +42,7 @@ func (this *apiComp) Setting(session comm.IUserSession, req *pb.UserSettingReq)
if ok, _ = wordfilter.Validate(req.Name); !ok {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_NameInscriptionWords,
Message: pb.ErrorCode_NameInscriptionWords.String(),
Message: "名称包含敏感词",
}
return
}

20
apps/services/modules/user/api_sgin.go

@ -4,6 +4,7 @@ import (
"context"
"yunyan/comm"
"yunyan/lego/sys/mysql"
"yunyan/lego/sys/wordfilter"
"yunyan/lego/utils/container/id"
"yunyan/pb"
apple_auth "yunyan/sys/auth/apple"
@ -233,9 +234,7 @@ func (this *apiComp) Sgin(session comm.IUserSession, req *pb.UserSginReq) (resp
// if user.Mail != "" {
// user.Mail = aes.AesEncryptCBC(user.Mail, this.options.CBCKey)
// }
if user.Name == "" {
user.Name = fmt.Sprintf("User_%d", rand.Intn(10000))
}
user.Name = cleanDisplayName(user.Name)
// user.Name = aes.AesEncryptCBC(user.Name, this.options.CBCKey)
// if user.Avatar != "" {
@ -281,9 +280,7 @@ func (this *apiComp) Sgin(session comm.IUserSession, req *pb.UserSginReq) (resp
case pb.SginTyoe_Apple:
user.Appleopenid = apple_uuid
}
if user.Name == "" {
user.Name = fmt.Sprintf("User_%d", rand.Intn(10000))
}
user.Name = cleanDisplayName(user.Name)
}
if istestaccount {
user.Vipexptime = time.Now().AddDate(1, 0, 0).Unix()
@ -346,3 +343,14 @@ func (this *apiComp) Sgin(session comm.IUserSession, req *pb.UserSginReq) (resp
}
return
}
// cleanDisplayName 去掉空名和敏感词。登录不能因为微信昵称不干净就拒绝建号,
// 换成 User_xxxx,之后可以在资料页再改一个干净的。
func cleanDisplayName(name string) string {
if name != "" {
if ok, _ := wordfilter.Validate(name); ok {
return name
}
}
return fmt.Sprintf("User_%d", rand.Intn(10000))
}

92
apps/services/modules/user/model_feedback.go

@ -0,0 +1,92 @@
package user
import (
"errors"
"time"
"yunyan/comm"
"yunyan/lego/core"
"yunyan/lego/core/cbase"
"yunyan/lego/sys/log"
"yunyan/lego/sys/mysql"
)
// dbUserFeedback App 用户反馈。本地 gorm 结构,不进 pb:
// 客户端只提交、不回读;后台列表由 console 直连业务库查询。
//
// version / phonemodel / channel 是提交当时从 user 行抄下来的快照。
// 用户之后升级或换机,这条记录仍停在提交时的环境上,排查「哪一版出的问题」靠它。
type dbUserFeedback struct {
Id uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Uid string `gorm:"column:uid;size:50;index" json:"uid"`
Message string `gorm:"column:message;size:2000" json:"message"`
Version string `gorm:"column:version;size:32" json:"version"`
Phonemodel string `gorm:"column:phonemodel;size:64" json:"phonemodel"`
Channel int32 `gorm:"column:channel" json:"channel"`
Createtime int64 `gorm:"column:createtime;index" json:"createtime"`
}
type modelFeedbackComp struct {
cbase.ModuleCompBase
module *User
}
func (this *modelFeedbackComp) 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.(*User)
if err = mysql.CreateTable(comm.TableUserFeedback, &dbUserFeedback{}); err != nil {
this.module.Errorln(err)
}
// 后台列表按手机号/昵称筛时要 JOIN user。user 表多半是老库的 utf8mb4_general_ci,
// 新表按服务器默认建会是 utf8mb4_0900_ai_ci,联表直接 1267。对齐后再查。
this.alignCollation()
return
}
func (this *modelFeedbackComp) alignCollation() {
target := this.tableCollation(comm.TableUser)
charset, ok := collationCharset(target)
if !ok {
this.module.Warn("用户反馈表排序规则对齐:user 表排序规则读不到或不合法,跳过", log.Field{Key: "collation", Value: target})
return
}
cur := this.tableCollation(comm.TableUserFeedback)
if cur == "" || cur == target {
return
}
if res := mysql.Exec("ALTER TABLE `" + comm.TableUserFeedback + "` CONVERT TO CHARACTER SET " + charset + " COLLATE " + target); res.Error != nil {
this.module.Error("用户反馈表排序规则对齐失败", log.Field{Key: "from", Value: cur},
log.Field{Key: "to", Value: target}, log.Field{Key: "err", Value: res.Error.Error()})
return
}
this.module.Infof("用户反馈表排序规则对齐:%s → %s", cur, target)
}
func (this *modelFeedbackComp) tableCollation(table string) string {
var coll string
res := mysql.Raw("SELECT TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?", table).Scan(&coll)
if res.Error != nil {
this.module.Warn("读表排序规则失败", log.Field{Key: "table", Value: table}, log.Field{Key: "err", Value: res.Error.Error()})
return ""
}
return coll
}
// insert 写入一条反馈。用户资料读不到时快照留空,内容照样落库——
// 反馈本身比「附带的版本号」重要,不能因为查用户失败就把内容丢掉。
func (this *modelFeedbackComp) insert(uid, message string) error {
row := &dbUserFeedback{
Uid: uid,
Message: message,
Createtime: time.Now().Unix(),
}
if user, err := this.module.model.findforid(uid); err == nil && user != nil {
row.Version = user.Version
row.Phonemodel = user.Phonemodel
row.Channel = int32(user.Channel)
} else if err != nil && !errors.Is(err, mysql.ErrNoDocuments) {
this.module.Warn("用户反馈:读用户快照失败,仍写入内容",
log.Field{Key: "uid", Value: uid}, log.Field{Key: "err", Value: err.Error()})
}
return mysql.Insert(comm.TableUserFeedback, row)
}

24
apps/services/modules/user/module.go

@ -21,18 +21,19 @@ func NewModule() core.IModule {
type User struct {
modules.ModuleBase
service base.IRPCXService //rpcx服务接口 主要client->server
api *apiComp
apiv2 *apiV2Comp
apiv3 *apiV3Comp
model *modelUserComp
configmodel *modelConfigComp
idverifymodel *modelIdVerifyComp //实名认证(身份证二要素核验)
service base.IRPCXService //rpcx服务接口 主要client->server
api *apiComp
apiv2 *apiV2Comp
apiv3 *apiV3Comp
model *modelUserComp
configmodel *modelConfigComp
idverifymodel *modelIdVerifyComp //实名认证(身份证二要素核验)
feedbackmodel *modelFeedbackComp //用户反馈(App「我的 → 用户反馈」落库)
translatemodel *modelTranslateComp //实时机器翻译(走第三方服务配置里的 MT 服务)
cache *modelCacheComp
options *Options
analyze comm.IAnalyze
echomeet comm.IEchomeet // 会议记录能力(同进程 echomeet 模块提供,v3 配置下发用;可能为 nil)
cache *modelCacheComp
options *Options
analyze comm.IAnalyze
echomeet comm.IEchomeet // 会议记录能力(同进程 echomeet 模块提供,v3 配置下发用;可能为 nil)
}
func (this *User) GetType() core.M_Modules {
@ -68,5 +69,6 @@ func (this *User) OnInstallComp() {
this.cache = this.RegisterComp(new(modelCacheComp)).(*modelCacheComp)
this.configmodel = this.RegisterComp(new(modelConfigComp)).(*modelConfigComp)
this.idverifymodel = this.RegisterComp(new(modelIdVerifyComp)).(*modelIdVerifyComp)
this.feedbackmodel = this.RegisterComp(new(modelFeedbackComp)).(*modelFeedbackComp)
this.translatemodel = this.RegisterComp(new(modelTranslateComp)).(*modelTranslateComp)
}

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

@ -18,7 +18,8 @@ sys:
Name: "home"
MaxReconnects: -1
ReconnectWait: 2
# 敏感词过滤:默认【全部注释掉 = 不加载任何词库】。
# 敏感词过滤:内置词库打在 home 二进制里,改昵称命中即拒,不依赖下面的文件。
# WorldFile 是额外词库,默认全部注释。
# ⚠️ 这些 txt 不在 git 里,要用就得自己放到部署目录的 wordfilter/ 下(会随工作目录挂进容器 /app)。
# 列表里只要有一个文件在服务器上不存在,home 就会 `panic: init sys.wordfilter err: no found file:...`
# → entrypoint 杀掉整个容器 → 无限重启;而表象常常是 api 报「Table 'xxx.userdevice' doesn't exist」

Loading…
Cancel
Save