Compare commits

...

7 Commits

Author SHA1 Message Date
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
  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. 22
      apps/client/ios/Runner.xcodeproj/project.pbxproj
  6. 119
      apps/client/ios/Runner/AppDelegate.swift
  7. 2
      apps/client/ios/Runner/Info.plist
  8. 2
      apps/client/lib/modules/eaicar/bindings/eaicar_chat_binding.dart
  9. 135
      apps/client/lib/modules/emai/controllers/emai_controller.dart
  10. 31
      apps/client/lib/modules/meeting/views/tabs/summary_tab.dart
  11. 2
      apps/client/local_plugins/bes_ota_manager/ios/Classes/bessdk/connect/BesBTConnector.m
  12. 2
      apps/client/local_plugins/bes_ota_manager/ios/Classes/bessdk/service/TotaDemo/BesTotaDemoService.m
  13. 2
      apps/client/local_plugins/bes_ota_manager/ios/Classes/bessdk/utils/FileUtils.h
  14. 4
      apps/client/local_plugins/bes_ota_manager/ios/Classes/bessdk/utils/FileUtils.m
  15. 15
      apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/SmartcarBleCentral.swift
  16. 4
      apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/SmartcarPeripheral.swift
  17. 2
      apps/client/pubspec.yaml
  18. 1
      apps/services/comm/const.go
  19. 7
      apps/services/comm/ossconf.go
  20. 25
      apps/services/lego/sys/wordfilter/embed.go
  21. 11
      apps/services/lego/sys/wordfilter/sys.go
  22. 25
      apps/services/lego/sys/wordfilter/sys_test.go
  23. 116
      apps/services/modules/console/api_feedback.go
  24. 28
      apps/services/modules/console/api_feedback_test.go
  25. 182
      apps/services/modules/console/api_upload.go
  26. 78
      apps/services/modules/console/api_upload_test.go
  27. 4
      apps/services/modules/console/server.go
  28. 35
      apps/services/modules/user/api_feedback.go
  29. 23
      apps/services/modules/user/api_feedback_test.go
  30. 2
      apps/services/modules/user/api_setting.go
  31. 20
      apps/services/modules/user/api_sgin.go
  32. 92
      apps/services/modules/user/model_feedback.go
  33. 2
      apps/services/modules/user/module.go
  34. 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' },
],
},
{

22
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";
@ -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 = 24;
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 = 24;
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 = 24;
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 */

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>

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,
);

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;
// 网络抖一下就掉的情况很常见,自动补一次;再断才让用户手动点

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)

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?) {

2
apps/client/pubspec.yaml

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

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

182
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.Join(seg, dir, name)
if !strings.HasPrefix(key, seg+"/") {
return "", fmt.Errorf("上传目录无效")
}
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("上传目录无效")
}
key := path.Clean(path.Join(uploadRoot, dir, name)) // Join+Clean 合并 //、解析 ..
// 兜底:若 Clean 后逃逸出根前缀(dir 里塞了过量 ../),强制退回根下只保留文件名。
if key != uploadRoot && !strings.HasPrefix(key, uploadRoot+"/") {
key = uploadRoot + "/" + name
parts := strings.Split(clean, "/")
if parts[0] != "product" {
return "", 0, fmt.Errorf("不支持的上传目录: %s", parts[0])
}
return key
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)
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
}
var key string
if strings.EqualFold(strings.TrimSpace(req.Target), "dist") {
conf = conf.ForDist()
if key, err = buildDistObjectKey(appName, req.Path, req.Filename); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
} else {
key = buildObjectKey(req.Path, req.Filename)
// 目录根取产品绑定的应用名,不看登录态里的 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)
}

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

@ -28,6 +28,7 @@ type User struct {
model *modelUserComp
configmodel *modelConfigComp
idverifymodel *modelIdVerifyComp //实名认证(身份证二要素核验)
feedbackmodel *modelFeedbackComp //用户反馈(App「我的 → 用户反馈」落库)
translatemodel *modelTranslateComp //实时机器翻译(走第三方服务配置里的 MT 服务)
cache *modelCacheComp
options *Options
@ -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