Browse Source

上传代码

main
liwei1dao 2 months ago
parent
commit
1639cf35a0
  1. 29
      CLAUDE.md
  2. 116
      apps/admin/app/components/SvcAlertBanner.vue
  3. 142
      apps/admin/app/components/SvcAlertBell.vue
  4. 44
      apps/admin/app/composables/useTableSort.ts
  5. 87
      apps/admin/app/layouts/default.vue
  6. 798
      apps/admin/app/pages/agents.vue
  7. 416
      apps/admin/app/pages/appgoods.vue
  8. 33
      apps/admin/app/pages/dashboard.vue
  9. 19
      apps/admin/app/pages/device.vue
  10. 63
      apps/admin/app/pages/factory.vue
  11. 400
      apps/admin/app/pages/product.vue
  12. 142
      apps/admin/app/pages/provider.vue
  13. 69
      apps/admin/app/stores/auth.ts
  14. 23
      apps/admin/app/utils/chipvendors.ts
  15. 1
      apps/admin/app/utils/menus.ts
  16. 2
      apps/admin/app/utils/svchealth.ts
  17. 21
      apps/proto/db.proto
  18. 37
      apps/services/comm/appgoods.go
  19. 1
      apps/services/comm/const.go
  20. 5
      apps/services/modules/api/api_addbrand.go
  21. 5
      apps/services/modules/api/api_addproduct.go
  22. 2
      apps/services/modules/api/api_updatebrand.go
  23. 12
      apps/services/modules/api/api_updateproduct.go
  24. 16
      apps/services/modules/api/model.go
  25. 67
      apps/services/modules/console/api_agent.go
  26. 4
      apps/services/modules/console/api_config.go
  27. 76
      apps/services/modules/console/api_device.go
  28. 262
      apps/services/modules/console/api_goods.go
  29. 27
      apps/services/modules/console/api_inspect.go
  30. 67
      apps/services/modules/console/model_goods.go
  31. 18
      apps/services/modules/console/registry.go
  32. 28
      apps/services/modules/console/scope_app.go
  33. 7
      apps/services/modules/console/server.go
  34. 45
      apps/services/modules/pay/api.go
  35. 196
      apps/services/modules/pay/api_paypalnotify_v2.go
  36. 21
      apps/services/modules/pay/model.go
  37. 57
      apps/services/modules/user/api_getchannelapp_test.go
  38. 88
      apps/services/modules/user/api_live_probe_test.go
  39. 71
      apps/services/modules/user/api_v3_getagents_test.go
  40. 26
      apps/services/modules/user/api_v3_getappconfig.go
  41. 67
      apps/services/modules/user/compat_test.go
  42. 579
      apps/services/modules/user/module_test.go
  43. 74
      apps/services/pb/db.pb.go
  44. 115
      apps/services/sys/pay/paypal/paypal.go
  45. 4
      deploy/admin/dev-deploy.sh
  46. 2
      deploy/admin/prod-deploy.sh
  47. 25
      deploy/app/README.md
  48. 40
      deploy/app/confs/home.yaml.example
  49. 11
      deploy/app/dev-deploy.sh
  50. 14
      deploy/app/docker-compose.yml
  51. 4
      deploy/app/env/env.example
  52. 282
      deploy/app/prod-deploy.sh
  53. 4
      deploy/console/dev-deploy.sh
  54. 2
      deploy/console/prod-deploy.sh
  55. 38
      docs/migrations/2026-08-18-product-provider.sql

29
CLAUDE.md

@ -74,7 +74,7 @@ cd deploy/console && ./prod-deploy.sh 0.2.1 yunyan # 第 2 个参数选生产
- `along` → `registry.ymaikj.com`(仓库实体就在阿龙测试机 8.133.166.29 上,**账号是 `admin` 不是 `liwei`**);阿龙测试机与正式机 47.116.104.181 都从这里拉镜像
测试环境档案在各 `dev-deploy.sh` 的 `env_profile()`:
- `along` → 8.133.166.29(阿里云,x86_64/Docker 24.0.9),目录 `/home/work/starpivot/{app,admin,console}`,密钥 `~/.ssh/along.pem`
- `along` → 8.133.166.29(阿里云,x86_64/Docker 24.0.9),目录 `/home/work/starpivot/{app,admin,console}`,密钥 `/Users/liwei1dao/liwei/密钥/along/releae-guangzhou.pem`
该机已自带 postgres:16 / redis:7 / mysql / NPM / gitea / docker-registry,配 `.env` 时优先复用,别另起一套
**生产镜像名统一为 `starpivot-<服务>`**(admin/console/app 三个服务、各家公司都一样),映射在
@ -92,11 +92,32 @@ admin/console 的 `prod-deploy.sh` 已改为**多生产环境档案**(`env_pro
- `yunyan` → 139.224.212.237(上海阿里云,与业务后端 app 同机),目录 `/home/work/starpivot/{admin,console}`,密钥 `~/Documents/key/aliyun_shanghai.pem`
(2026-08-05 由美国机 47.253.88.66 迁来;美国机上的 `yunyan-admin`/`console-a11` 容器仍在跑,待验证后停,脚本已不再指向它)
- `lingpu` → 8.166.114.65(广州阿里云),目录 `/home/work/starpivot/{admin,console}`,密钥 `~/.ssh/lingpu-gz.pem`
- `along` → 47.116.104.181(阿龙正式机,2026-08-16 补),目录 `/home/work/starpivot/{admin,console}`,密钥 `~/.ssh/along.pem`(与测试机同一把);镜像走阿龙仓库 `registry.ymaikj.com`
- `along` → 47.116.104.181(阿龙正式机,2026-08-16 补),目录 `/home/work/starpivot/{admin,console}`,密钥 `/Users/liwei1dao/liwei/密钥/along/releae-guangzhou.pem`(与测试机同一把);镜像走阿龙仓库 `registry.ymaikj.com`
app 的 `prod-deploy.sh` 是**按区域**(`region_profile()`):`shanghai|japan|singapore` 是云雁三地,新增 `along` → 47.116.104.181。
该函数除服务器连接信息外还给两项:`REGION_COMPANY`(这个区域拉哪家仓库,云雁三地=yunyan、阿龙=along;可用环境变量 `COMPANY` 覆盖)、
`DEPLOY_DIR_OVERRIDE`(阿龙用 `/home/work/starpivot/app`,不走 `APPS` 里的 `/home/work/<应用>-a11`)。
该函数除服务器连接信息外还给 `REGION_COMPANY`(这个区域拉哪家仓库,云雁三地=yunyan、阿龙=along;可用环境变量 `COMPANY` 覆盖)。
**远端目录与容器名一律按「应用」区分**(2026-08-18 起):目录只由 `APPS` 表给(`ym` → `/home/work/ym-a11`),
区域里**不再有 `DEPLOY_DIR_OVERRIDE`**(阿龙原先覆盖成 `/home/work/starpivot/app`,导致部 ym 时落错目录、还和已有容器撞名,已移除;
阿龙机上那份旧的 `/home/work/starpivot/app` 部署保持原样、不再由脚本管理)。容器名由应用 key 推导为 `<应用>-a11`,
经 `CONTAINER_NAME`(同时作 `COMPOSE_PROJECT_NAME`)注入 `docker-compose.yml` 的 `container_name: ${CONTAINER_NAME:-starpivot-app}`,
部署后写回服务器 `.env`。compose 里的 service 名固定为 `app`,别写成具体应用名。
dev(`dev-deploy.sh`)仍是每台机单份部署,走默认容器名 `starpivot-app`;admin/console 的 compose 也尚未参数化。
`docker-compose.yml` 由 `prod-deploy.sh` **按应用现生成再下发**:模板里 service 名是占位的 `app`,下发时
`sed` 改写成本应用名(`ym` → `ym-a11:`),于是服务器上 service 名 = 容器名 = `<应用>-a11`。
下发前与远端文件逐字节比对,一致就跳过、不一致才备份 `.bak` 后覆盖(纯"存在就不碰"会让旧 compose 永不更新——
ym 首次部署容器一直叫 `starpivot-app` 正是如此)。service 名变更后旧容器占着 container_name,
远端脚本会按 compose 标签识别并 `docker rm -f` 掉再重建。服务器上只需维护 `.env` 与 `confs/*.yaml`。
**新应用首次部署 checklist**(2026-08-18 部署 ym 时逐条踩出来的):
1. `confs/home.yaml` 的 `wordfilter.WorldFile` 若列了词库 txt,服务器上必须真有这些文件,否则 home
`panic: init sys.wordfilter err: no found file:...` → entrypoint 杀容器 → 无限重启。词库不在 git 里,
不需要就保持注释(模板已默认注释)。**表象是 api 报 `Table '<库>.userdevice' doesn't exist`**——
userdevice/user 等表由 home 里的 user 模块启动时 `CreateTable` 建,home 起不来就永远没表,别去查数据库,先看 panic。
2. `confs/ip2region_v4.xdb` / `v6.xdb`:`sys.ipinfo` 启动必需,缺了同样 panic。现在 prod-deploy 会自动下发(仅远端缺失时)。
3. `.env` 别照抄模板:`CLUSTER_TAG`、`ANALYZE_APP_NAME`、`REDIS_KEY_PREFIX` 都要换成本应用的身份,
其中 `ANALYZE_APP_NAME` 必须等于 console 注册表 `app_registry.app_name`,否则按应用作用域的配置全部落到全局默认。
4. console 后台要先把该应用登记进 `app_registry`(阿龙正式机 2026-08-18 时该表还是空的)。
`dev-deploy.sh <环境>` 只做镜像部署:构建推送 → 远端 pull 重启,**不上传** 服务器上的真实 `.env` / `confs/*.yaml`(只下发 `*.example` 模板)。

116
apps/admin/app/components/SvcAlertBanner.vue

@ -1,116 +0,0 @@
<template>
<!-- 仪表盘顶部第三方服务异常告警条。没有异常时整块不渲染,不占首屏。 -->
<div v-if="errorCount + warnCount > 0" class="alert-banner" :class="errorCount > 0 ? 'lv-error' : 'lv-warn'">
<div class="ab-head">
<span class="ab-ico">{{ errorCount > 0 ? '🚨' : '⚠️' }}</span>
<div class="ab-title">
<strong>
{{ errorCount > 0 ? `${errorCount} 项第三方服务不可用` : `${warnCount} 项第三方服务存在隐患` }}
</strong>
<span v-if="errorCount > 0 && warnCount > 0" class="ab-sub">,另有 {{ warnCount }} 项隐患</span>
<span class="ab-sub"> · 最近巡检 {{ lastRunText }}</span>
</div>
<button class="ab-btn" @click="expanded = !expanded">{{ expanded ? '收起' : '展开详情' }}</button>
<NuxtLink to="/svcinspect" class="ab-btn ab-btn-primary">前往巡检</NuxtLink>
</div>
<div v-if="expanded" class="ab-list">
<div v-for="a in alerts" :key="`${a.app_name}/${a.svc_id}/${a.region}`" class="ab-item">
<span class="ab-dot" :class="a.status === 'error' ? 'd-error' : 'd-warn'"></span>
<span class="ab-name">{{ a.name || a.svc_id }}</span>
<span v-if="a.provider" class="ab-chip">{{ a.provider }}</span>
<span class="ab-chip ab-chip-scope">{{ scopeText(a) }}</span>
<span class="ab-msg">{{ reasonOf(a) }}</span>
<span v-if="a.fail_streak > 1" class="ab-streak">连续失败 {{ a.fail_streak }} 次</span>
</div>
<div v-if="truncated" class="ab-more">
仅显示前 {{ alerts.length }} 条,其余请到<NuxtLink to="/svcinspect" class="ab-link">服务巡检</NuxtLink>查看
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { REGION_LABEL, healthReason, healthLastRunText, type SvcHealth } from '~/utils/svchealth'
const { webApi } = useApi()
const alerts = ref<SvcHealth[]>([])
const errorCount = ref(0)
const warnCount = ref(0)
const lastRun = ref(0)
const expanded = ref(false)
const truncated = ref(false)
const LIMIT = 12
const lastRunText = computed(() => healthLastRunText(lastRun.value))
function scopeText(a: SvcHealth): string {
const app = a.app_name ? a.app_name : '全局'
return a.region ? `${app} · ${REGION_LABEL[a.region] || '区域' + a.region}` : app
}
function reasonOf(a: SvcHealth): string {
return healthReason(a)
}
async function load() {
try {
const res = await webApi<{
alerts: SvcHealth[]
error_count: number
warn_count: number
last_run: number
}>('getsvcalerts', { limit: LIMIT })
alerts.value = res.alerts || []
errorCount.value = res.error_count || 0
warnCount.value = res.warn_count || 0
lastRun.value = res.last_run || 0
truncated.value = errorCount.value + warnCount.value > alerts.value.length
// 有明确不可用的服务时默认展开——这类问题需要马上看见是哪几个,而不是再点一次。
expanded.value = errorCount.value > 0
} catch {
// 巡检数据拿不到不应该拖垮整个仪表盘,静默降级为「无告警」。
alerts.value = []
errorCount.value = 0
warnCount.value = 0
}
}
onMounted(load)
defineExpose({ load })
</script>
<style scoped>
.alert-banner { border-radius:14px; padding:12px 16px; margin-bottom:18px; border:1px solid; }
.lv-error { background:linear-gradient(180deg,#fff1f2 0%,#fff7f7 100%); border-color:#fecdd3; }
.lv-warn { background:linear-gradient(180deg,#fffbeb 0%,#fffdf5 100%); border-color:#fde68a; }
.ab-head { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
.ab-ico { font-size:17px; line-height:1; }
.ab-title { font-size:13px; color:#334155; flex:1; min-width:200px; }
.lv-error .ab-title strong { color:#be123c; }
.lv-warn .ab-title strong { color:#b45309; }
.ab-sub { color:#94a3b8; font-size:12px; }
.ab-btn { font-size:12px; padding:4px 11px; border-radius:8px; border:1px solid #e2e8f0; background:#fff; color:#475569; white-space:nowrap; }
.ab-btn:hover { background:#f8fafc; }
.ab-btn-primary { background:#0f172a; border-color:#0f172a; color:#fff; }
.ab-btn-primary:hover { background:#1e293b; }
.ab-list { margin-top:10px; padding-top:10px; border-top:1px dashed rgba(148,163,184,.4); display:flex; flex-direction:column; gap:6px; }
.ab-item { display:flex; align-items:center; gap:8px; font-size:12.5px; color:#475569; flex-wrap:wrap; }
.ab-dot { width:7px; height:7px; border-radius:50%; flex-shrink:0; }
.d-error { background:#f43f5e; }
.d-warn { background:#f59e0b; }
.ab-name { font-weight:600; color:#1e293b; }
.ab-chip { font-size:11px; padding:1px 7px; border-radius:999px; background:#f1f5f9; color:#64748b; }
.ab-chip-scope { background:#e0e7ff; color:#4338ca; }
.ab-msg { color:#64748b; flex:1; min-width:160px; }
.ab-streak { font-size:11px; color:#be123c; font-weight:600; }
.ab-more { font-size:12px; color:#94a3b8; padding-top:2px; }
.ab-link { color:#4338ca; text-decoration:underline; margin:0 2px; }
</style>

142
apps/admin/app/components/SvcAlertBell.vue

@ -0,0 +1,142 @@
<template>
<!-- 顶栏通知铃铛:第三方服务巡检的异常提醒。
只对有「服务巡检」页面权限的账号(超管/管理员/运营)拉取与展示,
品牌商/渠道商这类外部角色既看不到菜单,也不该在这里看到平台的服务健康。 -->
<div class="dropdown dropdown-end">
<div tabindex="0" class="btn btn-ghost btn-sm btn-circle relative" @click="onOpen">
<svg class="h-5 w-5 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<span v-if="total > 0" class="bell-badge" :class="errorCount > 0 ? 'lv-error' : 'lv-warn'">
{{ total > 99 ? '99+' : total }}
</span>
</div>
<div tabindex="0" class="dropdown-content z-[2] shadow-xl bg-white rounded-xl w-96 border overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b">
<span class="text-sm font-semibold text-slate-700">系统通知</span>
<span class="text-[11px] text-slate-400">巡检 {{ lastRunText }}</span>
</div>
<div v-if="!canSee || total === 0" class="px-3 py-6 text-center text-slate-400 text-xs">暂无新通知</div>
<template v-else>
<div class="px-4 py-2.5 text-xs" :class="errorCount > 0 ? 'bg-rose-50 text-rose-700' : 'bg-amber-50 text-amber-700'">
<span class="font-semibold">
{{ errorCount > 0 ? `${errorCount} 项第三方服务不可用` : `${warnCount} 项第三方服务存在隐患` }}
</span>
<span v-if="errorCount > 0 && warnCount > 0" class="opacity-70">,另有 {{ warnCount }} 项隐患</span>
</div>
<div class="bell-list">
<NuxtLink v-for="a in alerts" :key="rowKey(a)" to="/svcinspect" class="bell-item">
<span class="bell-dot" :class="a.status === 'error' ? 'd-error' : 'd-warn'"></span>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="bell-name">{{ a.name || a.svc_id }}</span>
<span class="bell-chip">{{ scopeText(a) }}</span>
<span v-if="a.fail_streak > 1" class="bell-streak">连续 {{ a.fail_streak }} 次</span>
</div>
<div class="bell-msg">{{ reasonOf(a) || '巡检异常' }}</div>
</div>
</NuxtLink>
</div>
<NuxtLink to="/svcinspect" class="bell-foot">
{{ truncated ? `仅显示前 ${alerts.length} 条 · 前往服务巡检` : '前往服务巡检' }}
</NuxtLink>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useApi } from '~/composables/useApi'
import { useAuthStore } from '~/stores/auth'
import { REGION_LABEL, healthReason, healthLastRunText, type SvcHealth } from '~/utils/svchealth'
const { webApi } = useApi()
const auth = useAuthStore()
const alerts = ref<SvcHealth[]>([])
const errorCount = ref(0)
const warnCount = ref(0)
const lastRun = ref(0)
const truncated = ref(false)
const LIMIT = 8
const POLL_MS = 5 * 60 * 1000
// 只有能进「服务巡检」页的账号才拉这份数据(品牌商/渠道商无此菜单)。
const canSee = computed(() => auth.hasAccess('svcinspect'))
const total = computed(() => errorCount.value + warnCount.value)
const lastRunText = computed(() => healthLastRunText(lastRun.value))
function rowKey(a: SvcHealth) { return `${a.app_name}/${a.svc_id}/${a.region}` }
function scopeText(a: SvcHealth): string {
const app = a.app_name ? a.app_name : '全局'
return a.region ? `${app} · ${REGION_LABEL[a.region] || '区域' + a.region}` : app
}
function reasonOf(a: SvcHealth): string {
return healthReason(a)
}
async function load() {
if (!canSee.value || !auth.token) return
try {
const res = await webApi<{
alerts: SvcHealth[]
error_count: number
warn_count: number
last_run: number
}>('getsvcalerts', { limit: LIMIT })
alerts.value = res.alerts || []
errorCount.value = res.error_count || 0
warnCount.value = res.warn_count || 0
lastRun.value = res.last_run || 0
truncated.value = total.value > alerts.value.length
} catch {
// 巡检数据拿不到不该影响顶栏,静默降级为「无通知」。
alerts.value = []
errorCount.value = 0
warnCount.value = 0
truncated.value = false
}
}
// 点开时刷一次,避免看到几分钟前的旧状态。
function onOpen() { load() }
let timer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
load()
timer = setInterval(load, POLL_MS)
})
onBeforeUnmount(() => { if (timer) clearInterval(timer) })
</script>
<style scoped>
.bell-badge {
position:absolute; top:-1px; right:-1px; min-width:16px; height:16px; padding:0 4px;
border-radius:999px; color:#fff; font-size:10px; font-weight:700; line-height:16px;
display:flex; align-items:center; justify-content:center; border:1.5px solid #fff;
}
.lv-error { background:#f43f5e; }
.lv-warn { background:#f59e0b; }
.bell-list { max-height:320px; overflow-y:auto; }
.bell-item { display:flex; align-items:flex-start; gap:8px; padding:9px 14px; border-bottom:1px solid #f8fafc; }
.bell-item:hover { background:#f8fafc; }
.bell-dot { width:7px; height:7px; border-radius:50%; flex-shrink:0; margin-top:6px; }
.d-error { background:#f43f5e; }
.d-warn { background:#f59e0b; }
.bell-name { font-size:12.5px; font-weight:600; color:#1e293b; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.bell-chip { font-size:10.5px; padding:0 6px; border-radius:999px; background:#e0e7ff; color:#4338ca; white-space:nowrap; }
.bell-streak { font-size:10.5px; color:#be123c; font-weight:600; white-space:nowrap; }
.bell-msg { font-size:11.5px; color:#64748b; margin-top:2px; line-height:1.45; }
.bell-foot { display:block; text-align:center; font-size:12px; color:#4338ca; padding:9px; background:#f8fafc; }
.bell-foot:hover { background:#f1f5f9; }
</style>

44
apps/admin/app/composables/useTableSort.ts

@ -0,0 +1,44 @@
import { ref } from 'vue'
// 列表表头按字段排序。各页传一组「升序语义」的比较器,降序由这里统一取反;
// 比较器里用 `||` 串一个兜底字段(一般是 id),值相等时顺序才稳定、不会每次渲染乱跳。
//
// 用法:
// const { toggleSort, sortMark, applySort } = useTableSort<Row>({
// id: (a, b) => a.id - b.id,
// name: (a, b) => a.name.localeCompare(b.name, 'zh') || a.id - b.id,
// }, 'id')
// const viewRows = computed(() => applySort(rows.value.filter(...)))
// <th class="cursor-pointer select-none" @click="toggleSort('name')">名称{{ sortMark('name') }}</th>
export type SortDir = 'asc' | 'desc'
export type Comparator<T> = (a: T, b: T) => number
export function useTableSort<T>(
comparators: Record<string, Comparator<T>>,
defaultKey: string,
defaultDir: SortDir = 'asc'
) {
const sortKey = ref(defaultKey)
const sortDir = ref<SortDir>(defaultDir)
function toggleSort(key: string) {
if (!comparators[key]) return
if (sortKey.value === key) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
else { sortKey.value = key; sortDir.value = 'asc' }
}
// 表头后缀的排序箭头;非当前排序列返回空串
function sortMark(key: string) {
return sortKey.value === key ? (sortDir.value === 'asc' ? ' ↑' : ' ↓') : ''
}
// 返回新数组,不改原列表(原列表常是 ref 里的接口数据)
function applySort(list: T[]): T[] {
const cmp = comparators[sortKey.value]
if (!cmp) return list
const dir = sortDir.value === 'asc' ? 1 : -1
return [...list].sort((a, b) => cmp(a, b) * dir)
}
return { sortKey, sortDir, toggleSort, sortMark, applySort }
}

87
apps/admin/app/layouts/default.vue

@ -41,17 +41,35 @@
</template>
</nav>
<!-- 底部账号信息卡 -->
<div style="margin-top:auto; padding:14px 18px; border-top:1px solid rgba(255,255,255,0.06);">
<div style="display:flex; align-items:center; gap:10px;">
<!-- 底部账号卡:账号名 + 账号ID + 身份,点击向上弹出菜单(顶栏不再重复放一份) -->
<div class="dropdown dropdown-top w-full" style="margin-top:auto; border-top:1px solid rgba(255,255,255,0.06);">
<div
tabindex="0"
role="button"
class="hover:bg-white/5"
style="display:flex; align-items:center; gap:10px; padding:14px 18px; cursor:pointer;"
>
<div style="width:32px; height:32px; border-radius:9px; background:linear-gradient(135deg,#4F46E5,#7C3AED); color:#fff; display:flex; align-items:center; justify-content:center; font-weight:700; flex-shrink:0;">
{{ avatarChar }}
</div>
<div style="min-width:0;">
<div style="min-width:0; flex:1;">
<div style="color:#E2E8F0; font-size:13px; font-weight:600; line-height:1.2; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{ auth.account || '-' }}</div>
<div style="color:#64748B; font-size:11px; margin-top:2px;">{{ identityLabel }}</div>
<div style="color:#64748B; font-size:11px; margin-top:2px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
{{ accountIdLabel }} · {{ identityLabel }}
</div>
</div>
<svg style="width:14px; height:14px; color:#64748B; flex-shrink:0;" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</div>
<ul tabindex="0" class="dropdown-content z-[40] menu p-2 shadow-lg bg-white rounded-lg border" style="width:220px; margin-bottom:6px;">
<li class="menu-title px-2 py-1">
<span class="text-xs text-slate-500">{{ auth.account || '-' }}({{ identityLabel }})</span>
<span class="text-[10px] text-slate-400">账号 {{ accountIdLabel }}</span>
</li>
<li><a class="text-sm cursor-pointer" @click="openPwd">修改密码</a></li>
<li><a class="text-sm text-red-500 cursor-pointer" @click="logout">退出登录</a></li>
</ul>
</div>
</aside>
@ -66,41 +84,10 @@
<span class="text-slate-700 font-medium">{{ pageTitle }}</span>
</div>
<!-- 右侧操作 -->
<!-- 右侧操作:账号入口已移到左侧菜单底部,这里只留通知 -->
<div class="flex items-center gap-2">
<!-- 通知 -->
<div class="dropdown dropdown-end">
<div tabindex="0" class="btn btn-ghost btn-sm btn-circle relative">
<svg class="h-5 w-5 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
</div>
<div tabindex="0" class="dropdown-content z-[2] shadow-xl bg-white rounded-xl w-80 border" style="max-height:400px;">
<div class="flex items-center justify-between px-4 py-3 border-b">
<span class="text-sm font-semibold text-slate-700">系统通知</span>
</div>
<div class="px-3 py-6 text-center text-slate-400 text-xs">暂无新通知</div>
</div>
</div>
<!-- 用户菜单 -->
<div class="dropdown dropdown-end">
<div tabindex="0" class="flex items-center gap-2 cursor-pointer hover:bg-slate-50 rounded-lg px-3 py-1.5">
<div class="avatar placeholder">
<div class="bg-indigo-600 text-white rounded-full w-8">
<span class="text-sm">{{ avatarChar }}</span>
</div>
</div>
<div>
<div class="text-sm text-slate-700 leading-tight">{{ auth.account }}</div>
<div class="text-[10px] text-slate-400">{{ identityLabelShort }}</div>
</div>
</div>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow-lg bg-white rounded-lg w-44 border">
<li><a class="text-sm cursor-pointer" @click="openPwd">修改密码</a></li>
<li><a class="text-sm text-red-500 cursor-pointer" @click="logout">退出登录</a></li>
</ul>
</div>
<!-- 通知:第三方服务巡检异常提醒(有权限的角色才拉数据) -->
<SvcAlertBell />
</div>
</header>
@ -157,7 +144,13 @@ const route = useRoute()
const { consoleApi } = useApi()
const { success, error } = useToast()
// DaisyUI 的 dropdown 靠 focus 展开,点完菜单项要主动失焦才会收起
function closeMenu() {
if (import.meta.client) (document.activeElement as HTMLElement | null)?.blur()
}
function logout() {
closeMenu()
auth.logout()
router.push('/')
}
@ -168,6 +161,7 @@ const pwdSubmitting = ref(false)
const pwd = reactive({ old: '', next: '', confirm: '' })
function openPwd() {
closeMenu()
pwd.old = ''
pwd.next = ''
pwd.confirm = ''
@ -192,14 +186,13 @@ async function submitPwd() {
}
}
// 身份标签(侧栏用全称,顶栏用简称,与 common.js 一致)
const identityLabel = computed(() => {
const map: Record<number, string> = { 1: '超级管理员', 2: '管理员', 3: '代理', 4: '运营' }
return map[auth.identity] ?? ''
})
const identityLabelShort = computed(() => {
const map: Record<number, string> = { 1: '超管', 2: '管理员', 3: '代理', 4: '运营' }
return map[auth.identity] ?? ''
// 身份标签统一取 store 的 IDENTITY_LABELS(含品牌商/渠道商),避免各处再各写一份 map
const identityLabel = computed(() => auth.identityLabel || '未知身份')
// 账号 id:登录时下发。0 分两种情况——yaml 里的引导超管(库里本就没有记录),
// 或者本次登录态是加这个字段之前存下的(重新登录一次即可显示)。
const accountIdLabel = computed(() => {
if (auth.accountId) return 'ID ' + auth.accountId
return auth.identity === 1 ? '内置账号' : 'ID -'
})
const avatarChar = computed(() => (auth.account || 'A').charAt(0).toUpperCase())

798
apps/admin/app/pages/agents.vue

File diff suppressed because it is too large

416
apps/admin/app/pages/appgoods.vue

@ -0,0 +1,416 @@
<template>
<div>
<!-- 列表视图 -->
<div v-show="view === 'list'">
<div class="flex items-center justify-between mb-5">
<div>
<h2 class="text-lg font-semibold text-slate-800">应用商品配置</h2>
<p class="text-xs text-slate-400 mt-1">应用的内购商品(VIP 时长、AI/会议/翻译资源点包等),支持按语言配置名称、货币与售价。统一存后台公共库,按应用隔离,同一应用的各区域部署共用一份。</p>
</div>
<button class="btn btn-primary btn-sm text-white" :disabled="!curAppName" @click="showForm()">+ 新增商品</button>
</div>
<div class="flex items-center gap-3 mb-4 flex-wrap">
<span class="text-sm text-slate-500">应用</span>
<select v-model="curAppName" class="select select-bordered select-sm min-w-48" @change="loadData">
<option value="" disabled>请选择应用</option>
<option v-for="n in appNames" :key="n" :value="n">{{ n }}</option>
</select>
<input v-model="keyword" class="input input-bordered input-sm w-56" placeholder="按商品ID/名称搜索" />
<label class="label cursor-pointer gap-2">
<input v-model="onlyEnabled" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
<span class="label-text text-sm text-slate-500">只看已上架</span>
</label>
</div>
<div v-if="!curAppName" class="bg-white rounded-xl shadow-sm border border-slate-100 text-center text-slate-400 py-16">
<div class="text-4xl mb-2">🛒</div>
<p class="text-sm">请先在上方选择应用</p>
</div>
<div v-else class="bg-white rounded-xl shadow-sm border border-slate-100">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>商品ID</th><th>名称</th><th>类型</th><th>默认价格</th><th>发放内容</th><th>多语言</th><th>状态</th><th class="text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="8" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td>
</tr>
<tr v-else-if="!filteredRows.length">
<td colspan="8" class="text-center text-slate-400 py-8">暂无商品</td>
</tr>
<tr v-for="g in filteredRows" :key="g.id" class="hover">
<td class="font-mono text-sm font-medium">{{ g.goods_id }}</td>
<td class="text-sm">{{ g.name || '-' }}</td>
<td class="text-sm text-slate-500">{{ usageLabel(g.usagetype) }}</td>
<td class="text-sm">{{ g.price }}</td>
<td class="text-xs text-slate-500">{{ rewardText(g) || '-' }}</td>
<td>
<div v-if="localeCodes(g).length" class="flex flex-wrap gap-1">
<span v-for="code in localeCodes(g)" :key="code" class="badge badge-ghost badge-sm font-mono">{{ code }}</span>
</div>
<span v-else class="text-xs text-slate-400">未配置</span>
</td>
<td>
<span class="badge badge-sm" :class="g.enable ? 'badge-success text-white' : 'badge-ghost'">{{ g.enable ? '上架' : '下架' }}</span>
</td>
<td class="text-right">
<div class="inline-flex gap-1">
<button class="btn btn-ghost btn-xs" @click="showForm(g)">编辑</button>
<button class="btn btn-ghost btn-xs text-red-500" @click="delGoods(g)">删除</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 表单视图 -->
<div v-show="view === 'form'">
<div class="flex items-center gap-3 mb-5">
<button class="btn btn-ghost btn-sm" @click="showList()">&larr; 返回列表</button>
<h2 class="text-lg font-semibold text-slate-800">{{ isEditing ? '编辑商品' : '新增商品' }}</h2>
<span class="text-xs text-slate-400">所属应用:{{ curAppName || '-' }}</span>
</div>
<form class="space-y-5 max-w-4xl" @submit.prevent="saveGoods">
<!-- 基本信息 -->
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
<h3 class="text-sm font-semibold text-slate-600 mb-4">基本信息</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">商品ID (goods_id) *</span></label>
<input v-model="form.goods_id" type="text" class="input input-bordered w-full font-mono" required placeholder="如 vip_month_1(与苹果/谷歌后台的内购商品ID一致)" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">默认名称</span></label>
<input v-model="form.name" type="text" class="input input-bordered w-full" placeholder="未匹配到语言时展示的兜底名称" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">商品类型</span></label>
<select v-model.number="form.usagetype" class="select select-bordered w-full">
<option v-for="o in USAGE_TYPES" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">默认价格</span></label>
<input v-model.number="form.price" type="number" class="input input-bordered w-full" />
<span class="text-xs text-slate-400 mt-1">未配置该语言时的兜底价;各语言售价在下方按语言单独配。</span>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">排序</span></label>
<input v-model.number="form.sort" type="number" class="input input-bordered w-full" />
<span class="text-xs text-slate-400 mt-1">值小的排前面。</span>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">状态</span></label>
<label class="label cursor-pointer justify-start gap-3">
<input v-model="form.enable" type="checkbox" class="toggle toggle-primary toggle-sm" />
<span class="label-text">{{ form.enable ? '上架(客户端可见)' : '下架' }}</span>
</label>
</div>
<div class="form-control md:col-span-2">
<label class="label"><span class="label-text font-medium">备注</span></label>
<input v-model="form.remark" type="text" class="input input-bordered w-full" />
</div>
</div>
</div>
<!-- 发放内容 -->
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
<h3 class="text-sm font-semibold text-slate-600 mb-1">发放内容</h3>
<p class="text-xs text-slate-400 mb-4">购买成功后给用户加的资源,按需填写,不发的留 0。</p>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="form-control">
<label class="label"><span class="label-text font-medium">会员时长 (viptime)</span></label>
<input v-model.number="form.viptime" type="number" class="input input-bordered w-full" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">AI 资源点 (ainum)</span></label>
<input v-model.number="form.ainum" type="number" class="input input-bordered w-full" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">会议资源点 (meetnum)</span></label>
<input v-model.number="form.meetnum" type="number" class="input input-bordered w-full" />
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">翻译资源点 (tradenum)</span></label>
<input v-model.number="form.tradenum" type="number" class="input input-bordered w-full" />
</div>
</div>
</div>
<!-- 多语言 -->
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
<div class="flex items-center justify-between mb-1">
<h3 class="text-sm font-semibold text-slate-600">多语言配置</h3>
<div class="flex items-center gap-2">
<select v-model="langToAdd" class="select select-bordered select-sm min-w-52">
<option value="" disabled>选择语言</option>
<option v-for="o in addableLangs" :key="o.code" :value="o.code">{{ o.code }} · {{ o.name }}</option>
</select>
<button type="button" class="btn btn-outline btn-sm" :disabled="!langToAdd" @click="addLocale()">+ 添加语言</button>
</div>
</div>
<p class="text-xs text-slate-400 mb-4">客户端按自身语言取名称与售价;没配的语言回落到上方的默认名称与默认价格。</p>
<div v-if="!form.locales.length" class="text-center text-slate-400 text-sm py-8 border border-dashed border-slate-200 rounded-lg">
还没有语言配置,选一个语言添加
</div>
<div v-else class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th class="w-52">语言</th><th>名称</th><th class="w-28">货币符号</th><th class="w-36">价格</th><th class="w-20 text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(l, i) in form.locales" :key="l.lang">
<td>
<div class="font-mono text-sm">{{ l.lang }}</div>
<div class="text-xs text-slate-400">{{ langName(l.lang) }}</div>
</td>
<td><input v-model="l.name" type="text" class="input input-bordered input-sm w-full" placeholder="该语言下的商品名" /></td>
<td><input v-model="l.currency" type="text" class="input input-bordered input-sm w-full" placeholder="¥ / $" /></td>
<td><input v-model.number="l.price" type="number" step="0.01" class="input input-bordered input-sm w-full" /></td>
<td class="text-right">
<button type="button" class="btn btn-ghost btn-xs text-red-500" @click="form.locales.splice(i, 1)">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="flex gap-3">
<button type="button" class="btn btn-ghost" @click="showList()">取消</button>
<button type="submit" class="btn btn-primary text-white" :disabled="submitting">保存</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { LANG_OPTIONS, langName } from '~/composables/useLangOptions'
definePageMeta({ title: '应用商品配置' })
// 商品按「应用名」隔离(同一应用的多区域部署共用一份),故这里只选应用、不选部署环境——
// 与「应用环境配置」页按应用×部署环境读写业务库不同,本页数据统一落 console 公共库 app_goods。
interface GoodsLocale { name: string; currency: string; price: number }
interface Goods {
id: number
app_name: string
goods_id: string
enable: boolean
name: string
price: number
usagetype: number
ainum: number
meetnum: number
tradenum: number
viptime: number
sort: number
remark: string
locales: Record<string, GoodsLocale>
}
// pb.UsageType:1翻译 2会议 3智能体 4购买VIP时长
const USAGE_TYPES = [
{ value: 0, label: '未指定' },
{ value: 1, label: '翻译' },
{ value: 2, label: '会议' },
{ value: 3, label: '智能体' },
{ value: 4, label: 'VIP 时长' },
]
const { consoleApi } = useApi()
const { success, error } = useToast()
const view = ref<'list' | 'form'>('list')
const appNames = ref<string[]>([])
const curAppName = ref('')
const items = ref<Goods[]>([])
const loading = ref(false)
const submitting = ref(false)
const isEditing = ref(false)
const keyword = ref('')
const onlyEnabled = ref(false)
const langToAdd = ref('')
const form = reactive({
id: 0,
goods_id: '',
name: '',
enable: true,
price: 0,
usagetype: 4,
ainum: 0,
meetnum: 0,
tradenum: 0,
viptime: 0,
sort: 0,
remark: '',
locales: [] as Array<{ lang: string } & GoodsLocale>,
})
function usageLabel(v: number) {
return USAGE_TYPES.find((o) => o.value === v)?.label ?? String(v)
}
function localeCodes(g: Goods) {
return Object.keys(g.locales || {}).sort()
}
// 发放内容摘要(列表列,只显示非 0 项)
function rewardText(g: Goods) {
const parts: string[] = []
if (g.viptime) parts.push(`VIP ${g.viptime}`)
if (g.ainum) parts.push(`AI ${g.ainum}`)
if (g.meetnum) parts.push(`会议 ${g.meetnum}`)
if (g.tradenum) parts.push(`翻译 ${g.tradenum}`)
return parts.join(' · ')
}
const filteredRows = computed(() => {
const kw = keyword.value.trim().toLowerCase()
return items.value.filter((g) => {
if (onlyEnabled.value && !g.enable) return false
if (!kw) return true
return ((g.goods_id || '') + (g.name || '')).toLowerCase().includes(kw)
})
})
// 还没配过的语言(已配的不重复出现在「添加语言」下拉里)
const addableLangs = computed(() => {
const used = new Set(form.locales.map((l) => l.lang))
return LANG_OPTIONS.filter((o) => !used.has(o.code))
})
// 应用名来源:应用中心的应用表为主,再并上已有部署记录里的应用名(兜底老数据)
async function loadApps() {
try {
const [products, deploys] = await Promise.all([
consoleApi<Array<{ name: string }>>('products/list', {}).catch(() => []),
consoleApi<Array<{ app_name: string; name: string }>>('apps/list', {}).catch(() => []),
])
const names = new Set<string>()
;(products ?? []).forEach((p) => p.name && names.add(p.name))
;(deploys ?? []).forEach((d) => (d.app_name || d.name) && names.add(d.app_name || d.name))
appNames.value = [...names].sort()
if (!curAppName.value && appNames.value.length) {
curAppName.value = appNames.value[0]!
await loadData()
}
} catch (e: any) {
error(e?.message ?? '获取应用列表失败')
appNames.value = []
}
}
async function loadData() {
if (!curAppName.value) {
items.value = []
return
}
loading.value = true
try {
const data = await consoleApi<{ items: Goods[] }>('appgoods/list', { app_name: curAppName.value })
items.value = data?.items ?? []
} catch (e: any) {
error(e?.message ?? '获取商品失败')
items.value = []
} finally {
loading.value = false
}
}
function showList() {
view.value = 'list'
loadData()
}
function showForm(g?: Goods) {
isEditing.value = !!g
form.id = g?.id ?? 0
form.goods_id = g?.goods_id ?? ''
form.name = g?.name ?? ''
form.enable = g ? !!g.enable : true
form.price = g?.price ?? 0
form.usagetype = g?.usagetype ?? 4
form.ainum = g?.ainum ?? 0
form.meetnum = g?.meetnum ?? 0
form.tradenum = g?.tradenum ?? 0
form.viptime = g?.viptime ?? 0
form.sort = g?.sort ?? 0
form.remark = g?.remark ?? ''
form.locales = Object.entries(g?.locales ?? {})
.map(([lang, v]) => ({ lang, name: v?.name ?? '', currency: v?.currency ?? '', price: Number(v?.price ?? 0) }))
.sort((a, b) => (a.lang < b.lang ? -1 : 1))
langToAdd.value = ''
view.value = 'form'
}
// 新增语言行:默认名称/货币/价格从上方基础信息带一份,改起来快
function addLocale() {
const lang = langToAdd.value
if (!lang || form.locales.some((l) => l.lang === lang)) return
form.locales.push({ lang, name: form.name, currency: '', price: form.price })
langToAdd.value = ''
}
async function saveGoods() {
if (!curAppName.value) {
error('请先选择应用')
return
}
const locales: Record<string, GoodsLocale> = {}
for (const l of form.locales) {
locales[l.lang] = { name: l.name.trim(), currency: l.currency.trim(), price: Number(l.price) || 0 }
}
submitting.value = true
try {
const data: Record<string, unknown> = {
app_name: curAppName.value,
goods_id: form.goods_id.trim(),
name: form.name.trim(),
enable: form.enable,
price: Number(form.price) || 0,
usagetype: Number(form.usagetype) || 0,
ainum: Number(form.ainum) || 0,
meetnum: Number(form.meetnum) || 0,
tradenum: Number(form.tradenum) || 0,
viptime: Number(form.viptime) || 0,
sort: Number(form.sort) || 0,
remark: form.remark.trim(),
locales,
}
if (isEditing.value) {
data.id = Number(form.id)
await consoleApi('appgoods/update', data)
} else {
await consoleApi('appgoods/add', data)
}
success('保存成功')
showList()
} catch (e: any) {
error(e?.message ?? '保存失败')
} finally {
submitting.value = false
}
}
function delGoods(g: Goods) {
if (!window.confirm(`确定删除商品「${g.goods_id}」吗?`)) return
consoleApi('appgoods/del', { app_name: curAppName.value, ids: [g.id] })
.then(() => { success('删除成功'); loadData() })
.catch((e: any) => error(e?.message ?? '删除失败'))
}
onMounted(loadApps)
</script>

33
apps/admin/app/pages/dashboard.vue

@ -1,15 +1,12 @@
<template>
<div>
<!-- 第三方服务异常报警:巡检发现不可用/有隐患的服务时才出现,置于最顶部 -->
<SvcAlertBanner />
<!-- 全局筛选条(应用/区域/产品,时间由各版面单独控制)-->
<div class="panel p-4 mb-5">
<div class="flex flex-wrap items-end gap-3">
<div v-if="!IS_SCOPED || appOptions.length" class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">应用</span></label>
<select v-model="filters.app" class="select select-bordered select-sm w-40">
<option value="">全部应用</option>
<option value="">{{ allLabel('应用') }}</option>
<option v-for="a in appOptions" :key="a.value" :value="a.value">{{ a.label }}</option>
</select>
</div>
@ -20,23 +17,26 @@
<option v-for="r in regionOptions" :key="r.value" :value="r.value">{{ r.label }}</option>
</select>
</div>
<div class="form-control">
<div v-if="!IS_SCOPED || productOptions.length" class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">产品</span></label>
<select v-model="filters.product" class="select select-bordered select-sm w-48">
<option value="">全部产品</option>
<option value="">{{ allLabel('产品') }}</option>
<option v-for="p in productOptions" :key="p.value" :value="p.value">{{ p.label }}</option>
</select>
</div>
<div v-if="!auth.isDealer" class="form-control">
<div v-if="!auth.isDealer && (!IS_SCOPED || channelOptions.length)" class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">渠道商</span></label>
<select v-model="filters.channel" class="select select-bordered select-sm w-48">
<option value="">全部渠道商</option>
<option value="">{{ allLabel('渠道商') }}</option>
<option value="__none__">未分配渠道</option>
<option v-for="ch in channelOptions" :key="ch.value" :value="ch.value">{{ ch.label }}</option>
</select>
</div>
<button class="btn btn-primary btn-sm text-white" @click="loadAll">查询</button>
<span class="text-xs text-slate-400 ml-auto self-center">顶部为全量总览;各版面右上角可单独选时间周期</span>
<span class="text-xs text-slate-400 ml-auto self-center">
{{ IS_SCOPED ? `顶部为${SCOPE_PREFIX}总览(筛选项按${SCOPE_PREFIX}关联产品推导);各版面右上角可单独选时间周期`
: '顶部为全量总览;各版面右上角可单独选时间周期' }}
</span>
</div>
</div>
@ -235,6 +235,15 @@ const globalCards = computed(() => IS_AGENT.value ? GLOBAL_CARDS_ALL.filter((c)
const todayCards = computed(() => IS_AGENT.value ? TODAY_CARDS_ALL.filter((c) => !HIDE_FOR_AGENT.includes(c[0])) : TODAY_CARDS_ALL)
const panels = computed(() => IS_AGENT.value ? PANELS_ALL.filter((p) => p.key !== 'user' && p.key !== 'qrcode') : PANELS_ALL)
// 筛选项的「全部」文案。品牌商/渠道商账号的数据范围由后端按归属强制收敛(品牌商=名下渠道商∪产品,
// 渠道商=自己那一个渠道商),下拉里的选项也全部由「品牌商关联产品」推导:
// 应用 = 名下产品绑定的应用(api_getmyapps)、产品 = 名下产品、渠道商 = 名下渠道商。
// 此时再写「全部应用/全部产品」会让人误以为看的是全平台,故按归属改写成「本品牌/本渠道全部 XX」。
const SCOPE_PREFIX = computed(() => auth.isDealer ? '本渠道' : (auth.isBrandAccount ? '本品牌' : ''))
function allLabel(noun: string): string {
return SCOPE_PREFIX.value ? `${SCOPE_PREFIX.value}全部${noun}` : `全部${noun}`
}
const rangeBtns: [number, string][] = [[7, '7天'], [30, '30天'], [90, '90天'], [0, '全部']]
// ── 筛选状态 ──
@ -466,7 +475,8 @@ async function initFilters() {
} catch { appOptions.value = [] }
}
// 产品
// 产品:品牌商/渠道商账号由后端按绑定品牌商收敛(只回本品牌名下产品),
// 代理再按账号勾选的产品过滤一次;两者都拿不到别家品牌的产品。
try {
const dp = await webApi<{ products: any[] }>('getproducts', {})
let ps = (dp && dp.products) ? dp.products : []
@ -476,7 +486,8 @@ async function initFilters() {
productOptions.value = ps.map((p: any) => ({ value: p.id, label: (p.devicename || ('产品#' + p.id)) + ' (#' + p.id + ')' }))
} catch { productOptions.value = [] }
// 渠道商(含停用的:历史统计行仍挂在它名下,筛选项要能选到)
// 渠道商(含停用的:历史统计行仍挂在它名下,筛选项要能选到);
// 品牌商账号由后端强制收敛为「本品牌名下渠道商」。
try {
const dc = await webApi<{ channels: any[] }>('getchannels', { status: -1 })
const cs = (dc && dc.channels) ? dc.channels : []

19
apps/admin/app/pages/device.vue

@ -426,13 +426,13 @@ definePageMeta({ title: '生产管理' })
interface Brand {
id: number
brand: string
providerid?: number
contactmails?: string
}
interface Product {
id: number
devicename: string
brandid: number
providerid?: number // 归属方案商 = 服务ID(CID)
devicetype?: number
devicecodecount?: number
}
@ -629,11 +629,15 @@ const createProductInfo = computed(() => {
})
const createFinalTotal = computed(() => (Number(cNumber.value) || 0) * createProductInfo.value.codeCount)
const importMacCount = computed(() => cMacaddresses.value.split(',').map((s) => s.trim()).filter(Boolean).length)
// 服务ID(CID) = 产品绑定的方案商 id(挂在产品上,不是品牌商上)
const previewCid = computed(() => {
if (!preview.value) return 'N/A'
const b = brands.value.find((x) => x.id === preview.value!.brandid)
return b && b.providerid != null ? Number(b.providerid).toString(16).padStart(4, '0').toUpperCase() : 'N/A'
return cidOfProduct(preview.value.productid)
})
function cidOfProduct(productId: number) {
const p = allProducts.value.find((x) => x.id === productId)
return p && p.providerid ? Number(p.providerid).toString(16).padStart(4, '0').toUpperCase() : 'N/A'
}
// ========== 工具函数 ==========
function fmtTime(ts?: number) {
@ -962,6 +966,11 @@ function buildCreateParams(): Record<string, unknown> | null {
const channelId = cChannel.value.trim()
if (!brandId || !productId) { warn('请选择品牌商和产品'); return null }
if (!channelId) { warn('请选择渠道商'); return null }
// 服务ID(CID) 取自产品绑定的方案商,没绑就生产不出可用的设备码
if (cidOfProduct(productId) === 'N/A') {
warn('该产品未绑定方案商,无法确定服务ID(CID),请先到【产品管理】补上归属方案商')
return null
}
const params: Record<string, unknown> = { brandid: brandId, channelid: channelId, productid: productId, remark: cRemark.value.trim() }
if (createMode.value === 'production') {
params.createtype = 1
@ -1096,8 +1105,6 @@ function openEmailDialog(createResult: CreateResult, brandId: number, productId:
const brand = brands.value.find((f) => f.id === brandId)
const product = allProducts.value.find((p) => p.id === productId)
const productName = product ? product.devicename : '未知产品(ID:' + productId + ')'
// 归属方案商 id 作为服务ID(CID)
const serviceid = brand && brand.providerid != null ? Number(brand.providerid) : null
let emails: string[] = []
if (brand && brand.contactmails) {
@ -1114,7 +1121,7 @@ function openEmailDialog(createResult: CreateResult, brandId: number, productId:
'设备地址区间为:' + (createResult.startmac || '') + '-' + (createResult.endmac || '') + '\n' +
'序号区间:' + (createResult.serialstart ?? '') + ' - ' + (createResult.serialend ?? '') + '\n' +
'生产批次(BatchNo):' + (createResult.batchno || '') + '\n' +
'服务ID(CID): 0x' + (serviceid != null ? serviceid.toString(16).padStart(4, '0').toUpperCase() : 'N/A') + '\n' +
'服务ID(CID): 0x' + cidOfProduct(productId) + '\n' +
'品牌ID(BID): 0x' + (brandId != null ? Number(brandId).toString(16).padStart(4, '0').toUpperCase() : 'N/A') + '\n' +
'产品ID(PID): 0x' + (productId != null ? Number(productId).toString(16).padStart(4, '0').toUpperCase() : 'N/A') + '\n' +
'产品名称: ' + productName + '\n' +

63
apps/admin/app/pages/factory.vue

@ -11,20 +11,24 @@
<table class="table table-zebra">
<thead>
<tr>
<th>ID</th><th>品牌名称</th><th>归属方案商</th><th>分成比例</th><th>联系邮箱</th><th>产品</th><th>操作</th>
<th class="cursor-pointer select-none" @click="toggleSort('id')">ID{{ sortMark('id') }}</th>
<th class="cursor-pointer select-none" @click="toggleSort('brand')">品牌名称{{ sortMark('brand') }}</th>
<th class="cursor-pointer select-none" @click="toggleSort('sharerate')">分成比例{{ sortMark('sharerate') }}</th>
<th>联系邮箱</th><th>产品</th>
<th class="cursor-pointer select-none" @click="toggleSort('createtime')">创建时间{{ sortMark('createtime') }}</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="7" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td>
</tr>
<tr v-else-if="!rows.length">
<tr v-else-if="!viewRows.length">
<td colspan="7" class="text-center text-slate-400 py-8">暂无数据</td>
</tr>
<tr v-for="b in rows" :key="b.id">
<tr v-for="b in viewRows" :key="b.id">
<td class="font-mono text-sm">{{ hexId(b.id) }}</td>
<td class="font-medium">{{ b.brand }}</td>
<td><span class="badge badge-ghost badge-sm">{{ providerName(b.providerid) }}</span></td>
<td class="text-sm">{{ ratePct(b.sharerate) }}</td>
<td class="text-sm">
<template v-if="mailList(b.contactmails).length">
@ -33,6 +37,7 @@
<span v-else class="text-slate-300">-</span>
</td>
<td class="text-sm text-slate-500">{{ b.productlists || '' }}</td>
<td class="text-xs text-slate-500">{{ formatTs(b.createtime) }}</td>
<td>
<div class="flex gap-1">
<button class="btn btn-ghost btn-xs" @click="showForm(b)">编辑</button>
@ -58,13 +63,6 @@
<label class="label"><span class="label-text font-medium">品牌名称 *</span></label>
<input v-model="form.brand" type="text" class="input input-bordered w-full" required />
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">归属方案商</span></label>
<select v-model.number="form.providerid" class="select select-bordered w-full">
<option :value="0">请选择方案商</option>
<option v-for="p in providers" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">分成比例(%)</span></label>
<input v-model.number="form.sharepct" type="number" step="0.01" min="0" max="100" class="input input-bordered w-full" />
@ -156,6 +154,7 @@ import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { useAuthStore } from '~/stores/auth'
import { useTableSort } from '~/composables/useTableSort'
import type { AccountCredential } from '~/components/AccountCredentialDialog.vue'
definePageMeta({ title: '品牌商管理' })
@ -163,13 +162,12 @@ definePageMeta({ title: '品牌商管理' })
interface Brand {
id: number
brand: string
providerid: number
description: string
contactmails: string
productlists: string
sharerate: number // 万分比
createtime?: number
}
interface Provider { id: number; name: string }
// addbrand 的返回:品牌商本体 + 自动建的官方渠道 + (可选)品牌账号凭据
interface AddBrandResp {
channel?: { id: string; name: string }
@ -185,7 +183,6 @@ const auth = useAuthStore()
const view = ref<'list' | 'form'>('list')
const rows = ref<Brand[]>([])
const providers = ref<Provider[]>([])
const loading = ref(false)
const submitting = ref(false)
const isEditing = ref(false)
@ -194,7 +191,7 @@ const newEmail = ref('')
// 比例在表单里用百分比(更直观),提交时换算成后端的万分比整数
const form = reactive({
id: 0, brand: '', providerid: 0, description: '', sharepct: 0,
id: 0, brand: '', description: '', sharepct: 0,
// 仅新增时用:官方渠道名称 + 顺手开一个品牌商账号
officialChannelName: '',
createAccount: false,
@ -216,6 +213,23 @@ const credentialMails = computed(() =>
// 新开账号的凭据回执(密码只有这一次能看到)
const cred = ref<AccountCredential | null>(null)
function formatTs(ts?: number) {
if (!ts) return '-'
const d = new Date(ts * 1000)
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') +
' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
}
// 列表排序:默认按品牌商 ID 升序,表头可切
const { toggleSort, sortMark, applySort } = useTableSort<Brand>({
id: (a, b) => Number(a.id) - Number(b.id),
brand: (a, b) => String(a.brand || '').localeCompare(String(b.brand || ''), 'zh') || Number(a.id) - Number(b.id),
sharerate: (a, b) => Number(a.sharerate || 0) - Number(b.sharerate || 0) || Number(a.id) - Number(b.id),
// 历史品牌商没有创建时间(0),相等时退回按 ID
createtime: (a, b) => Number(a.createtime || 0) - Number(b.createtime || 0) || Number(a.id) - Number(b.id),
}, 'id')
const viewRows = computed(() => applySort(rows.value))
function ratePct(rate?: number) {
return ((Number(rate) || 0) / 100).toFixed(2) + '%'
}
@ -226,25 +240,9 @@ function hexId(id: number) {
function mailList(s?: string) {
return (s || '').split(',').map((e) => e.trim()).filter(Boolean)
}
function providerName(pid?: number) {
if (!pid) return '-'
const p = providers.value.find((x) => x.id === pid)
return p ? p.name : ('方案商#' + pid)
}
async function loadProviders() {
try {
const d = await webApi<{ providers: Provider[] }>('getsolutionproviders', {})
providers.value = d?.providers ?? []
} catch {
providers.value = []
}
}
async function loadData() {
loading.value = true
try {
await loadProviders()
const data = await webApi<{ brands: Brand[] }>('getbrands', {})
rows.value = data?.brands ?? []
} catch (e: any) {
@ -265,7 +263,6 @@ function showForm(b?: Brand) {
isEditing.value = true
form.id = b.id
form.brand = b.brand || ''
form.providerid = b.providerid || 0
form.description = b.description || ''
form.sharepct = (Number(b.sharerate) || 0) / 100
emails.value = mailList(b.contactmails)
@ -273,7 +270,6 @@ function showForm(b?: Brand) {
isEditing.value = false
form.id = 0
form.brand = ''
form.providerid = 0
form.description = ''
form.sharepct = 0
emails.value = []
@ -304,7 +300,6 @@ async function saveBrand() {
const payload: Record<string, unknown> = {
brand: {
brand: form.brand,
providerid: Number(form.providerid) || 0,
description: form.description,
sharerate: Math.round((Number(form.sharepct) || 0) * 100),
contactmails: emails.value.join(','),

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

@ -6,16 +6,57 @@
<h2 class="text-lg font-semibold text-slate-800">产品列表</h2>
<button class="btn btn-primary btn-sm text-white" @click="showForm()">+ 新增产品</button>
</div>
<!-- 筛选:品牌商 / 方案商 / 关键字 -->
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 mb-4">
<div class="flex flex-wrap items-end gap-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">品牌商</span></label>
<select v-model.number="filterBrand" class="select select-bordered select-sm" style="min-width:160px;">
<option :value="0">全部品牌商</option>
<option v-for="b in brandOptions" :key="b.id" :value="b.id">{{ b.brand }}</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.number="filterProvider" class="select select-bordered select-sm" style="min-width:200px;">
<option :value="0">全部方案</option>
<optgroup v-for="g in providerGroups" :key="g.vendor" :label="g.label">
<option v-for="pv in g.items" :key="pv.id" :value="pv.id">{{ pv.name }}({{ hexId(pv.id) }})</option>
</optgroup>
<option :value="-1">未绑定方案商</option>
</select>
</div>
<div class="form-control flex-1" style="min-width:200px;">
<label class="label py-1"><span class="label-text text-xs text-slate-500">关键字</span></label>
<input v-model="keyword" type="text" class="input input-bordered input-sm w-full" placeholder="产品名称 / 产品ID(十六进制)" />
</div>
<button class="btn btn-ghost btn-sm" @click="resetFilter">重置</button>
<span class="text-xs text-slate-400 ml-auto">共 {{ viewRows.length }} / {{ rows.length }} 条</span>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100">
<div class="overflow-x-auto">
<table class="table table-zebra text-sm">
<thead>
<tr><th>ID</th><th>图片</th><th>名称</th><th>品牌商</th><th>绑定应用</th><th>类型</th><th>版本</th><th>功能</th><th>操作</th></tr>
<tr>
<th class="cursor-pointer select-none" @click="toggleSort('id')">ID{{ sortMark('id') }}</th>
<th>图片</th>
<th class="cursor-pointer select-none" @click="toggleSort('devicename')">名称{{ sortMark('devicename') }}</th>
<th class="cursor-pointer select-none" @click="toggleSort('brand')">品牌商{{ sortMark('brand') }}</th>
<th class="cursor-pointer select-none" @click="toggleSort('provider')">方案商{{ sortMark('provider') }}</th>
<th>绑定应用</th>
<th class="cursor-pointer select-none" @click="toggleSort('devicetype')">类型{{ sortMark('devicetype') }}</th>
<th>版本</th><th>功能</th>
<th class="cursor-pointer select-none" @click="toggleSort('createtime')">创建时间{{ sortMark('createtime') }}</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading"><td colspan="9" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td></tr>
<tr v-else-if="!rows.length"><td colspan="9" class="text-center text-slate-400 py-8">暂无数据</td></tr>
<tr v-for="p in rows" :key="p.id">
<tr v-if="loading"><td colspan="11" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td></tr>
<tr v-else-if="!viewRows.length"><td colspan="11" class="text-center text-slate-400 py-8">暂无数据</td></tr>
<tr v-for="p in viewRows" :key="p.id">
<td class="font-mono text-sm">{{ hexId(p.id) }}</td>
<td>
<img v-if="p.productimage" :src="p.productimage" class="w-10 h-10 rounded object-cover" />
@ -23,8 +64,17 @@
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
</div>
</td>
<td class="font-medium">{{ p.devicename }}</td>
<td>
<div class="font-medium">{{ p.devicename }}</div>
<div v-if="p.description" class="text-xs text-slate-400 max-w-xs truncate" :title="p.description">{{ p.description }}</div>
</td>
<td>{{ brands[p.brandid] || '' }}</td>
<td>
<template v-if="p.providerid">
<span class="badge badge-outline badge-sm">{{ providerLabel(p.providerid) }}</span>
</template>
<span v-else class="badge badge-warning badge-sm">未绑定</span>
</td>
<td>
<div v-if="appNamesOf(p).length" class="flex flex-wrap gap-1">
<span v-for="n in appNamesOf(p)" :key="n" class="badge badge-ghost badge-sm">{{ n }}</span>
@ -34,6 +84,7 @@
<td><span class="badge badge-outline badge-sm">{{ deviceTypes[p.devicetype] || p.devicetype }}</span></td>
<td class="text-sm"><span v-if="p.version">{{ p.version }}</span><span v-else class="text-slate-300">-</span></td>
<td><span class="text-xs text-slate-500">{{ featureCount(p) }}/14</span></td>
<td class="text-xs text-slate-500">{{ formatTs(p.createtime) }}</td>
<td>
<div class="flex gap-1">
<button class="btn btn-ghost btn-xs" @click="showForm(p)">编辑</button>
@ -76,6 +127,30 @@
</select>
</div>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-medium">产品说明</span>
<span class="label-text-alt text-slate-400">这款产品是什么、有什么特性(与版本更新说明无关)</span>
</label>
<textarea v-model="form.description" class="textarea textarea-bordered w-full" rows="3" placeholder="如:双麦降噪同传耳机,支持 40 种语言实时翻译、会议转写"></textarea>
</div>
<!-- 归属方案商:生产设备码时的服务ID(CID)取这里;不再由品牌商推导 -->
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-medium">归属方案商 *</span>
<span class="label-text-alt text-slate-400">生产设备码的服务ID(CID)取自这里</span>
</label>
<select v-model.number="form.providerid" class="select select-bordered w-full" required>
<option :value="0" disabled>请选择方案商</option>
<!-- 按芯片厂商分组:同一家方案商公司可能有多套方案,选项带 ID 才分得清是哪一套 -->
<optgroup v-for="g in providerGroups" :key="g.vendor" :label="g.label">
<option v-for="pv in g.items" :key="pv.id" :value="pv.id">{{ pv.name }}({{ hexId(pv.id) }})</option>
</optgroup>
</select>
<span v-if="!providers.length" class="label-text-alt text-amber-500 mt-1">还没有方案商,请先到【方案商管理】创建</span>
</div>
<!-- 绑定应用:决定该产品的数据算在哪个应用下,也决定品牌商/渠道商账号能选到哪些应用 -->
<div class="form-control mb-4">
<label class="label">
@ -177,6 +252,88 @@
</div>
</div>
<!-- ── 新增产品二次确认 ── -->
<dialog ref="confirmModal" class="modal">
<div class="modal-box w-11/12 max-w-2xl">
<h3 class="font-bold text-lg mb-4">确认创建产品</h3>
<div class="bg-slate-50 rounded-lg p-4 text-sm space-y-2 mb-4">
<div class="flex"><span class="text-slate-500 w-28">产品名称</span><span class="font-medium">{{ form.devicename || '-' }}</span></div>
<div v-if="form.description" class="flex"><span class="text-slate-500 w-28 shrink-0">产品说明</span><span class="whitespace-pre-wrap">{{ form.description }}</span></div>
<div class="flex"><span class="text-slate-500 w-28">品牌商(BID)</span>
<span class="font-medium">{{ brandName(form.brandid) }}<span class="font-mono text-slate-400 ml-2">0x{{ hex4(form.brandid) }}</span></span></div>
<div class="flex"><span class="text-slate-500 w-28">方案商(CID)</span>
<span class="font-medium">{{ providerLabel(form.providerid) }}<span class="font-mono text-slate-400 ml-2">0x{{ hex4(form.providerid) }}</span></span></div>
<div class="flex"><span class="text-slate-500 w-28">设备类型</span><span>{{ deviceTypeOptions[form.devicetype] || form.devicetype }}</span></div>
<div class="flex"><span class="text-slate-500 w-28">设备码数量</span><span>{{ form.devicecodecount === 2 ? '双份 (2个/副)' : '单份 (1个/副)' }}</span></div>
<div class="flex"><span class="text-slate-500 w-28">绑定应用</span><span>{{ form.appnames.length ? form.appnames.join('、') : '未绑定' }}</span></div>
<p class="text-xs text-slate-400 pt-1">产品ID(PID) 由系统在创建后分配,会一并写进通知邮件。</p>
</div>
<label class="flex items-center gap-2 cursor-pointer mb-3">
<input v-model="notify.enabled" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
<span class="text-sm font-medium">创建后发送产品通知邮件(方案商ID / 品牌商ID / 产品ID)</span>
</label>
<div v-if="notify.enabled" class="space-y-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">收件人(品牌方)*</span></label>
<input v-model="notify.to" type="email" class="input input-bordered input-sm w-full" placeholder="品牌方联系邮箱" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">抄送</span></label>
<input v-model="notify.cc" type="text" class="input input-bordered input-sm w-full" placeholder="多个邮箱用逗号分隔" />
<div v-if="ccCandidates.length" class="flex flex-wrap gap-1 mt-2">
<button v-for="m in ccCandidates" :key="m.mail" type="button"
class="badge badge-outline badge-sm hover:badge-primary cursor-pointer" @click="addCc(m.mail)">
+ {{ m.mail }}<span class="text-slate-400 ml-1">({{ m.from }})</span>
</button>
</div>
</div>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost btn-sm" @click="closeConfirm">返回修改</button>
<button type="button" class="btn btn-primary btn-sm text-white" :disabled="submitting" @click="doCreate">
<span v-if="submitting" class="loading loading-spinner loading-xs"></span>确认创建
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- ── 产品通知邮件 ── -->
<dialog ref="emailModal" class="modal">
<div class="modal-box w-11/12 max-w-2xl">
<h3 class="font-bold text-lg mb-4">发送产品通知邮件</h3>
<form @submit.prevent="sendNotifyMail">
<div class="grid grid-cols-2 gap-4 mb-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">收件人 *</span></label>
<input v-model="notify.to" type="email" class="input input-bordered input-sm w-full" required />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">抄送</span></label>
<input v-model="notify.cc" type="text" class="input input-bordered input-sm w-full" placeholder="多个邮箱用逗号分隔" />
</div>
</div>
<div class="form-control mb-3">
<label class="label py-1"><span class="label-text text-xs">主题 *</span></label>
<input v-model="notify.subject" type="text" class="input input-bordered input-sm w-full" required />
</div>
<div class="form-control mb-3">
<label class="label py-1"><span class="label-text text-xs">内容 *</span></label>
<textarea v-model="notify.content" class="textarea textarea-bordered w-full font-mono text-xs" rows="12" required></textarea>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost btn-sm" @click="skipNotifyMail">暂不发送</button>
<button type="submit" class="btn btn-primary btn-sm text-white" :disabled="mailSending">
<span v-if="mailSending" class="loading loading-spinner loading-xs"></span>发送
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- ── 版本管理 ── -->
<div v-show="view === 'version'">
<div class="flex items-center gap-3 mb-5">
@ -251,11 +408,16 @@
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { useAuthStore } from '~/stores/auth'
import { CHIP_VENDORS, chipVendorName } from '~/utils/chipvendors'
import { useTableSort } from '~/composables/useTableSort'
definePageMeta({ title: '产品管理' })
interface Product {
id: number; devicename: string; brandid: number; devicetype: number
id: number; devicename: string; brandid: number; providerid?: number; devicetype: number
description?: string // 产品说明(特性描述)
createtime?: number
scanuuid: string; devicecodecount: number; productimage: string
channel: string; channelextra: string
appnames?: string // 绑定应用名 CSV(空=未绑定)
@ -264,9 +426,12 @@ interface Product {
[key: string]: unknown
}
interface Version { id: number; version: string; description: string; updatepackageaddress: string; productid: number }
interface Brand { id: number; brand: string; contactmails?: string }
interface Provider { id: number; name: string; vendor?: number; contactmails?: string }
const { webApi, consoleApi, uploadFile } = useApi()
const { success, error, warn } = useToast()
const auth = useAuthStore()
const deviceTypes: Record<number, string> = { 0: '未知', 1: '经典蓝牙', 2: 'BLE双端', 3: 'BLE单端', 4: '录音笔', 5: '智能眼镜', 6: '激活码卡片' }
const deviceTypeOptions: Record<number, string> = { 0: '未知', 1: '经典蓝牙耳机', 2: 'BLE双端', 3: 'BLE单端', 4: '智能录音笔', 5: '智能眼镜', 6: '激活码卡片' }
@ -291,7 +456,13 @@ const toggleFields = [
const view = ref<'list' | 'form' | 'version'>('list')
const rows = ref<Product[]>([])
const brands = ref<Record<number, string>>({})
const brandOptions = ref<{ id: number; brand: string }[]>([])
const brandOptions = ref<Brand[]>([])
const providers = ref<Provider[]>([])
// 列表筛选:品牌商 / 方案商(-1=未绑定)/ 关键字
const filterBrand = ref(0)
const filterProvider = ref(0)
const keyword = ref('')
// 列表默认按产品 ID 升序(后端返回顺序不保证),表头可切换排序字段
// 可绑定的应用名(应用中心 app_product.name);只有超管/管理员进得来产品页,直接调注册表接口取。
const appOptions = ref<string[]>([])
const loading = ref(false)
@ -305,7 +476,7 @@ function emptyToggles(): Record<string, boolean> {
}
const form = reactive({
id: 0, devicename: '', brandid: 0, devicetype: 0, scanuuid: '', devicecodecount: 1,
id: 0, devicename: '', description: '', brandid: 0, providerid: 0, devicetype: 0, scanuuid: '', devicecodecount: 1,
productimage: '', channel: 'official', appnames: [] as string[],
bindrewardvpitime: 0, bindrewardtranslate: 0, bindrewardmeeting: 0,
toggles: emptyToggles() as Record<string, boolean>,
@ -313,6 +484,65 @@ const form = reactive({
const mobileExtra = reactive({ platform_id: '', platform_key: '', tui: '' })
function hexId(id: number) { return id ? Number(id).toString(16).toUpperCase() : '' }
function hex4(id: number) { return id ? Number(id).toString(16).padStart(4, '0').toUpperCase() : 'N/A' }
function formatTs(ts?: number) {
if (!ts) return '-'
const d = new Date(ts * 1000)
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') +
' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
}
function mailsOf(s?: string) { return String(s || '').split(',').map((e) => e.trim()).filter(Boolean) }
function brandName(id: number) { return brands.value[id] || (id ? '品牌商#' + hexId(id) : '-') }
// 方案商展示:公司名(芯片分组),认不出就退回 hex id
function providerLabel(id?: number) {
if (!id) return '未绑定'
const pv = providers.value.find((x) => x.id === id)
return pv ? `${pv.name}(${chipVendorName(pv.vendor)})` : '方案商#' + hexId(id)
}
// 方案商下拉/筛选按芯片厂商分组(未分组的归到最后一组)
const providerGroups = computed(() => {
const groups = CHIP_VENDORS.map((v) => ({ vendor: v.value, label: v.label, items: [] as Provider[] }))
groups.push({ vendor: 0, label: chipVendorName(0), items: [] as Provider[] })
for (const pv of providers.value) {
const g = groups.find((x) => x.vendor === Number(pv.vendor || 0)) ?? groups[groups.length - 1]!
g.items.push(pv)
}
return groups.filter((g) => g.items.length)
})
const { toggleSort, sortMark, applySort } = useTableSort<Product>({
id: (a, b) => Number(a.id) - Number(b.id),
devicename: (a, b) => String(a.devicename || '').localeCompare(String(b.devicename || ''), 'zh') || Number(a.id) - Number(b.id),
brand: (a, b) => brandName(a.brandid).localeCompare(brandName(b.brandid), 'zh') || Number(a.id) - Number(b.id),
// 芯片方案商:先按芯片厂商分组,再按方案商 id,同一套方案的产品挨着排
provider: (a, b) => {
const pa = providers.value.find((x) => x.id === a.providerid)
const pbv = providers.value.find((x) => x.id === b.providerid)
return (Number(pa?.vendor || 0) - Number(pbv?.vendor || 0)) ||
(Number(a.providerid || 0) - Number(b.providerid || 0)) ||
(Number(a.id) - Number(b.id))
},
devicetype: (a, b) => Number(a.devicetype || 0) - Number(b.devicetype || 0) || Number(a.id) - Number(b.id),
// 历史产品没有创建时间(0),相等时退回按 ID,顺序才稳定
createtime: (a, b) => Number(a.createtime || 0) - Number(b.createtime || 0) || Number(a.id) - Number(b.id),
}, 'id')
const viewRows = computed(() => {
const kw = keyword.value.trim().toLowerCase()
const list = rows.value.filter((p) => {
if (filterBrand.value && Number(p.brandid) !== filterBrand.value) return false
if (filterProvider.value === -1 && Number(p.providerid || 0) !== 0) return false
if (filterProvider.value > 0 && Number(p.providerid || 0) !== filterProvider.value) return false
if (!kw) return true
return String(p.devicename || '').toLowerCase().includes(kw) || hexId(p.id).toLowerCase().includes(kw)
})
return applySort(list)
})
function resetFilter() {
filterBrand.value = 0
filterProvider.value = 0
keyword.value = ''
}
function appNamesOf(p: Product): string[] {
return String(p.appnames || '').split(',').map((s) => s.trim()).filter(Boolean)
}
@ -322,9 +552,9 @@ function featureCount(p: Product) { return toggleFields.filter((t) => !!p[t.key]
async function loadData() {
loading.value = true
try {
const fd = await webApi<{ brands: { id: number; brand: string }[] }>('getbrands', {})
await Promise.all([loadBrands(), loadProviders()])
const fmap: Record<number, string> = {}
;(fd?.brands ?? []).forEach((b) => { fmap[b.id] = b.brand })
brandOptions.value.forEach((b) => { fmap[b.id] = b.brand })
brands.value = fmap
const pd = await webApi<{ products: Product[] }>('getproducts', {})
rows.value = pd?.products ?? []
@ -338,11 +568,19 @@ async function loadData() {
async function loadBrands() {
try {
const fd = await webApi<{ brands: { id: number; brand: string }[] }>('getbrands', {})
const fd = await webApi<{ brands: Brand[] }>('getbrands', {})
brandOptions.value = fd?.brands ?? []
} catch { brandOptions.value = [] }
}
// 方案商:产品必须绑一个,生产设备码的服务ID(CID) 取自它
async function loadProviders() {
try {
const d = await webApi<{ providers: Provider[] }>('getsolutionproviders', {})
providers.value = d?.providers ?? []
} catch { providers.value = [] }
}
// 应用候选取自应用中心(console/api/products/list,即 app_product 表),与部署无关,只按应用名绑。
async function loadAppOptions() {
try {
@ -355,12 +593,15 @@ function showList() { view.value = 'list'; loadData() }
function showForm(p?: Product) {
loadBrands()
loadProviders()
loadAppOptions()
if (p) {
isEditing.value = true
form.id = p.id
form.devicename = p.devicename || ''
form.description = p.description || ''
form.brandid = p.brandid || 0
form.providerid = p.providerid || 0
form.devicetype = p.devicetype || 0
form.scanuuid = p.scanuuid || ''
form.devicecodecount = p.devicecodecount === 2 ? 2 : 1
@ -380,7 +621,9 @@ function showForm(p?: Product) {
isEditing.value = false
form.id = 0
form.devicename = ''
form.description = ''
form.brandid = 0
form.providerid = 0
form.devicetype = 0
form.scanuuid = ''
form.devicecodecount = 1
@ -398,16 +641,16 @@ function showForm(p?: Product) {
view.value = 'form'
}
async function saveProduct() {
submitting.value = true
try {
function buildProductPayload(): Record<string, unknown> {
let channelextra = ''
if (form.channel === 'mobile') {
channelextra = JSON.stringify({ platform_id: mobileExtra.platform_id || '', platform_key: mobileExtra.platform_key || '', tui: mobileExtra.tui || '' })
}
const product: Record<string, unknown> = {
devicename: form.devicename,
description: form.description,
brandid: Number(form.brandid) || 0,
providerid: Number(form.providerid) || 0,
devicetype: Number(form.devicetype) || 0,
scanuuid: form.scanuuid,
devicecodecount: Number(form.devicecodecount) || 1,
@ -421,7 +664,22 @@ async function saveProduct() {
}
toggleFields.forEach((t) => { product[t.key] = form.toggles[t.key] })
if (isEditing.value) product.id = Number(form.id)
await webApi(isEditing.value ? 'updateproduct' : 'addproduct', { product })
return product
}
// 编辑直接保存;新增走二次确认(确认框里同时定好通知邮件的收件人/抄送)
async function saveProduct() {
if (!form.devicename.trim()) { warn('请输入产品名称'); return }
if (!form.brandid) { warn('请选择所属品牌商'); return }
if (!form.providerid) { warn('请选择归属方案商(生产设备码的 CID 取自它)'); return }
if (!isEditing.value) {
prepareNotify()
confirmModal.value?.showModal()
return
}
submitting.value = true
try {
await webApi('updateproduct', { product: buildProductPayload() })
success('保存成功')
showList()
} catch (e: any) {
@ -431,6 +689,118 @@ async function saveProduct() {
}
}
// ── 新增二次确认 + 产品通知邮件 ──
const confirmModal = ref<HTMLDialogElement | null>(null)
const emailModal = ref<HTMLDialogElement | null>(null)
const mailSending = ref(false)
const notify = reactive({ enabled: true, to: '', cc: '', subject: '', content: '' })
// 抄送候选:品牌方剩下的邮箱 + 方案商邮箱,点一下加进抄送
const ccCandidates = computed(() => {
const used = new Set([...mailsOf(notify.to), ...mailsOf(notify.cc)])
const out: { mail: string; from: string }[] = []
const b = brandOptions.value.find((x) => x.id === form.brandid)
mailsOf(b?.contactmails).forEach((m) => { if (!used.has(m)) out.push({ mail: m, from: '品牌方' }) })
const pv = providers.value.find((x) => x.id === form.providerid)
mailsOf(pv?.contactmails).forEach((m) => { if (!used.has(m) && !out.some((x) => x.mail === m)) out.push({ mail: m, from: '方案商' }) })
return out
})
function addCc(mail: string) {
const cur = mailsOf(notify.cc)
if (cur.includes(mail)) return
cur.push(mail)
notify.cc = cur.join(',')
}
// 收件人默认品牌方第一个联系邮箱,其余进抄送
function prepareNotify() {
const b = brandOptions.value.find((x) => x.id === form.brandid)
const mails = mailsOf(b?.contactmails)
notify.enabled = true
notify.to = mails[0] || ''
notify.cc = mails.slice(1).join(',')
notify.subject = ''
notify.content = ''
}
function closeConfirm() { confirmModal.value?.close() }
async function doCreate() {
if (notify.enabled && !notify.to.trim()) { warn('请填写通知邮件的收件人(品牌方邮箱)'); return }
submitting.value = true
try {
const d = await webApi<{ product: Product }>('addproduct', { product: buildProductPayload() })
const pid = Number(d?.product?.id || 0)
success('创建成功')
closeConfirm()
if (notify.enabled) {
buildNotifyMail(pid)
emailModal.value?.showModal()
} else {
showList()
}
} catch (e: any) {
error(e?.message ?? '创建失败')
} finally {
submitting.value = false
}
}
// 邮件正文给客户录入用:方案商ID(CID) / 品牌商ID(BID) / 产品ID(PID) 三个十六进制标识
function buildNotifyMail(pid: number) {
const b = brandOptions.value.find((x) => x.id === form.brandid)
const pv = providers.value.find((x) => x.id === form.providerid)
const today = new Date().toLocaleDateString()
notify.subject = '【产品创建通知】' + form.devicename + ' - ' + today
notify.content =
'尊敬的合作伙伴:\n\n' +
'贵司产品「' + form.devicename + '」已在平台创建完成,相关标识如下,请按此录入生产与开发环境:\n\n' +
'方案商(CID): 0x' + hex4(form.providerid) + ' ' + (pv ? pv.name + '(' + chipVendorName(pv.vendor) + ')' : '') + '\n' +
'品牌商(BID): 0x' + hex4(form.brandid) + ' ' + (b ? b.brand : '') + '\n' +
'产品ID(PID): 0x' + hex4(pid) + ' ' + form.devicename + '\n' +
'设备类型: ' + (deviceTypeOptions[form.devicetype] || form.devicetype) + '\n' +
(form.description ? '产品说明: ' + form.description + '\n' : '') +
'设备码数量(每副): ' + (form.devicecodecount === 2 ? '2 个' : '1 个') + '\n' +
'绑定应用: ' + (form.appnames.length ? form.appnames.join('、') : '未绑定') + '\n\n' +
'后续生产设备码时,服务ID(CID) 即以上方案商 ID。\n\n' +
'此致\n敬礼!'
}
function skipNotifyMail() {
emailModal.value?.close()
showList()
}
// 与生产通知一致走 multipart 的 api_sendsmtpemail(此处无附件)
async function sendNotifyMail() {
if (!notify.to.trim()) { warn('请填写收件人'); return }
mailSending.value = true
try {
const fd = new FormData()
fd.append('to', notify.to.trim())
fd.append('subject', notify.subject)
fd.append('content', notify.content)
const cc = notify.cc.trim()
if (cc) fd.append('cc', cc)
const headers: Record<string, string> = {}
if (auth.token) headers['Authorization'] = auth.token
if (auth.currentAppId) headers['X-App-Id'] = String(auth.currentAppId)
const resp = await fetch('/web/api/api_sendsmtpemail', { method: 'POST', headers, body: fd })
const data = await resp.json()
if (data.code === 0) {
success('通知邮件已发送')
emailModal.value?.close()
showList()
} else {
error('邮件发送失败: ' + (data.msg || '未知错误') + '(产品已创建,可在此重试)')
}
} catch (e: any) {
error('邮件发送失败: ' + (e?.message ?? '') + '(产品已创建,可在此重试)')
} finally {
mailSending.value = false
}
}
function delProduct(p: Product) {
if (!window.confirm(`确定删除产品 "${p.devicename}" 吗?`)) return
webApi('delproduct', { id: p.id })

142
apps/admin/app/pages/provider.vue

@ -6,25 +6,55 @@
<h2 class="text-lg font-semibold text-slate-800">方案商列表</h2>
<button class="btn btn-primary btn-sm text-white" @click="showForm()">+ 新增方案商</button>
</div>
<!-- 筛选:芯片分组 + 关键字(公司名 / 方案说明 / ID) -->
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 mb-4">
<div class="flex flex-wrap items-end gap-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">芯片分组</span></label>
<select v-model.number="filterVendor" class="select select-bordered select-sm w-40">
<option :value="-1">全部芯片</option>
<option v-for="v in CHIP_VENDORS" :key="v.value" :value="v.value">{{ v.label }}</option>
<option :value="0">未分组</option>
</select>
</div>
<div class="form-control flex-1 min-w-[220px]">
<label class="label py-1"><span class="label-text text-xs text-slate-500">关键字</span></label>
<input v-model="keyword" type="text" class="input input-bordered input-sm w-full" placeholder="方案商公司名 / 方案说明 / ID(十六进制)" />
</div>
<button class="btn btn-ghost btn-sm" @click="resetFilter">重置</button>
<span class="text-xs text-slate-400 ml-auto">共 {{ viewRows.length }} / {{ rows.length }} 条</span>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100">
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>ID</th><th>方案商名称</th><th>描述</th><th>联系邮箱</th><th>创建时间</th><th>操作</th>
<th class="cursor-pointer select-none" @click="toggleSort('id')">ID{{ sortMark('id') }}</th>
<th class="cursor-pointer select-none" @click="toggleSort('vendor')">芯片分组{{ sortMark('vendor') }}</th>
<th class="cursor-pointer select-none" @click="toggleSort('name')">方案商公司{{ sortMark('name') }}</th>
<th>方案说明</th>
<th>联系邮箱</th>
<th class="cursor-pointer select-none" @click="toggleSort('createtime')">创建时间{{ sortMark('createtime') }}</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="6" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td>
<td colspan="7" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td>
</tr>
<tr v-else-if="!rows.length">
<td colspan="6" class="text-center text-slate-400 py-8">暂无数据</td>
<tr v-else-if="!viewRows.length">
<td colspan="7" class="text-center text-slate-400 py-8">暂无数据</td>
</tr>
<tr v-for="p in rows" :key="p.id">
<tr v-for="p in viewRows" :key="p.id">
<td class="font-mono text-sm">{{ hexId(p.id) }}</td>
<td>
<span class="badge badge-sm" :class="p.vendor ? 'badge-primary badge-outline' : 'badge-ghost'">{{ chipVendorName(p.vendor) }}</span>
</td>
<td class="font-medium">{{ p.name }}</td>
<td class="text-sm text-slate-500">{{ p.description || '' }}</td>
<td class="text-sm text-slate-500 max-w-xs truncate" :title="p.description">{{ p.description || '' }}</td>
<td class="text-sm">
<template v-if="mailList(p.contactmails).length">
<span v-for="(e, i) in mailList(p.contactmails)" :key="i" class="badge badge-ghost badge-sm mr-1">{{ e }}</span>
@ -53,13 +83,46 @@
</div>
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-2xl">
<form @submit.prevent="saveProvider">
<!-- 方案商 ID:手工指定的有含义编码,落库后不可改(已烧录进设备) -->
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">方案商名称 *</span></label>
<input v-model="form.name" type="text" class="input input-bordered w-full" required />
<label class="label"><span class="label-text font-medium">方案商 ID(十六进制)*</span></label>
<div class="flex items-stretch">
<span class="bg-slate-100 text-slate-400 font-mono px-3 flex items-center rounded-l-lg border border-r-0 border-slate-200">0x</span>
<input
v-model="form.idHex"
type="text"
class="input input-bordered w-full font-mono uppercase rounded-l-none"
:disabled="isEditing"
placeholder="C101"
maxlength="8"
required
/>
</div>
<span class="label-text-alt text-slate-400 mt-1">
方案商 ID 不自增,由人工指定成有含义的编码(如 C101 = 杰里 01 方案)。新增一律用 C 开头段(C1xx…);
历史 ID(ABEF / ABF0~ABF4 / AB21)已烧录进设备,<b>创建后不可修改</b>。
</span>
<span v-if="!isEditing && usedIdHint" class="label-text-alt text-slate-400 mt-1">{{ usedIdHint }}</span>
</div>
<!-- 芯片分组:一个芯片厂商可以有多套方案,每套方案单独一条记录 -->
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">描述</span></label>
<textarea v-model="form.description" class="textarea textarea-bordered w-full" rows="3"></textarea>
<label class="label"><span class="label-text font-medium">芯片分组 *</span></label>
<select v-model.number="form.vendor" class="select select-bordered w-full" required>
<option :value="0" disabled>请选择芯片厂商</option>
<option v-for="v in CHIP_VENDORS" :key="v.value" :value="v.value">{{ v.label }}</option>
</select>
<span class="label-text-alt text-slate-400 mt-1">同一个芯片厂商可以有多套方案,每套方案单独建一条方案商记录,靠这里归组。</span>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">方案商公司名称 *</span></label>
<input v-model="form.name" type="text" class="input input-bordered w-full" placeholder="如:深圳市XX科技有限公司" required />
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">方案说明</span></label>
<textarea v-model="form.description" class="textarea textarea-bordered w-full" rows="3" placeholder="这套方案是做什么的:芯片型号、适用品类、能力范围等"></textarea>
</div>
<!-- 多邮箱管理 -->
@ -94,9 +157,11 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { useToast } from '~/composables/useToast'
import { CHIP_VENDORS, chipVendorName } from '~/utils/chipvendors'
import { useTableSort } from '~/composables/useTableSort'
definePageMeta({ title: '方案商管理' })
@ -106,6 +171,7 @@ interface Provider {
description: string
contactmails: string
createtime: number
vendor: number
}
const { webApi } = useApi()
@ -119,7 +185,11 @@ const isEditing = ref(false)
const emails = ref<string[]>([])
const newEmail = ref('')
const form = reactive({ id: 0, name: '', description: '' })
// 列表筛选 / 排序
const filterVendor = ref(-1) // -1 全部;0 未分组;其余为芯片厂商值
const keyword = ref('')
const form = reactive({ id: 0, idHex: '', name: '', description: '', vendor: 0 })
function hexId(id: number) {
return id ? Number(id).toString(16).toUpperCase() : ''
@ -134,6 +204,38 @@ function formatTs(ts?: number) {
' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
}
// 同芯片厂商下已占用的 ID,填新 ID 时提示一眼,避免撞号
const usedIdHint = computed(() => {
if (!form.vendor) return ''
const used = rows.value.filter((p) => Number(p.vendor) === form.vendor).map((p) => hexId(p.id))
return used.length ? `该芯片厂商已用 ID:${used.join('、')}` : '该芯片厂商下还没有方案商'
})
// 列表排序:默认按方案商 ID 升序,表头可切
const { toggleSort, sortMark, applySort } = useTableSort<Provider>({
id: (a, b) => Number(a.id) - Number(b.id),
name: (a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'zh') || Number(a.id) - Number(b.id),
// 同芯片厂商再按 ID,同厂商的多套方案挨着排
vendor: (a, b) => Number(a.vendor || 0) - Number(b.vendor || 0) || Number(a.id) - Number(b.id),
createtime: (a, b) => Number(a.createtime || 0) - Number(b.createtime || 0) || Number(a.id) - Number(b.id),
}, 'id')
const viewRows = computed(() => {
const kw = keyword.value.trim().toLowerCase()
const list = rows.value.filter((p) => {
if (filterVendor.value >= 0 && Number(p.vendor || 0) !== filterVendor.value) return false
if (!kw) return true
return (p.name || '').toLowerCase().includes(kw) ||
(p.description || '').toLowerCase().includes(kw) ||
hexId(p.id).toLowerCase().includes(kw)
})
return applySort(list)
})
function resetFilter() {
filterVendor.value = -1
keyword.value = ''
}
async function loadData() {
loading.value = true
try {
@ -156,14 +258,18 @@ function showForm(p?: Provider) {
if (p) {
isEditing.value = true
form.id = p.id
form.idHex = hexId(p.id)
form.name = p.name || ''
form.description = p.description || ''
form.vendor = Number(p.vendor || 0)
emails.value = mailList(p.contactmails)
} else {
isEditing.value = false
form.id = 0
form.idHex = ''
form.name = ''
form.description = ''
form.vendor = filterVendor.value > 0 ? filterVendor.value : 0
emails.value = []
}
newEmail.value = ''
@ -182,16 +288,26 @@ function removeEmail(idx: number) {
}
async function saveProvider() {
if (!form.vendor) { warn('请选择芯片分组'); return }
let id = form.id
if (!isEditing.value) {
const hex = form.idHex.trim().replace(/^0x/i, '')
if (!/^[0-9a-fA-F]{1,8}$/.test(hex)) { warn('方案商 ID 需填十六进制,如 C101'); return }
id = Number.parseInt(hex, 16)
if (!id) { warn('方案商 ID 不能为 0'); return }
if (rows.value.some((p) => Number(p.id) === id)) { warn(`ID ${hex.toUpperCase()} 已被占用`); return }
}
submitting.value = true
try {
const payload: { provider: Record<string, unknown> } = {
provider: {
id,
name: form.name,
description: form.description,
contactmails: emails.value.join(','),
vendor: form.vendor,
},
}
if (isEditing.value) payload.provider.id = Number(form.id)
await webApi(isEditing.value ? 'updatesolutionprovider' : 'addsolutionprovider', payload)
success('保存成功')
showList()

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

@ -25,6 +25,7 @@ interface AppBind {
interface LoginData {
account: string
account_id?: number
identity: number
token: string
access: string
@ -64,6 +65,7 @@ export const useAuthStore = defineStore('auth', {
state: () => ({
token: '' as string,
account: '' as string,
accountId: 0 as number, // 账号表 id(0 = yaml 里的引导超管,库里没有记录)
identity: 0 as number,
access: [] as string[],
apps: [] as string[],
@ -102,23 +104,12 @@ export const useAuthStore = defineStore('auth', {
},
actions: {
setLoginData(data: LoginData) {
this.token = data.token
this.account = data.account
this.identity = data.identity
this.access = parseComma(data.access)
this.apps = parseComma(data.apps)
this.regions = parseComma(data.regions)
this.products = parseComma(data.products)
this.brandid = data.brandid ?? 0
this.channelid = data.channelid ?? ''
this.appbinds = data.appbinds ?? []
if (this.appbinds.length > 0 && !this.currentAppId) {
this.currentAppId = this.appbinds[0].id
}
// 登录态落盘(三处写入共用一份,加字段只改这里)
persist() {
saveToStorage({
token: this.token,
account: this.account,
accountId: this.accountId,
identity: this.identity,
access: this.access,
apps: this.apps,
@ -132,45 +123,38 @@ export const useAuthStore = defineStore('auth', {
})
},
setLoginData(data: LoginData) {
this.token = data.token
this.account = data.account
this.accountId = data.account_id ?? 0
this.identity = data.identity
this.access = parseComma(data.access)
this.apps = parseComma(data.apps)
this.regions = parseComma(data.regions)
this.products = parseComma(data.products)
this.brandid = data.brandid ?? 0
this.channelid = data.channelid ?? ''
this.appbinds = data.appbinds ?? []
if (this.appbinds.length > 0 && !this.currentAppId) {
this.currentAppId = this.appbinds[0].id
}
this.persist()
},
setCurrentApp(id: number) {
this.currentAppId = id
saveToStorage({
token: this.token,
account: this.account,
identity: this.identity,
access: this.access,
apps: this.apps,
regions: this.regions,
products: this.products,
brandid: this.brandid,
channelid: this.channelid,
appbinds: this.appbinds,
currentAppId: this.currentAppId,
siteTitle: this.siteTitle,
})
this.persist()
},
setSiteTitle(title: string) {
this.siteTitle = title
saveToStorage({
token: this.token,
account: this.account,
identity: this.identity,
access: this.access,
apps: this.apps,
regions: this.regions,
products: this.products,
brandid: this.brandid,
channelid: this.channelid,
appbinds: this.appbinds,
currentAppId: this.currentAppId,
siteTitle: this.siteTitle,
})
this.persist()
},
logout() {
this.token = ''
this.account = ''
this.accountId = 0
this.identity = 0
this.access = []
this.apps = []
@ -191,6 +175,7 @@ export const useAuthStore = defineStore('auth', {
if (!stored) return
this.token = (stored.token as string) ?? ''
this.account = (stored.account as string) ?? ''
this.accountId = (stored.accountId as number) ?? 0
this.identity = (stored.identity as number) ?? 0
this.access = (stored.access as string[]) ?? []
this.apps = (stored.apps as string[]) ?? []

23
apps/admin/app/utils/chipvendors.ts

@ -0,0 +1,23 @@
// 芯片方案厂商分组,对应后端 DBSolutionProvider.vendor(proto 的 SolutionVendor 枚举)。
// 一个芯片厂商下可能有多套方案,每套方案是一条方案商记录,靠这个分组归拢。
// 新增芯片厂商要三处同步补同一个数值:
// apps/proto/db.proto 的 SolutionVendor
// apps/services/modules/console/api_device.go 的 chipVendorNames
// 本文件的 CHIP_VENDORS
export interface ChipVendor {
value: number
label: string
}
export const CHIP_VENDORS: ChipVendor[] = [
{ value: 1, label: '杰里' },
{ value: 2, label: '蓝汛' },
{ value: 3, label: '恒玄' },
{ value: 4, label: '博通' },
]
export function chipVendorName(v?: number | null): string {
const n = Number(v || 0)
if (!n) return '未分组'
return CHIP_VENDORS.find((x) => x.value === n)?.label ?? ('厂商#' + n)
}

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

@ -52,6 +52,7 @@ export const MENU_GROUPS: MenuGroup[] = [
items: [
{ id: 'globalconfig', label: '全局环境配置', to: '/globalconfig', icon: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9' },
{ id: 'appconfig', label: '应用环境配置', to: '/appconfig', icon: 'M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4' },
{ id: 'appgoods', label: '应用商品配置', to: '/appgoods', icon: 'M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z' },
{ id: 'meettemplate', label: '会议模板', to: '/meettemplates', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
{ id: 'svcconfig', label: '第三方服务配置', to: '/serviceconfig', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
{ id: 'agents', label: 'Agent 配置', to: '/agents', icon: 'M12 3a2 2 0 012 2v1h3a2 2 0 012 2v3a2 2 0 01-2 2h-1v3a2 2 0 01-2 2H8a2 2 0 01-2-2v-3H5a2 2 0 01-2-2V8a2 2 0 012-2h3V5a2 2 0 012-2zM9 13h.01M15 13h.01' },

2
apps/admin/app/utils/svchealth.ts

@ -1,5 +1,5 @@
// 第三方服务巡检(svc_health)的前端共享类型与展示辅助。
// 巡检页(pages/svcinspect.vue) 与仪表盘告警条(components/SvcAlertBanner.vue) 共用,
// 巡检页(pages/svcinspect.vue) 与顶栏通知铃铛(components/SvcAlertBell.vue) 共用,
// 保证两处对「状态怎么叫、原因怎么写、区域怎么显示」的口径完全一致。
// 与后端 comm/svchealth.go 的 SvcHealth 字段一一对应。

21
apps/proto/db.proto

@ -141,12 +141,14 @@ message DBAppConfigItem{
}
//厂家数据管理
//方案商所属厂商(分组维度)。一个厂商可以有多个方案,每个方案一条 DBSolutionProvider 记录。
//方案商所属芯片厂商(分组维度)。一个芯片厂商可以有多套方案,每套方案一条 DBSolutionProvider 记录。
//新增厂商在这里加值,同时补 modules/console/api_device.go 的 chipVendorNames 与前端 utils/chipvendors.ts。
enum SolutionVendor {
VENDOR_UNKNOWN = 0; //未指定
VENDOR_JIELI = 1; //杰里
VENDOR_LANXUN = 2; //蓝汛
VENDOR_HENGXUAN = 3; //恒玄
VENDOR_BOTONG = 4; //博通
}
//方案商(芯片/整体方案提供方)
@ -154,22 +156,25 @@ enum SolutionVendor {
//历史 ID(ABEF/ABF0~ABF4/AB21)已烧录进设备,不可变更;新增方案商一律用 C 开头段(C1xx…)。
message DBSolutionProvider{
uint32 id = 1; //@go_tags(`gorm:"id;primaryKey" json:"id"`) 方案商id(手工指定,不自增)
string name = 2; //@go_tags(`gorm:"name;index" json:"name"`) 方案商名称
string description = 3; //@go_tags(`gorm:"description" json:"description"`) 描述
string name = 2; //@go_tags(`gorm:"name;index" json:"name"`) 方案商公司名称
string description = 3; //@go_tags(`gorm:"description" json:"description"`) 方案说明(这套方案做什么)
string contactmails = 4; //@go_tags(`gorm:"contactmails" json:"contactmails"`) 联系人邮箱
int64 createtime = 5; //@go_tags(`gorm:"createtime" json:"createtime"`) 创建时间
SolutionVendor vendor = 6; //@go_tags(`gorm:"vendor;index" json:"vendor"`) 所属厂商分组(杰里/蓝汛/恒玄)
SolutionVendor vendor = 6; //@go_tags(`gorm:"vendor;index" json:"vendor"`) 所属芯片厂商分组(杰里/蓝汛/恒玄/博通)
}
//品牌商(原厂家;归属一个方案商)
message DBBrand{
uint32 id = 1; //@go_tags(`gorm:"id;primaryKey;autoIncrement" json:"id"`) 品牌商id
string brand = 2; //@go_tags(`gorm:"brand" json:"brand"`) 品牌商名称
uint32 providerid = 3; //@go_tags(`gorm:"providerid;index" json:"providerid"`) 归属方案商id
//【已废弃】品牌商不归属方案商——方案商是「产品用了哪套芯片方案」,同一品牌不同产品可以用不同方案,
//所以归属挂在 DBProduct.providerid 上。本字段与 brand.providerid 列均已停写,仅为兼容旧数据保留,勿再使用。
uint32 providerid = 3; //@go_tags(`gorm:"providerid;index" json:"providerid"`) [已废弃]原「归属方案商id」
string description = 4; //@go_tags(`gorm:"description" json:"description"`) 描述
string contactmails = 5; //@go_tags(`gorm:"contactmails" json:"contactmails"`) 联系人邮箱 xxx@qq.com,xxx@gmail.com
string productlists = 7; //@go_tags(`gorm:"productlists" json:"productlists"`) 产品列表 1,2,3
int32 sharerate = 8; //@go_tags(`gorm:"sharerate" json:"sharerate"`) 分成比例(万分比):平台从订单实付里分给品牌商的总提成,渠道商再从这份里切
int64 createtime = 9; //@go_tags(`gorm:"createtime" json:"createtime"`) 创建时间(秒);历史品牌商为0
}
//渠道商(挂在品牌商下,一个品牌商可绑定多个渠道商)
@ -280,6 +285,12 @@ message DBProduct{
//产品归属的应用(应用中心 app_product.name,如 Voitrans,Lumi),CSV 可多选、空=未绑定。
//品牌商/渠道商账号的「应用」下拉即由本字段推导:只列出自己品牌名下产品绑定过的应用。
string appnames = 35; //@go_tags(`gorm:"appnames;size:255" json:"appnames"`) 绑定应用名CSV(空=未绑定)
//产品用的是哪套芯片方案(DBSolutionProvider.id)。生产设备码时的服务ID(CID)直接取这里——
//以前从品牌商推导,但同一品牌的不同产品可能用不同方案商的方案,推导会串号,故改为产品级绑定,新建产品必填。
uint32 providerid = 36; //@go_tags(`gorm:"providerid;index" json:"providerid"`) 归属方案商id(CID)
int64 createtime = 37; //@go_tags(`gorm:"createtime" json:"createtime"`) 创建时间(秒);历史产品为0
//产品说明:这款产品是什么、有什么特性,给后台运营与客户看的,与版本更新说明(updatedescription)无关。
string description = 38; //@go_tags(`gorm:"description" json:"description"`) 产品说明(特性描述)
}

37
apps/services/comm/appgoods.go

@ -0,0 +1,37 @@
package comm
// AppGoods 应用内购商品配置:一行 = 某应用的一个内购商品(如 vip_month_1 / ai_点数包)。
// 存 console 主库(与 app_registry / app_product 同库),**按应用名 app_name 隔离**——
// 同一应用在上海/日本/新加坡等多个部署环境共用同一份商品配置,不随部署走。
//
// 放在 comm 的原因:这张表由 console 后台维护、业务侧(pay 发货)读取,两边共用同一结构,
// 与 AgentConfig / ThirdSvcConfig 等跨模块公共库表同一套路。业务侧取自己那份用 AppName()。
//
// 字段与 pb.DBGoods 一一对应(多出 AppName/Sort/Remark/时间戳),多语言沿用 DBGoods.localname 的 JSON 形态:
//
// {"zh-CN":{"name":"月度会员","currency":"¥","price":30},"en-US":{...}}
type AppGoods struct {
Id uint32 `gorm:"primaryKey;autoIncrement;column:id" json:"id"`
AppName string `gorm:"column:app_name;size:64;index" json:"app_name"` // 所属应用名(app_product.name,如 Voitrans)
GoodsId string `gorm:"column:goods_id;size:128;index" json:"goods_id"` // 商品标识(= DBGoods.id / DBPayOrder.product_id,苹果/谷歌后台的内购商品 ID),应用内唯一
Enable bool `gorm:"column:enable" json:"enable"` // 是否上架
Name string `gorm:"column:name;size:128" json:"name"` // 默认名称(无对应语言时的兜底)
// ── 售价与发放内容(语义同 pb.DBGoods)──
Price int32 `gorm:"column:price" json:"price"` // 默认价格(未配该语言时的兜底价)
Usagetype int32 `gorm:"column:usagetype" json:"usagetype"` // 商品类型 pb.UsageType:1翻译 2会议 3智能体 4VIP时长
Ainum int64 `gorm:"column:ainum" json:"ainum"` // 发放的 AI 资源点
Meetnum int64 `gorm:"column:meetnum" json:"meetnum"` // 发放的会议资源点
Tradenum int64 `gorm:"column:tradenum" json:"tradenum"` // 发放的翻译资源点
Viptime int32 `gorm:"column:viptime" json:"viptime"` // 发放的会员时长
// 多语言名称/货币/价格,JSON 同 pb.DBGoods.localname;后台读写经 console 的 api_goods.go 结构化收发。
Localname string `gorm:"column:localname;type:text" json:"localname"`
Sort int32 `gorm:"column:sort" json:"sort"` // 排序权重(小的在前,同值按 id)
Remark string `gorm:"column:remark;size:255" json:"remark"`
Createtime int64 `gorm:"column:createtime" json:"createtime"`
Updatetime int64 `gorm:"column:updatetime" json:"updatetime"`
}
func (AppGoods) TableName() string { return TableAppGoods }

1
apps/services/comm/const.go

@ -105,6 +105,7 @@ const (
TableCallTranslatePair = "call_translate_pair" //【已废弃】旧通话翻译语言对表(区域×单语言对×直接拼服务),由 call_translate_rule 取代,代码不再读写
TableCallTranslateRule = "call_translate_rule" //通话翻译规则表(区域×多源/多目标语言×优先级 → agent)
TableChannelAppCfg = "channel_app" //渠道分发配置表(console 主库,按 app_name 作用域;版本/强更/下载地址/支付渠道开关)
TableAppGoods = "app_goods" //应用内购商品配置表(console 主库,按 app_name 作用域;多语言名称/货币/价格存 localname JSON)
)
// NATS 管道 subject 与事件结构统一定义在 comm/nats.go(含 Nats_ConfigEventSubjectPrefix 等)。

5
apps/services/modules/api/api_addbrand.go

@ -1,10 +1,12 @@
package api
import (
"strings"
"time"
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
"strings"
)
const defaultFactoryContactMail = "358030594@qq.com"
@ -16,6 +18,7 @@ func (this *apiComp) AddBrand(session comm.IUserSession, req *pb.ApiAddBrandReq)
)
if req.Brand != nil {
req.Brand.Contactmails = ensureDefaultContactMail(req.Brand.Contactmails)
req.Brand.Createtime = time.Now().Unix()
}
if err = this.module.model.addfactory(req.Brand); err != nil {
errdata = &pb.ErrorData{

5
apps/services/modules/api/api_addproduct.go

@ -1,10 +1,12 @@
package api
import (
"fmt"
"time"
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
"fmt"
)
// 添加配置
@ -28,6 +30,7 @@ func (this *apiComp) AddProduct(session comm.IUserSession, req *pb.ApiAddProduct
this.module.Error("AddProduct!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: err.Error()})
return
}
req.Product.Createtime = time.Now().Unix()
if err = this.module.model.addproducts(req.Product); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,

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

@ -30,7 +30,7 @@ func (this *apiComp) UpdateBrand(session comm.IUserSession, req *pb.ApiUpdateBra
// 仅允许客户端覆盖可编辑字段,productlists 等由服务端维护的字段不接受前端写入,
// 防止前端表单缺字段或携带陈旧值时把产品列表覆盖回旧值。
old.Brand = req.Brand.Brand
old.Providerid = req.Brand.Providerid
// providerid 不再由品牌商维护:方案商归属挂在产品上(DBProduct.providerid),这里刻意不覆盖旧值。
old.Description = req.Brand.Description
old.Contactmails = req.Brand.Contactmails
// 分成比例只有超管/管理员能改(品牌商自己改比例等于自定分成)。

12
apps/services/modules/api/api_updateproduct.go

@ -17,13 +17,23 @@ func (this *apiComp) UpdateProduct(session comm.IUserSession, req *pb.ApiUpdateP
return
}
if _, err = this.module.model.product(req.Product.Id); err != nil {
var old *pb.DBProduct
if old, err = this.module.model.product(req.Product.Id); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_ReqParameterError,
Message: pb.ErrorCode_ReqParameterError.String(),
}
return
}
// 归属方案商(生产设备码的 CID):更新是全字段 Save,没传就会被清空,
// 所以传 0 视为「不改」,沿用原绑定。绑定与校验统一在 console 后台做。
if req.Product.Providerid == 0 && old != nil {
req.Product.Providerid = old.Providerid
}
// 创建时间同理:只在新增时写一次,更新没回传就沿用旧值。
if req.Product.Createtime == 0 && old != nil {
req.Product.Createtime = old.Createtime
}
// 指定了 versionid 时,从版本表同步版本/描述/升级包地址,前端无需重复传
if req.Product.Versionid != 0 {

16
apps/services/modules/api/model.go

@ -76,6 +76,10 @@ func (this *modelComp) Init(service core.IService, module core.IModule, comp cor
//设置brand表的主键从0xA001开始(仅空表生效,跨 MySQL/Postgres 通用)
postgres.AutoIncrementStart(comm.TableBrand, "id", 0xA001)
}
//createtime 是后加字段:CreateTable 对已存在的表跳过 AutoMigrate,故显式补列(同 console 的 ensureDeviceTables)
if res := postgres.Exec("ALTER TABLE " + comm.TableBrand + " ADD COLUMN IF NOT EXISTS createtime bigint DEFAULT 0"); res.Error != nil {
this.module.Errorln(res.Error)
}
if err = postgres.CreateTable(comm.TableChannel, &pb.DBChannel{}); err != nil {
this.module.Errorln(err)
}
@ -88,6 +92,18 @@ func (this *modelComp) Init(service core.IService, module core.IModule, comp cor
//设置product表的主键从0xB001开始(仅空表生效,跨 MySQL/Postgres 通用)
postgres.AutoIncrementStart(comm.TableProduct, "id", 0xB001)
}
//providerid/createtime/description 是后加字段:CreateTable 对已存在的表跳过 AutoMigrate,故显式补列
//(与 console 的 ensureDeviceTables 同口径;两边指向同一设备库时重复执行无副作用)。
for _, ddl := range []string{
"ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS providerid bigint DEFAULT 0",
"ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS createtime bigint DEFAULT 0",
"ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS description text DEFAULT ''",
"CREATE INDEX IF NOT EXISTS idx_product_providerid ON " + comm.TableProduct + " (providerid)",
} {
if res := postgres.Exec(ddl); res.Error != nil {
this.module.Errorln(res.Error)
}
}
if err = postgres.CreateTable(comm.TableProductVersion, &pb.DBProductVersion{}); err != nil {
this.module.Errorln(err)
}

67
apps/services/modules/console/api_agent.go

@ -1,6 +1,7 @@
package console
import (
"errors"
"strconv"
"strings"
"time"
@ -151,6 +152,42 @@ func normalizeAgent(a *AgentConfig) {
a.Variables = vars
}
// ============================== Agent ID 生成 ==============================
// agentIdPrefix agent 业务 id 的类型前缀:id 由后台生成,但仍希望肉眼能看出是哪类 agent。
func agentIdPrefix(t string) string {
switch t {
case AgentTypeChat:
return "chat"
case AgentTypeStsChat:
return "sts"
case AgentTypeTranslate:
return "trans"
case AgentTypeAstTranslate:
return "ast"
}
return "agent"
}
// genAgentId 生成作用域内唯一的 agent 业务 id(形如 chat-a3k7m2qp)。
// 运营只填名称/类型,id 一律由后台生成——避免人工起名撞车,也免得下发给客户端的标识随手改。
// 唯一性最终由 (app_name,id) 唯一索引兜底,这里做几次探测降低冲突概率。
func genAgentId(appName, agentType string) (string, error) {
prefix := agentIdPrefix(agentType)
for i := 0; i < 8; i++ {
code, err := comm.GenerateShortCode(8)
if err != nil {
return "", err
}
id := prefix + "-" + strings.ToLower(code)
exist := &AgentConfig{}
if err := postgres.FindOne(comm.TableAgentConfig, exist, "app_name=? AND id=?", appName, id); err != nil {
return id, nil // 查不到 = 没被占用
}
}
return "", errors.New("生成智能体ID失败,请重试")
}
// ============================== Agent CRUD ==============================
// getAgents 列出某作用域(app_name)下全部 agent 配置。app_name=” 为全局默认。
@ -192,19 +229,33 @@ func (this *serverComp) addAgent(c *gin.Context) {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
if strings.TrimSpace(m.Id) == "" || strings.TrimSpace(m.Name) == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "智能体ID 和 名称 必填")
if !this.requireAppNameScope(c, m.AppName) { // 应用级账号只能在自己名下的应用里建
return
}
exist := &AgentConfig{}
if err := postgres.FindOne(comm.TableAgentConfig, exist, "app_name=? AND id=?", m.AppName, m.Id); err == nil {
writeErr(c, pb.ErrorCode_ReqParameterError, "智能体ID 已存在: "+m.Id)
m.Name = strings.TrimSpace(m.Name)
if m.Name == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "智能体名称 必填")
return
}
if msg := validateAgentType(&m, nil); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
// 业务 id 由后台生成:前端不再填。仍兼容显式传入(导入/迁移场景),此时校验作用域内不重复。
if m.Id = strings.TrimSpace(m.Id); m.Id == "" {
id, err := genAgentId(m.AppName, m.Type)
if err != nil {
writeErr(c, pb.ErrorCode_SystemError, err.Error())
return
}
m.Id = id
} else {
exist := &AgentConfig{}
if err := postgres.FindOne(comm.TableAgentConfig, exist, "app_name=? AND id=?", m.AppName, m.Id); err == nil {
writeErr(c, pb.ErrorCode_ReqParameterError, "智能体ID 已存在: "+m.Id)
return
}
}
if msg := validateAgentSvcRefs(&m); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
@ -237,6 +288,9 @@ func (this *serverComp) updateAgent(c *gin.Context) {
writeErr(c, pb.ErrorCode_ReqParameterError, "智能体ID 必填")
return
}
if !this.requireAppNameScope(c, m.AppName) {
return
}
old := &AgentConfig{}
if err := postgres.FindOne(comm.TableAgentConfig, old, "app_name=? AND id=?", m.AppName, m.Id); err != nil {
writeErr(c, pb.ErrorCode_DBError, "智能体不存在: "+m.Id)
@ -276,6 +330,9 @@ func (this *serverComp) delAgent(c *gin.Context) {
writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填")
return
}
if !this.requireAppNameScope(c, req.AppName) {
return
}
if err := postgres.Delete(comm.TableAgentConfig, "app_name=? AND id=?", req.AppName, req.Id); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return

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

@ -108,6 +108,10 @@ func ensureConfigTables() error {
if err := pg.CreateTable(comm.TableEchomeetOrchSetting, &EchoOrchSetting{}); err != nil {
return err
}
// 应用内购商品配置(按 app_name 隔离,多语言存 localname JSON)。
if err := pg.CreateTable(comm.TableAppGoods, &AppGoods{}); err != nil {
return err
}
// 一次性迁移:老表数据 → 新表 app_name='' 全局作用域(新表为空且老表存在时)。
migrateLegacyConfigToScoped()
// 一次性迁移:独立 mcp 表 → svc_config 的 MCP 服务类型(含区域覆盖)。

76
apps/services/modules/console/api_device.go

@ -30,10 +30,23 @@ func (this *serverComp) updateProduct(c *gin.Context, sys *appConn) {
writeErr(c, pb.ErrorCode_ReqParameterError, "product 必填")
return
}
if _, err := dvProduct(sys, req.Product.Id); err != nil {
old, err := dvProduct(sys, req.Product.Id)
if err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, "产品不存在")
return
}
// 方案商:dvUpdateProduct 是全字段 Save,没传就会被清空。所以传 0 视为「不改」,沿用原绑定;
// 传了值才校验存在性(产品表单必填,切版本这类局部更新不带该字段)。
if req.Product.Providerid == 0 {
req.Product.Providerid = old.Providerid
} else if msg := checkProductProvider(sys, req.Product.Providerid); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
// 创建时间只在新增时写一次,更新是全字段 Save,前端没回传就会被抹成 0。
if req.Product.Createtime == 0 {
req.Product.Createtime = old.Createtime
}
// 绑定应用(CSV,可多选):归一去重后校验都在应用中心里,避免拼错的应用名让下拉永远空着。
req.Product.Appnames = normalizeAppNames(req.Product.Appnames)
if msg := this.checkAppNames(req.Product.Appnames); msg != "" {
@ -127,11 +140,18 @@ func (this *serverComp) addProduct(c *gin.Context, sys *appConn) {
writeErr(c, pb.ErrorCode_DBError, "品牌商不存在: "+err.Error())
return
}
// 方案商挂在产品上(不是品牌商上):同一品牌的不同产品可能用不同芯片方案,
// 生产设备码时的服务ID(CID)直接取产品的 providerid,所以这里必须绑定且必须真实存在。
if msg := checkProductProvider(sys, req.Product.Providerid); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
req.Product.Appnames = normalizeAppNames(req.Product.Appnames)
if msg := this.checkAppNames(req.Product.Appnames); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
req.Product.Createtime = time.Now().Unix()
if err = dvAddProduct(sys, req.Product); err != nil { // Insert 后回填 Product.Id + 建 license 分表
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
@ -153,6 +173,42 @@ func (this *serverComp) addProduct(c *gin.Context, sys *appConn) {
// ---- 方案商(写)----
// 芯片方案厂商分组(DBSolutionProvider.vendor)。一个芯片厂商下往往有多套方案,
// 每套方案单独一条方案商记录:vendor 归组、name 记方案商公司名、description 记这套方案做什么。
// 注意:pb 里的 SolutionVendor 只生成到 3(恒玄),博通=4 已写进 apps/proto/db.proto,
// 但本机没装 protoc、pb 尚未重生成;proto3 是开放枚举,按数值收发即可,
// 下次重生成 pb 后这里可换成 pb.SolutionVendor_VENDOR_BOTONG 等常量。
// 新增芯片厂商 = db.proto 的 SolutionVendor + 这张表 + 前端 utils/chipvendors.ts 三处补同一个数值。
var chipVendorNames = map[int32]string{
1: "杰里",
2: "蓝汛",
3: "恒玄",
4: "博通",
}
// checkChipVendor 校验芯片分组:必填且必须是已知厂商,返回空串表示通过。
func checkChipVendor(v pb.SolutionVendor) string {
if int32(v) == 0 {
return "芯片分组必填(杰里/蓝汛/恒玄/博通)"
}
if _, ok := chipVendorNames[int32(v)]; !ok {
return fmt.Sprintf("未知的芯片分组:%d", int32(v))
}
return ""
}
// checkProductProvider 校验产品绑定的方案商:必填且必须存在,返回空串表示通过。
func checkProductProvider(sys *appConn, id uint32) string {
if id == 0 {
return "归属方案商必填(生产设备码的服务ID(CID)取自产品的方案商)"
}
p, err := dvSolutionProvider(sys, id)
if err != nil || p == nil || p.Id == 0 {
return fmt.Sprintf("方案商 %X 不存在", id)
}
return ""
}
func (this *serverComp) addSolutionProvider(c *gin.Context, sys *appConn) {
var req pb.ApiAddSolutionProviderReq
_ = c.ShouldBindJSON(&req)
@ -170,6 +226,10 @@ func (this *serverComp) addSolutionProvider(c *gin.Context, sys *appConn) {
writeErr(c, pb.ErrorCode_ReqParameterError, fmt.Sprintf("方案商 id %X 已存在(%s)", old.Id, old.Name))
return
}
if msg := checkChipVendor(req.Provider.Vendor); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
req.Provider.Createtime = time.Now().Unix()
if err := dvAddSolutionProvider(sys, req.Provider); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
@ -185,6 +245,14 @@ func (this *serverComp) updateSolutionProvider(c *gin.Context, sys *appConn) {
writeErr(c, pb.ErrorCode_ReqParameterError, "provider id is required")
return
}
if strings.TrimSpace(req.Provider.Name) == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "provider name 必填")
return
}
if msg := checkChipVendor(req.Provider.Vendor); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
old, err := dvSolutionProvider(sys, req.Provider.Id)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
@ -193,7 +261,7 @@ func (this *serverComp) updateSolutionProvider(c *gin.Context, sys *appConn) {
old.Name = req.Provider.Name
old.Description = req.Provider.Description
old.Contactmails = req.Provider.Contactmails
old.Vendor = req.Provider.Vendor // 所属厂商分组(杰里/蓝汛/恒玄)
old.Vendor = req.Provider.Vendor // 芯片分组(杰里/蓝汛/恒玄/博通)
if err = dvSaveSolutionProvider(sys, old); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
@ -245,6 +313,8 @@ func (this *serverComp) addBrand(c *gin.Context, sys *appConn) {
}
}
}
req.Brand.Providerid = 0 // 品牌商不归属方案商,忽略任何传入值(归属在 DBProduct.providerid)
req.Brand.Createtime = time.Now().Unix()
req.Brand.Contactmails = ensureDefaultContactMail(req.Brand.Contactmails)
if err := dvAddBrand(sys, req.Brand); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
@ -288,7 +358,7 @@ func (this *serverComp) updateBrand(c *gin.Context, sys *appConn) {
}
// 只覆盖可编辑字段;productlists 等服务端维护字段不接受前端写入,防止表单缺字段把产品列表覆盖回旧值。
old.Brand = req.Brand.Brand
old.Providerid = req.Brand.Providerid
// providerid 不再由品牌商维护:方案商归属挂在产品上(DBProduct.providerid),这里刻意不覆盖旧值。
old.Description = req.Brand.Description
old.Contactmails = req.Brand.Contactmails
// 分成比例只有超管/管理员能改(品牌商自己改比例等于自定分成)。

262
apps/services/modules/console/api_goods.go

@ -0,0 +1,262 @@
package console
import (
"encoding/json"
"strings"
"time"
"yunyan/lego/sys/postgres"
"yunyan/pb"
"github.com/gin-gonic/gin"
)
// ============================ 应用商品配置 (appgoods) ============================
//
// 「应用商品配置」页(admin appgoods.vue)的后端:读写 console 主库的 app_goods 表——
// 即各应用的**内购商品**(VIP 时长包、AI/会议/翻译资源点包等)及其多语言售卖信息。
//
// 与「应用环境配置」(appcfg)的差别:
// - appcfg 按「应用 × 部署环境」写各应用**业务库**;
// - 商品是应用级的售卖定义,与部署在哪个区域无关,故按 **app_name** 隔离、统一落 console 公共库,
// 同一应用的多个区域部署共用一份。
//
// 多语言:前端按语言逐条编辑(语言 / 名称 / 货币符号 / 价格),后端序列化成
// pb.DBGoods.localname 同构的 JSON 存 Localname 列,列表/详情再解析回 locales 下发,
// 前端不必碰 JSON 字符串。
// GoodsLocale 单语言售卖信息,字段与 pb.DBGoodsLocal 的 json 名一致。
type GoodsLocale struct {
Name string `json:"name"` // 该语言下的商品名
Currency string `json:"currency"` // 货币符号,如 ¥ / $
Price float64 `json:"price"` // 该语言(地区)售价
}
// appGoodsReq 新增/更新商品的请求体(locales 为结构化多语言,落库时序列化进 localname)。
type appGoodsReq struct {
Id uint32 `json:"id"`
AppName string `json:"app_name"`
GoodsId string `json:"goods_id"`
Enable bool `json:"enable"`
Name string `json:"name"`
Price int32 `json:"price"`
Usagetype int32 `json:"usagetype"`
Ainum int64 `json:"ainum"`
Meetnum int64 `json:"meetnum"`
Tradenum int64 `json:"tradenum"`
Viptime int32 `json:"viptime"`
Locales map[string]*GoodsLocale `json:"locales"`
Sort int32 `json:"sort"`
Remark string `json:"remark"`
}
// appGoodsView 下发给前端的商品:库行 + 解析好的多语言(localname 原串一并保留,便于排查)。
type appGoodsView struct {
*AppGoods
Locales map[string]*GoodsLocale `json:"locales"`
}
// parseGoodsLocales 解析 localname JSON;空串或坏数据都返回空 map(坏数据不该让整页打不开)。
func parseGoodsLocales(s string) map[string]*GoodsLocale {
out := make(map[string]*GoodsLocale)
if strings.TrimSpace(s) == "" {
return out
}
if err := json.Unmarshal([]byte(s), &out); err != nil {
return make(map[string]*GoodsLocale)
}
for k, v := range out {
if v == nil {
delete(out, k)
}
}
return out
}
// marshalGoodsLocales 序列化多语言为 localname JSON:语言码去空白、丢弃空码与空项;
// 一项都没有时存空串(而不是 "{}"),与历史数据保持一致。
func marshalGoodsLocales(in map[string]*GoodsLocale) (string, error) {
clean := make(map[string]*GoodsLocale, len(in))
for k, v := range in {
lang := strings.TrimSpace(k)
if lang == "" || v == nil {
continue
}
clean[lang] = &GoodsLocale{
Name: strings.TrimSpace(v.Name),
Currency: strings.TrimSpace(v.Currency),
Price: v.Price,
}
}
if len(clean) == 0 {
return "", nil
}
b, err := json.Marshal(clean)
if err != nil {
return "", err
}
return string(b), nil
}
// applyGoodsReq 把请求体写进模型(不含 Id/AppName/时间戳,由调用方按新增/更新分别处理)。
func applyGoodsReq(m *AppGoods, req *appGoodsReq, localname string) {
m.GoodsId = strings.TrimSpace(req.GoodsId)
m.Enable = req.Enable
m.Name = strings.TrimSpace(req.Name)
m.Price = req.Price
m.Usagetype = req.Usagetype
m.Ainum = req.Ainum
m.Meetnum = req.Meetnum
m.Tradenum = req.Tradenum
m.Viptime = req.Viptime
m.Localname = localname
m.Sort = req.Sort
m.Remark = strings.TrimSpace(req.Remark)
}
// checkGoodsReq 校验必填并归一,返回错误说明(空串=通过)。
func checkGoodsReq(req *appGoodsReq) string {
req.AppName = strings.TrimSpace(req.AppName)
req.GoodsId = strings.TrimSpace(req.GoodsId)
if req.AppName == "" {
return "app_name 必填"
}
if req.GoodsId == "" {
return "商品ID(goods_id) 必填"
}
return ""
}
// appGoodsList 列出某应用的全部商品(含多语言)。
func (this *serverComp) appGoodsList(c *gin.Context) {
var req struct {
AppName string `json:"app_name"`
}
_ = c.ShouldBindJSON(&req)
req.AppName = strings.TrimSpace(req.AppName)
if req.AppName == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "app_name 必填")
return
}
models, err := this.module.model.listGoods(req.AppName)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
items := make([]*appGoodsView, 0, len(models))
for _, m := range models {
items = append(items, &appGoodsView{AppGoods: m, Locales: parseGoodsLocales(m.Localname)})
}
writeOK(c, gin.H{"items": items})
}
// appGoodsAdd 新增商品:同一应用内 goods_id 不可重复。
func (this *serverComp) appGoodsAdd(c *gin.Context) {
var req appGoodsReq
if err := c.ShouldBindJSON(&req); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
if msg := checkGoodsReq(&req); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
dup, err := this.module.model.getGoodsByGid(req.AppName, req.GoodsId)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
if dup != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, "该应用下商品ID已存在: "+req.GoodsId)
return
}
localname, err := marshalGoodsLocales(req.Locales)
if err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, "多语言配置格式错误: "+err.Error())
return
}
now := time.Now().Unix()
m := &AppGoods{AppName: req.AppName, Createtime: now, Updatetime: now}
applyGoodsReq(m, &req, localname)
if err := this.module.model.addGoods(m); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
writeOK(c, &appGoodsView{AppGoods: m, Locales: parseGoodsLocales(m.Localname)})
}
// appGoodsUpdate 更新商品:应用归属不可改;改了 goods_id 同样要在应用内查重。
func (this *serverComp) appGoodsUpdate(c *gin.Context) {
var req appGoodsReq
if err := c.ShouldBindJSON(&req); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
if msg := checkGoodsReq(&req); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
if req.Id == 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填")
return
}
old, err := this.module.model.getGoods(req.Id)
if err == postgres.ErrNoDocuments || (err == nil && old.Id == 0) {
writeErr(c, pb.ErrorCode_ReqParameterError, "商品不存在")
return
}
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
if old.AppName != req.AppName {
writeErr(c, pb.ErrorCode_ReqParameterError, "商品归属应用不可修改")
return
}
if old.GoodsId != strings.TrimSpace(req.GoodsId) {
dup, err := this.module.model.getGoodsByGid(req.AppName, req.GoodsId)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
if dup != nil && dup.Id != req.Id {
writeErr(c, pb.ErrorCode_ReqParameterError, "该应用下商品ID已存在: "+req.GoodsId)
return
}
}
localname, err := marshalGoodsLocales(req.Locales)
if err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, "多语言配置格式错误: "+err.Error())
return
}
applyGoodsReq(old, &req, localname)
old.Updatetime = time.Now().Unix()
if err := this.module.model.saveGoods(old); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
writeOK(c, &appGoodsView{AppGoods: old, Locales: parseGoodsLocales(old.Localname)})
}
// appGoodsDel 按 id 批量删除某应用的商品。
func (this *serverComp) appGoodsDel(c *gin.Context) {
var req struct {
AppName string `json:"app_name"`
Ids []uint32 `json:"ids"`
}
_ = c.ShouldBindJSON(&req)
req.AppName = strings.TrimSpace(req.AppName)
if req.AppName == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "app_name 必填")
return
}
if len(req.Ids) == 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "ids 必填")
return
}
if err := this.module.model.delGoods(req.AppName, req.Ids); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
writeOK(c, gin.H{"deleted": len(req.Ids)})
}

27
apps/services/modules/console/api_inspect.go

@ -6,7 +6,7 @@ package console
// 三个接口分工:
// api_getsvchealth 巡检页:全量明细 + 汇总
// api_runsvcinspect 巡检页:立即巡检(全量或单个服务)
// api_getsvcalerts 仪表盘顶部告警条:只回异常项,尽量小
// api_getsvcalerts 顶栏通知铃铛:只回异常项,尽量小
//
// 巡检结果不含任何凭据明文(探针只把服务商的错误描述写进 probe_msg),可直接下发前端。
@ -21,6 +21,18 @@ import (
"github.com/gin-gonic/gin"
)
// requireInspectAccess 巡检结果只对内部角色开放(超管/管理员/运营)。
// 品牌商(3)/渠道商(5) 是外部角色,「服务巡检」菜单本就不在它们的可见上限内,
// 接口这一层也一并挡住,免得绕过前端直接读到平台第三方服务的健康状况。
func (this *serverComp) requireInspectAccess(c *gin.Context) bool {
switch currentIdentity(c) {
case pb.Identity_Admin, pb.Identity_Manager, pb.Identity_Operator:
return true
}
writeErr(c, pb.ErrorCode_InsufficientPermissions, "无权查看服务巡检")
return false
}
// healthSummary 各状态计数,前端顶部汇总卡片直接用。
type healthSummary struct {
Total int `json:"total"`
@ -33,6 +45,9 @@ type healthSummary struct {
// getSvcHealth 列出巡检结果。app_name 传 "__all__" 或省略即不限作用域(巡检覆盖全部作用域,
// 而 app_name='' 的全局默认服务对所有应用生效,默认展示全部更符合运维视角)。
func (this *serverComp) getSvcHealth(c *gin.Context) {
if !this.requireInspectAccess(c) {
return
}
var req struct {
AppName string `json:"app_name"`
OnlyAbnormal bool `json:"only_abnormal"`
@ -81,6 +96,9 @@ func (this *serverComp) getSvcHealth(c *gin.Context) {
// 全量巡检可能持续数十秒,但仍同步执行——运营点「立即巡检」就是要看到这一轮的结果,
// 异步返回会让前端只能靠轮询猜什么时候好了。
func (this *serverComp) runSvcInspect(c *gin.Context) {
if !this.requireInspectAccess(c) {
return
}
var req struct {
AppName string `json:"app_name"`
SvcId string `json:"svc_id"`
@ -105,9 +123,12 @@ func (this *serverComp) runSvcInspect(c *gin.Context) {
writeOK(c, gin.H{"ok": true, "count": n})
}
// getSvcAlerts 仪表盘顶部告警条数据源:只回异常项(error 在前,warn 在后),并做数量上限,
// 避免配置量大时把整张巡检表塞进仪表盘首屏。
// getSvcAlerts 顶栏铃铛的数据源:只回异常项(error 在前,warn 在后),并做数量上限,
// 避免配置量大时把整张巡检表塞进通知面板。
func (this *serverComp) getSvcAlerts(c *gin.Context) {
if !this.requireInspectAccess(c) {
return
}
var req struct {
Limit int `json:"limit"`
}

67
apps/services/modules/console/model_goods.go

@ -0,0 +1,67 @@
package console
import (
"yunyan/comm"
"yunyan/lego/sys/postgres"
)
// AppGoods 应用内购商品配置。结构定义在 comm(见 comm/appgoods.go)——这张表由本后台维护、
// 业务侧 pay 模块发货时按应用名读取,两边共用同一份结构,故上移到 comm,这里保留别名不动调用方。
//
// 与旧方案的关系:商品原本落在**各应用业务库**的 goods 表(pb.DBGoods,由 api 模块的
// api_getgoodss/api_addgoods 等接口维护)。本版把配置口径统一收到后台公共库,
// 字段与 pb.DBGoods 一一对应(多出 AppName/Sort/Remark/时间戳)。
// 业务侧读取现状:PayPal 通知 v2(pay_paypalnotifyv2)已改读本表发货;
// 其余支付渠道与 api_getgoodss 仍读各自业务库的 goods 表,逐步迁移。
type AppGoods = comm.AppGoods
// ---- 商品表 CRUD(console 主库 postgres,与 app_product 同库同套 helper)----
// listGoods 列出某应用的全部商品(sort 升序,其次 id)。appName 为空返回空列表——
// 商品必须挂在具体应用下,没有「全局商品」这一档。
func (this *modelComp) listGoods(appName string) (models []*AppGoods, err error) {
models = make([]*AppGoods, 0)
if appName == "" {
return
}
err = postgres.Table(comm.TableAppGoods).Where("app_name=?", appName).Order("sort ASC, id ASC").Find(&models).Error
return
}
// getGoods 按主键取商品。
func (this *modelComp) getGoods(id uint32) (model *AppGoods, err error) {
model = &AppGoods{}
err = postgres.FindOne(comm.TableAppGoods, model, "id=?", id)
return
}
// getGoodsByGid 按 (应用, 商品标识) 取商品,用于查重。不存在返回 (nil,nil)。
func (this *modelComp) getGoodsByGid(appName, goodsId string) (*AppGoods, error) {
model := &AppGoods{}
err := postgres.FindOne(comm.TableAppGoods, model, "app_name=? AND goods_id=?", appName, goodsId)
if err != nil {
if err == postgres.ErrNoDocuments {
return nil, nil
}
return nil, err
}
return model, nil
}
// addGoods 新增商品(Insert 后 model.Id 被回填)。
func (this *modelComp) addGoods(model *AppGoods) (err error) {
err = postgres.Insert(comm.TableAppGoods, model)
return
}
// saveGoods 更新商品。
func (this *modelComp) saveGoods(model *AppGoods) (err error) {
err = postgres.Save(comm.TableAppGoods, model)
return
}
// delGoods 按 id 批量删除某应用的商品(带 app_name 条件,防跨应用误删)。
func (this *modelComp) delGoods(appName string, ids []uint32) (err error) {
err = postgres.Delete(comm.TableAppGoods, "app_name=? AND id IN ?", appName, ids)
return
}

18
apps/services/modules/console/registry.go

@ -163,6 +163,11 @@ func ensureDeviceTables() error {
return err
}
_ = adb.AutoIncrementStart(comm.TableBrand, "id", 0xA001)
// createtime 是后加字段(品牌商建档时间):CreateTable 对已存在的表跳过 AutoMigrate,故显式补列。
// 历史品牌商为 0,后台列表按此展示为空。
if res := adb.Exec("ALTER TABLE " + comm.TableBrand + " ADD COLUMN IF NOT EXISTS createtime bigint DEFAULT 0"); res.Error != nil {
return res.Error
}
if err := adb.CreateTable(comm.TableChannel, &pb.DBChannel{}); err != nil {
return err
}
@ -183,6 +188,19 @@ func ensureDeviceTables() error {
if res := adb.Exec("ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS appnames varchar(255) DEFAULT ''"); res.Error != nil {
return res.Error
}
// providerid/createtime/description 同为后加字段(产品级绑定方案商、建档时间、产品说明),同样要显式补列。
// providerid 历史行为 0 = 未绑定方案商:生产设备码前必须在后台补选,否则 CID 取不到。
for _, ddl := range []string{
"ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS providerid bigint DEFAULT 0",
"ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS createtime bigint DEFAULT 0",
"ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS description text DEFAULT ''",
// pb 里 providerid 带 gorm index;建表走 AutoMigrate 会自动建,补列这条路要自己补上
"CREATE INDEX IF NOT EXISTS idx_product_providerid ON " + comm.TableProduct + " (providerid)",
} {
if res := adb.Exec(ddl); res.Error != nil {
return res.Error
}
}
if err := adb.CreateTable(comm.TableProductVersion, &pb.DBProductVersion{}); err != nil {
return err
}

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

@ -4,6 +4,8 @@ import (
"sort"
"strings"
"yunyan/pb"
"github.com/gin-gonic/gin"
)
@ -127,3 +129,29 @@ func (this *serverComp) visibleAppBinds(names []string, unlimited bool) []gin.H
}
return out
}
// requireAppNameScope 校验「按应用名作用域」的写操作在当前账号权限内(agent 等按 app_name 隔离的配置用)。
// 与 requireAppScope(按 X-App-Id)同一套口径,只是这里的作用域键是应用名:
// - 超管/管理员(unlimited) 或 账号没有应用维度绑定 → 放行(沿用全局「非空才限制」语义);
// - 有绑定的账号只能写自己名下的应用,且不能写「全局默认」(app_name=”)——
// 那是所有应用的兜底,改一处影响所有人,只该由超管/管理员维护。
//
// 只用于写操作:读(列表)仍允许看全局默认,否则应用级账号看不到自己继承的那份配置。
func (this *serverComp) requireAppNameScope(c *gin.Context, appName string) bool {
if scopeOf(c).unlimited {
return true
}
allowed := this.accountAppNames(c)
if len(allowed) == 0 {
return true
}
if strings.TrimSpace(appName) == "" {
writeErr(c, pb.ErrorCode_InsufficientPermissions, "无权修改「全局默认」作用域,请先选择具体应用")
return false
}
if !containsStr(allowed, appName) {
writeErr(c, pb.ErrorCode_InsufficientPermissions, "无权访问该应用: "+appName)
return false
}
return true
}

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

@ -124,6 +124,12 @@ func (this *serverComp) routes(eng *gin.Engine) {
apps.POST("/appcfg/add", this.appCfgAdd)
apps.POST("/appcfg/update", this.appCfgUpdate)
apps.POST("/appcfg/del", this.appCfgDel)
// 应用商品配置(内购商品,按 app_name 隔离、存 console 公共库;与部署环境无关)
apps.POST("/appgoods/list", this.appGoodsList)
apps.POST("/appgoods/add", this.appGoodsAdd)
apps.POST("/appgoods/update", this.appGoodsUpdate)
apps.POST("/appgoods/del", this.appGoodsDel)
// 会议记录服务配置(识别/翻译/总结优先级 + 凭据 + 识别语言 + 总结模型清单;存应用业务库)。
// 部署环境(区域)管理:与应用注册同权限(超管 + 管理员)。
apps.POST("/envs/list", this.envList)
@ -652,6 +658,7 @@ func (this *serverComp) login(c *gin.Context) {
}
writeOK(c, gin.H{
"account": req.Account,
"account_id": accountId, // 账号表 id(0 = yaml 引导超管,库里没有记录);后台侧栏账号卡展示用
"identity": identity,
"token": ts,
"avatar": "https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png",

45
apps/services/modules/pay/api.go

@ -36,12 +36,26 @@ func (this *apiComp) Start() (err error) {
return
}
// 更具订单信息 给用户下发资源
// goodsGrant 一笔订单要发放的内容。商品来源有两套——历史口径读**各业务库的 goods 表**
// (pb.DBGoods),v2 口径读**管理后台的商品配置**(console 公共库 app_goods,按应用名隔离)——
// 但发放、记账、埋点是同一套,抽出来共用。
type goodsGrant struct {
Viptime int32
Ainum int64
Meetnum int64
Tradenum int64
}
// grantOfAppGoods 把后台商品配置换算成发放内容(v2 口径)。
func grantOfAppGoods(g *comm.AppGoods) *goodsGrant {
return &goodsGrant{Viptime: g.Viptime, Ainum: g.Ainum, Meetnum: g.Meetnum, Tradenum: g.Tradenum}
}
// deliverResourceByOrder 按订单给用户发货:商品取自**业务库 goods 表**(历史口径,
// 微信/支付宝/Apple/Google 通知与 verifyorder 都走这条)。
// 商品对不上时 grant 为 nil:不加资源,但仍走一遍更新与记账,与改造前行为一致。
func (this *apiComp) deliverResourceByOrder(user *pb.DBUser, order *pb.DBPayOrder) (err error) {
var (
goodss []*pb.DBGoods
userlog *pb.DBUserUseLog
)
var goodss []*pb.DBGoods
// 1. 检查订单状态
if order.Status != pb.PayOrderStatus_PAY_ORDER_PAID {
err = fmt.Errorf("订单状态错误,无法下发资源: %v", order.Status)
@ -52,14 +66,30 @@ func (this *apiComp) deliverResourceByOrder(user *pb.DBUser, order *pb.DBPayOrde
err = fmt.Errorf("查询商品信息失败: %v", err)
return
}
var grant *goodsGrant
for _, v := range goodss {
if v.Id == order.ProductId {
grant = &goodsGrant{Viptime: v.Viptime, Ainum: v.Ainum, Meetnum: v.Meetnum, Tradenum: v.Tradenum}
}
}
return this.deliverGrant(user, order, grant)
}
// deliverGrant 真正的发货:改用户资源 → 写消费日志 → 上报埋点。
// grant 为 nil 表示没匹配到商品(不加任何资源,也不写发放明细)。
func (this *apiComp) deliverGrant(user *pb.DBUser, order *pb.DBPayOrder, grant *goodsGrant) (err error) {
var userlog *pb.DBUserUseLog
if order.Status != pb.PayOrderStatus_PAY_ORDER_PAID {
err = fmt.Errorf("订单状态错误,无法下发资源: %v", order.Status)
return
}
now := time.Now()
userlog = &pb.DBUserUseLog{
Uid: order.Uid,
Logtype: pb.UserLogType_Pay,
Ts: now.Unix(),
}
for _, v := range goodss {
if v.Id == order.ProductId {
if v := grant; v != nil {
if v.Viptime > 0 {
user.Viplv = 1
prev := user.Vipexptime
@ -101,7 +131,6 @@ func (this *apiComp) deliverResourceByOrder(user *pb.DBUser, order *pb.DBPayOrde
order.OrderId, order.ProductId, order.ProductName, order.Amount, order.PaymentMethod,
userlog.Addvipday, userlog.Addagentintegral, userlog.Addtradesecond, userlog.Addmeetsecond)
}
}
// 2. 更新用户信息
if err = this.module.model.updateUser(user); err != nil {
err = fmt.Errorf("更新用户信息失败: %v", err)

196
apps/services/modules/pay/api_paypalnotify_v2.go

@ -0,0 +1,196 @@
package pay
import (
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"time"
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
"yunyan/sys/pay/paypal"
"yunyan/utils"
)
// @Summary 处理PayPal支付异步通知 v2 [内部RPC]
// @Description 接收由HTTP回调网关转发来的支付通知,验签后按管理后台的商品配置发货
// @Tags Pay
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param notify body pb.PayPayPalNotifyReq true "PayPal 异步通知参数"
// @Success 200 {object} comm.HttpResult "处理结果"
// @Router /api/home/pay_paypalnotifyv2 [post]
//
// PayPalNotifyV2 与 v1(PayPalNotify)流程一致,差别只有一处、但是关键的一处:
// **发货口径改成读管理后台的商品配置** —— console 公共库的 app_goods 表,
// 按本部署的应用名(comm.AppName() / 环境变量 ANALYZE_APP_NAME)隔离,
// 而不是各业务库自己维护的 goods 表。这样商品(VIP 时长、AI/会议/翻译点数)在后台
// 「应用商品配置」页改完即生效,多区域部署共用一份,不必再逐个业务库同步。
//
// 幂等:订单已不是「待支付」就直接回 SUCCESS,PayPal 重推不会重复发货。
// 后台没配该商品时**不发货、也不改订单状态**,回 Fail 让 PayPal 重试——
// 补好配置后自然会补发,比发一笔空货再人工补要好。
func (this *apiComp) PayPalNotifyV2(session comm.IUserSession, req *pb.PayPayPalNotifyReq) (resp []byte, errdata *pb.ErrorData) {
var (
order *pb.DBPayOrder
goods *comm.AppGoods
user *pb.DBUser
ok bool
err error
)
// 1. 验签:webhook 的可信来源判断全靠它,失败一律不处理
headers := map[string]string{
"PayPal-Transmission-Id": req.TransmissionId,
"PayPal-Transmission-Time": req.TransmissionTime,
"PayPal-Cert-Url": req.CertUrl,
"PayPal-Auth-Algo": req.AuthAlgo,
"PayPal-Transmission-Sig": req.TransmissionSig,
"Webhook-Id": req.WebhookId,
}
if ok, err = paypal.VerifyWebhook(headers, []byte(req.Body)); !ok || err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_PayNotifyDecryptFail,
Message: "验证PayPal通知失败:" + fmt.Sprintf("%v", err),
}
return
}
// 2. 解析通知体,只认支付完成事件;其余事件回 SUCCESS 让 PayPal 别再推
var body map[string]any
if err = json.Unmarshal([]byte(req.Body), &body); err != nil {
resp = payNotifyResp("Fail", "通知体解析失败")
return
}
if eventType, _ := body["event_type"].(string); eventType != "PAYMENT.CAPTURE.COMPLETED" {
resp = payNotifyResp("SUCCESS", "忽略非支付完成事件")
return
}
resource, _ := body["resource"].(map[string]any)
outTradeNo := paypalOutTradeNo(resource)
if outTradeNo == "" {
resp = payNotifyResp("Fail", "无法识别订单号")
return
}
// 3. 订单:不存在回 Fail(等 PayPal 重试),已处理过直接回 SUCCESS(幂等)
if order, err = this.module.model.getPayOrder(outTradeNo); err != nil {
resp = payNotifyResp("Fail", fmt.Sprintf("订单不存在: %s", outTradeNo))
return
}
if order.Status != pb.PayOrderStatus_PAY_ORDER_PENDING {
resp = payNotifyResp("SUCCESS", "成功")
return
}
// 金额对账:验签已保证通知来自 PayPal,这里对不上多半是配置/口径问题,
// 记日志留痕但不拦截发货(用户确实付了钱);要改成严格拒绝,把下面的日志换成 return 即可。
if cents, ok2 := paypalAmountCents(resource); ok2 && cents != order.Amount {
this.module.Error("PayPalNotifyV2 金额与订单不一致",
log.Field{Key: "order", Value: order.OrderId},
log.Field{Key: "order_amount", Value: order.Amount},
log.Field{Key: "notify_amount", Value: cents})
}
// 4. 商品:读后台配置(按应用隔离)。没配就不发货、不改状态,等补好配置后 PayPal 重推
if goods, err = this.module.model.appGoods(order.ProductId); err != nil {
resp = payNotifyResp("Fail", "查询商品配置失败: "+err.Error())
return
}
if goods == nil {
this.module.Error("PayPalNotifyV2 后台未配置该商品,暂不发货",
log.Field{Key: "app", Value: comm.AppName()},
log.Field{Key: "order", Value: order.OrderId},
log.Field{Key: "goods_id", Value: order.ProductId})
resp = payNotifyResp("Fail", fmt.Sprintf("应用 %s 未配置商品 %s", comm.AppName(), order.ProductId))
return
}
if !goods.Enable {
// 已下架的商品仍然照常发货:钱已经收了,下架只影响后续售卖
this.module.Warnf("PayPalNotifyV2 商品已下架仍按订单发货: app=%s order=%s goods=%s",
comm.AppName(), order.OrderId, order.ProductId)
}
// 5. 用户 → 发货 → 落订单状态
if user, err = this.module.model.getUser(order.Uid); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: "查询用户信息失败:" + err.Error(),
}
return
}
order.Status = pb.PayOrderStatus_PAY_ORDER_PAID
order.PayTime = time.Now().Unix()
if order.ProductName == "" {
order.ProductName = goods.Name // 老订单没存商品名时,用后台配置补上,便于对账
}
if id, _ := resource["id"].(string); id != "" {
order.TransactionId = id
}
if err = this.deliverGrant(user, order, grantOfAppGoods(goods)); err != nil {
resp = payNotifyResp("Fail", fmt.Sprintf("发货资源失败: %v", err))
return
}
if err = this.module.model.updatePayOrder(order); err != nil {
resp = payNotifyResp("Fail", fmt.Sprintf("更新订单状态失败: %v", err))
return
}
this.module.Debug("PayPalNotifyV2 发货完成",
log.Field{Key: "app", Value: comm.AppName()},
log.Field{Key: "order", Value: order.OrderId},
log.Field{Key: "uid", Value: order.Uid},
log.Field{Key: "goods_id", Value: goods.GoodsId})
resp = payNotifyResp("SUCCESS", "成功")
return
}
// payNotifyResp PayPal 回调的应答体(与 v1 保持同样的形状)。
func payNotifyResp(code, message string) []byte {
return []byte(utils.ToString(map[string]string{"code": code, "message": message}))
}
// paypalOutTradeNo 从通知的 resource 里取我们自己的商户订单号:
// 下单时写在 custom_id;取不到再退而求其次找 purchase_units[0].reference_id / invoice_id。
func paypalOutTradeNo(resource map[string]any) string {
if resource == nil {
return ""
}
if v, _ := resource["custom_id"].(string); strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
if pus, _ := resource["purchase_units"].([]any); len(pus) > 0 {
if unit, _ := pus[0].(map[string]any); unit != nil {
if v, _ := unit["reference_id"].(string); strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
}
if v, _ := resource["invoice_id"].(string); strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
return ""
}
// paypalAmountCents 取通知里的实付金额并换算成「分」(订单 Amount 的单位)。
// PayPal 的 amount.value 是字符串金额(如 "9.99");解析不出来返回 ok=false,此时不做对账。
func paypalAmountCents(resource map[string]any) (int64, bool) {
if resource == nil {
return 0, false
}
amount, _ := resource["amount"].(map[string]any)
if amount == nil {
return 0, false
}
value, _ := amount["value"].(string)
if strings.TrimSpace(value) == "" {
return 0, false
}
f, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err != nil {
return 0, false
}
return int64(math.Round(f * 100)), true
}

21
apps/services/modules/pay/model.go

@ -66,6 +66,27 @@ func (this *modelComp) getUserLastDevice(uid string) (model *pb.DBUserDivice, er
return models[0], nil
}
// appGoods 取**管理后台配置**的商品(console 公共库 app_goods 表),按本部署的应用名隔离:
// 应用名来自 comm.AppName()(环境变量 ANALYZE_APP_NAME,与 console 应用注册表里的名字一致),
// 所以同一份代码部署给不同应用时,各自只读得到自己的商品,不会串。
// goodsId 就是下单时写进订单的 product_id(= 苹果/谷歌后台的内购商品 ID)。
// 应用名没配、或后台没配这个商品时返回 (nil, nil),交调用方决定怎么处理。
func (this *modelComp) appGoods(goodsId string) (model *comm.AppGoods, err error) {
appName := comm.AppName()
if appName == "" || goodsId == "" {
return nil, nil
}
model = &comm.AppGoods{}
if err = postgres.FindOne(comm.TableAppGoods, model, "app_name=? AND goods_id=?", appName, goodsId); err != nil {
if err == postgres.ErrNoDocuments {
return nil, nil
}
this.module.Errorln(err)
return nil, err
}
return model, nil
}
// getProduct 取产品(订单快照需要产品所属品牌商)。
func (this *modelComp) getProduct(id uint32) (model *pb.DBProduct, err error) {
model = &pb.DBProduct{}

57
apps/services/modules/user/api_getchannelapp_test.go

@ -1,57 +0,0 @@
package user
import (
"testing"
"yunyan/comm"
)
// 客户端拿到的就是这三个布尔值,各端据此决定「提示更新 / 阻断使用 / 隐藏未过审功能」,
// 判定错了直接表现为线上事故,故逐个组合固化。
func TestEvalChannelUpgrade(t *testing.T) {
cfg := &comm.ChannelApp{Version: "1.10.0", MinVersion: "1.5.0", ForceUpdate: true, ReviewVersion: "1.11.0"}
cases := []struct {
name string
cfg *comm.ChannelApp
client string
need, force, reviewing bool
}{
{"低于最低支持版本:强更", cfg, "1.4.9", true, true, false},
{"高于最低支持、低于线上:只提示更新", cfg, "1.9.0", true, false, false},
{"已是线上最新:什么都不提示", cfg, "1.10.0", false, false, false},
{"高于线上版本(内测包):不提示", cfg, "1.11.0", false, false, true},
{"送审版本:标记审核期", cfg, "1.11.0", false, false, true},
{"客户端未上报版本:一律 false,不能把老客户端全挡住", cfg, "", false, false, false},
{"该渠道未配置:一律 false", nil, "1.0.0", false, false, false},
{"字符串比较陷阱:1.9 < 1.10 应判定为需更新", cfg, "1.9.9", true, false, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
need, force, reviewing := evalChannelUpgrade(c.cfg, c.client)
if need != c.need || force != c.force || reviewing != c.reviewing {
t.Fatalf("client=%q 得到 need=%v force=%v reviewing=%v,期望 need=%v force=%v reviewing=%v",
c.client, need, force, reviewing, c.need, c.force, c.reviewing)
}
})
}
}
// 运营把最低支持版本填得比线上版本还高时,不能出现「必须更新但没有更新可用」这种客户端无解的组合。
func TestEvalChannelUpgradeForceImpliesNeed(t *testing.T) {
cfg := &comm.ChannelApp{Version: "1.0.0", MinVersion: "2.0.0", ForceUpdate: true}
need, force, _ := evalChannelUpgrade(cfg, "1.5.0")
if !force {
t.Fatal("低于最低支持版本应强更")
}
if !need {
t.Fatal("强更时 needupdate 必须同时为 true,否则客户端会收到「必须更新但无更新」")
}
}
// 停用/草稿的渠道由 model 层过滤成 nil,这里确认 nil 不会 panic 也不会误判。
func TestEvalChannelUpgradeNilSafe(t *testing.T) {
if need, force, reviewing := evalChannelUpgrade(nil, "1.0.0"); need || force || reviewing {
t.Fatal("nil 配置必须全 false")
}
}

88
apps/services/modules/user/api_live_probe_test.go

@ -1,88 +0,0 @@
package user
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"yunyan/lego/utils/crypto/aes"
"yunyan/pb"
)
// 联调用:打一次线上接口,解密后反序列化成对象,方便打断点看数据。改下面三个常量即可。
//
// go test ./modules/user/ -run TestApiProbe -v
//
// probeKey 留空 = 不解密(v1 之类不加密的接口)。⚠️ 数据含明文凭据,别外传。
const (
probeURL = "https://echomeet.voitrans.net/api/home/user_getappconfig_v2"
probeToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJFYXJwaG9uZSIsInN1YiI6IlNnaW4iLCJuYmYiOjE3ODY3NjEzNTYsImlhdCI6MTc4Njc2MTM1NiwianRpIjoiMjA4ODQ1NDYxNjI2MDgwNDYwOCJ9.tAkiUVNNEJMF4_Gfl1qYseUmh73CWcU5Ls9govEOtEQ"
probeKey = "83KNTeWwQ4GiQHhlNJH1H8RyN88HNUOf" // 该环境 .env 里的 GATEWAY_ENCRYPT_KEY
)
// probeResult 换接口时把 Data 的类型改掉即可:
//
// user_getappconfig / _v2 → pb.UserGetAppConfigResp
// user_getappconfig_v3 → pb.UserGetAppConfigV3Resp
// user_getthirdsvcs_v2 → pb.UserGetThirdSvcsResp
// user_getagents_v2 / _v3 → pb.UserGetAgentsResp / pb.UserGetAgentsV3Resp
type probeResult struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data pb.UserGetAppConfigResp `json:"data"`
}
func TestApiProbe(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, probeURL, bytes.NewBufferString("{}"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", probeToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
body := string(raw)
if resp.Header.Get("X-Encrypted") == "1" {
if probeKey == "" {
t.Fatal("响应是加密的,请先填 probeKey(该环境的 GATEWAY_ENCRYPT_KEY)")
}
body = aes.AesDecryptCBC(strings.TrimSpace(body), probeKey)
if !strings.HasPrefix(strings.TrimSpace(body), "{") {
t.Fatalf("解密失败:probeKey 与该环境的 GATEWAY_ENCRYPT_KEY 不一致(长度须 16/24/32,当前 %d)", len(probeKey))
}
}
// ① 强类型对象——断点看它,字段能直接展开
var result probeResult
if err := json.Unmarshal([]byte(body), &result); err != nil {
t.Fatalf("反序列化失败: %v", err)
}
env := result.Data.Env
agents := result.Data.Agents
mcps := result.Data.Mcps
products := result.Data.Products
// ② 通用对象——换了接口懒得改 Data 类型时,看这个
var generic map[string]interface{}
_ = json.Unmarshal([]byte(body), &generic)
// 断点打在这一行,watch: result / env / mcps / generic
t.Logf("code=%d msg=%s | env=%d agents=%d mcps=%d products=%d",
result.Code, result.Msg, len(env), len(agents), len(mcps), len(products))
for k, v := range env {
t.Logf(" env[%s] = %s", k, v)
}
for id, m := range mcps {
t.Logf(" mcp[%s] name=%s url=%s type=%v tools=%s", id, m.Servername, m.Url, m.Type, m.Tools)
}
for _, a := range agents {
t.Logf(" agent id=%s name=%s", a.Id, a.Name)
}
}

71
apps/services/modules/user/api_v3_getagents_test.go

@ -1,71 +0,0 @@
package user
import (
"testing"
"yunyan/comm"
)
// v3 下发的 voice_map 是「补全后」的:客户端不带音色表,切语言时得能直接查到。
func TestResolveAgentVoices(t *testing.T) {
a := &comm.AgentConfig{
Type: comm.AgentTypeChat,
Gender: comm.GenderFemale,
SupportLangs: "zh-CN, en-US ,ja-JP",
DefaultLang: "zh-CN", // 与 support_langs 重复,不该出两条
VoiceMap: []comm.AgentVoice{
{Language: "zh-CN", Gender: comm.GenderFemale, Voice: "zh-CN-XiaoyiNeural"}, // 运营选过,优先
},
}
got := resolveAgentVoices(a, comm.TTSProviderAzure)
if len(got) != 3 {
t.Fatalf("三种支持语言应各一条,实际 %d 条: %+v", len(got), got)
}
byLang := map[string]string{}
for _, v := range got {
if _, dup := byLang[v.Language]; dup {
t.Fatalf("语言 %s 出现重复", v.Language)
}
byLang[v.Language] = v.Voice
}
if byLang["zh-CN"] != "zh-CN-XiaoyiNeural" {
t.Fatalf("运营在后台选过的音色应优先,实际 %s", byLang["zh-CN"])
}
if byLang["en-US"] != "en-US-JennyNeural" || byLang["ja-JP"] != "ja-JP-NanamiNeural" {
t.Fatalf("未配的语言应按音色表取第一个女声,实际 %+v", byLang)
}
}
// 音色表覆盖不到的:不能瞎发一个别的语言的音色,宁可不发让客户端走服务商默认。
func TestResolveAgentVoicesSkipsUnknown(t *testing.T) {
a := &comm.AgentConfig{Type: comm.AgentTypeChat, Gender: comm.GenderMale, SupportLangs: "zh-CN,xx-YY"}
got := resolveAgentVoices(a, comm.TTSProviderAzure)
if len(got) != 1 || got[0].Language != "zh-CN" {
t.Fatalf("未知语言不该下发,实际 %+v", got)
}
// 服务商不在音色表里(火山等)且运营没手配 → 一条都不发
if n := len(resolveAgentVoices(a, "volcengine")); n != 0 {
t.Fatalf("音色表未覆盖的服务商应不下发音色,实际 %d 条", n)
}
// 但运营手填过的照发
a2 := &comm.AgentConfig{
Type: comm.AgentTypeChat, SupportLangs: "zh-CN",
VoiceMap: []comm.AgentVoice{{Language: "zh-CN", Voice: "BV700_streaming"}},
}
got2 := resolveAgentVoices(a2, "volcengine")
if len(got2) != 1 || got2[0].Voice != "BV700_streaming" {
t.Fatalf("手填音色应照常下发,实际 %+v", got2)
}
}
// 出声的是哪个服务:对话类看 TTS,端到端对话看 STS(音色候选按服务商区分,取错就全错)。
func TestVoiceSvcIdOf(t *testing.T) {
chat := &comm.AgentConfig{Type: comm.AgentTypeChat, TtsSvcId: "tts_azure", StsSvcId: "sts_x"}
if got := voiceSvcIdOf(chat); got != "tts_azure" {
t.Fatalf("chat 应取 tts_svc_id,实际 %s", got)
}
sts := &comm.AgentConfig{Type: comm.AgentTypeStsChat, TtsSvcId: "tts_azure", StsSvcId: "sts_x"}
if got := voiceSvcIdOf(sts); got != "sts_x" {
t.Fatalf("sts-chat 应取 sts_svc_id,实际 %s", got)
}
}

26
apps/services/modules/user/api_v3_getappconfig.go

@ -37,15 +37,7 @@ func (this *apiV3Comp) GetAppConfig(session comm.IUserSession, req *pb.UserGetAp
// 环境变量:应用自有配置(app_config) 打底,本区域的 global_config 覆盖(口径同 v2)。
config, globalconfig, agents, _ := this.module.configmodel.getdb()
env := make(map[string]string, len(config)+len(globalconfig))
for _, v := range config {
env[v.Key] = v.Value
}
for _, v := range globalconfig {
if v.Region == region {
env[v.Key] = v.Value
}
}
env := mergeAppEnv(config, globalconfig, region)
products, err := this.module.cache.GetProducts()
if err != nil && err != mysql.ErrNoDocuments {
@ -70,6 +62,22 @@ func (this *apiV3Comp) GetAppConfig(session comm.IUserSession, req *pb.UserGetAp
return
}
// mergeAppEnv 合并下发给客户端的环境变量:应用自有配置(app_config) 打底,
// 本区域的 global_config 覆盖同名 key。口径与 v2 一致——非本区域的 global_config 一律不下发,
// 否则客户端会拿到别的区域的服务地址/密钥。
func mergeAppEnv(config []*pb.DBAppConfigItem, globalconfig []*pb.DBGlobalConfigItem, region pb.Region) map[string]string {
env := make(map[string]string, len(config)+len(globalconfig))
for _, v := range config {
env[v.Key] = v.Value
}
for _, v := range globalconfig {
if v.Region == region {
env[v.Key] = v.Value
}
}
return env
}
// meetCapabilities 取会议记录能力;echomeet 未随本服务装载时返回空壳(字段都在,列表为空),
// 免得客户端还要判 null。
func (this *apiV3Comp) meetCapabilities() *pb.MeetCapabilitiesItem {

67
apps/services/modules/user/compat_test.go

@ -1,67 +0,0 @@
package user
import (
"encoding/json"
"sync"
"testing"
"yunyan/pb"
"gorm.io/gorm/schema"
)
// 老包按「非空 int」解析 factoryid,缺字段整个响应就解析不了。这里固化两件事:
// 值等于 brandid,且 brandid=0 时也要输出 0(不能被 omitempty 吃掉变成 null)。
func TestCompatProductFillsFactoryid(t *testing.T) {
cases := []struct {
name string
brandid uint32
}{
{"正常品牌商", 40981},
{"无品牌归属:仍要输出 0,不能缺字段", 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
p := compatProduct(&pb.DBProduct{Id: 45107, Brandid: c.brandid})
if p.Factoryid != c.brandid {
t.Fatalf("factoryid=%d,期望等于 brandid=%d", p.Factoryid, c.brandid)
}
buf, err := json.Marshal(p)
if err != nil {
t.Fatal(err)
}
var got map[string]any
if err = json.Unmarshal(buf, &got); err != nil {
t.Fatal(err)
}
v, ok := got["factoryid"]
if !ok || v == nil {
t.Fatalf("下发 JSON 缺 factoryid(老客户端会 TypeError):%s", buf)
}
if v.(float64) != float64(c.brandid) {
t.Fatalf("下发 factoryid=%v,期望 %d", v, c.brandid)
}
})
}
}
func TestCompatProductsNilSafe(t *testing.T) {
if compatProduct(nil) != nil {
t.Fatal("nil 入参不应造出对象")
}
compatProducts([]*pb.DBProduct{nil, {Brandid: 7}}) // 不能 panic
}
// factoryid 是纯下发字段:gorm 必须完全忽略它,否则 AutoMigrate 会给产品表建一个恒为 0 的多余列。
func TestFactoryidNotPersisted(t *testing.T) {
s, err := schema.Parse(&pb.DBProduct{}, &sync.Map{}, schema.NamingStrategy{})
if err != nil {
t.Fatal(err)
}
if f := s.LookUpField("factoryid"); f != nil {
t.Fatalf("factoryid 被 gorm 当成了库字段(列 %s),应由 gorm:\"-\" 忽略", f.DBName)
}
if s.LookUpField("brandid") == nil {
t.Fatal("brandid 应该是库字段,测试基准不成立")
}
}

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

@ -1,15 +1,442 @@
package user_test
// user 模块的全部单元测试集中在本文件,别再按接口拆一堆 xxx_test.go。
// 分区顺序:① 公共桩 ② getappconfig_v3 ③ getagents_v3 ④ getchannelapp ⑤ 老客户端兼容 ⑥ 杂项 ⑦ 线上联调探针。
// 用内部包 package user,测试可以直接调未导出的函数(resolveAgentVoices / mergeAppEnv 等)。
//
// 约定:每个测试函数自带它要用的数据/地址/密钥(写在函数体内的局部 const/变量),
// 不放包级全局——改一个接口的联调参数不会波及别的测试。①区只放无状态的桩类型与构造函数。
package user
import (
"yunyan/lego/utils/crypto/aes"
"bytes"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"reflect"
"strings"
"sync"
"testing"
"time"
"yunyan/comm"
"yunyan/lego/utils/crypto/aes"
"yunyan/pb"
"github.com/golang-jwt/jwt/v4"
"gorm.io/gorm/schema"
)
// ---------------------------------------------------------------------------
// ① 公共桩
// ---------------------------------------------------------------------------
// fakeEchomeet 假的会议记录模块,只为把一份能力快照喂给 v3 的 meet 字段。
type fakeEchomeet struct{ caps comm.MeetCapabilities }
func (f *fakeEchomeet) MeetCapabilities() comm.MeetCapabilities { return f.caps }
// newV3Comp 造一个只够跑纯逻辑的 apiV3Comp:不 Init(Init 要真 service),
// 只挂一个带 echomeet 的 User。echomeet 传 nil = 本服务没装 echomeet 模块。
func newV3Comp(echomeet comm.IEchomeet) *apiV3Comp {
return &apiV3Comp{module: &User{echomeet: echomeet}}
}
// ---------------------------------------------------------------------------
// ② user_getappconfig_v3
// ---------------------------------------------------------------------------
// env 是客户端启动就要用的服务地址/密钥。合并口径错一点,客户端就连到别的区域去了。
func TestV3MergeAppEnv(t *testing.T) {
config := []*pb.DBAppConfigItem{
{Key: "OSS_BUCKET", Value: "app-oss"},
{Key: "LLM_URL", Value: "https://app-default"}, // 会被本区域的 global 覆盖
}
globalconfig := []*pb.DBGlobalConfigItem{
{Region: pb.Region_RegionChina, Key: "LLM_URL", Value: "https://cn"},
{Region: pb.Region_RegionChina, Key: "CN_ONLY", Value: "1"},
{Region: pb.Region_RegionUSA, Key: "LLM_URL", Value: "https://us"},
{Region: pb.Region_RegionUSA, Key: "US_ONLY", Value: "1"},
}
t.Run("本区域 global 覆盖 app_config", func(t *testing.T) {
env := mergeAppEnv(config, globalconfig, pb.Region_RegionChina)
if env["LLM_URL"] != "https://cn" {
t.Fatalf("同名 key 应被本区域 global_config 覆盖,实际 %s", env["LLM_URL"])
}
if env["OSS_BUCKET"] != "app-oss" {
t.Fatalf("global 没配的 key 应保留 app_config 的值,实际 %s", env["OSS_BUCKET"])
}
if env["CN_ONLY"] != "1" {
t.Fatal("本区域独有的 key 应下发")
}
if _, ok := env["US_ONLY"]; ok {
t.Fatal("别的区域的 key 不能下发给中国用户")
}
})
t.Run("换个区域取到的是另一套值", func(t *testing.T) {
env := mergeAppEnv(config, globalconfig, pb.Region_RegionUSA)
if env["LLM_URL"] != "https://us" {
t.Fatalf("美区应取美区的值,实际 %s", env["LLM_URL"])
}
if _, ok := env["CN_ONLY"]; ok {
t.Fatal("中国区独有的 key 不能下发给美区用户")
}
})
t.Run("没有该区域配置时只剩 app_config 打底", func(t *testing.T) {
env := mergeAppEnv(config, globalconfig, pb.Region_RegionBrazil)
if env["LLM_URL"] != "https://app-default" {
t.Fatalf("无区域覆盖时应回落到 app_config,实际 %s", env["LLM_URL"])
}
if len(env) != 2 {
t.Fatalf("不该混入别区域的 key,实际 %+v", env)
}
})
t.Run("空配置返回空 map 而不是 nil", func(t *testing.T) {
env := mergeAppEnv(nil, nil, pb.Region_RegionUSA)
if env == nil {
t.Fatal("env 必须非 nil,否则下发 JSON 是 null,客户端取值直接崩")
}
if len(env) != 0 {
t.Fatalf("空输入应得空 map,实际 %+v", env)
}
})
}
// echomeet 没随本服务装载时(比如只跑 home 不跑会议记录),meet 得是个字段齐全的空壳,
// 不能是 null——客户端不该为此写一堆判空。
func TestV3MeetCapabilitiesWithoutEchomeet(t *testing.T) {
item := newV3Comp(nil).meetCapabilities()
if item == nil {
t.Fatal("meet 不能为 nil")
}
if item.TranscribeLanguages == nil || item.TranslateLanguages == nil || item.SummaryServices == nil {
t.Fatalf("列表必须是空数组而不是 nil:%+v", item)
}
buf, err := json.Marshal(item)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(buf), "null") {
t.Fatalf("下发 JSON 不该出现 null:%s", buf)
}
}
// 能力快照 → 下发结构的逐字段映射。漏一个字段客户端就会少一种语言/少一个可选模型。
func TestV3MeetCapabilitiesMapping(t *testing.T) {
caps := comm.MeetCapabilities{
TranscribeLanguages: []string{"zh-CN", "en-US"},
TranscribeAnyLanguage: true,
TranslateLanguages: []string{"ja-JP"},
TranslateAnyLanguage: false,
SummaryClientChoice: true,
SummaryServices: []comm.MeetSummaryService{
{SvcId: "llm_qwen", Name: "通义千问", IsDefault: true},
{SvcId: "llm_gpt", Name: "GPT", IsDefault: false},
},
}
item := newV3Comp(&fakeEchomeet{caps: caps}).meetCapabilities()
if !reflect.DeepEqual(item.TranscribeLanguages, caps.TranscribeLanguages) {
t.Fatalf("识别语言映射错:%+v", item.TranscribeLanguages)
}
if !reflect.DeepEqual(item.TranslateLanguages, caps.TranslateLanguages) {
t.Fatalf("翻译语言映射错:%+v", item.TranslateLanguages)
}
if !item.TranscribeAnyLanguage || item.TranslateAnyLanguage {
t.Fatalf("不限语言标记映射错:transcribe=%v translate=%v", item.TranscribeAnyLanguage, item.TranslateAnyLanguage)
}
if !item.SummaryClientChoice {
t.Fatal("summary_client_choice 映射错,客户端会以为不能选模型")
}
if len(item.SummaryServices) != 2 {
t.Fatalf("总结模型应有 2 项,实际 %d", len(item.SummaryServices))
}
got := item.SummaryServices[0]
if got.SvcId != "llm_qwen" || got.Name != "通义千问" || !got.IsDefault {
t.Fatalf("总结模型项映射错:%+v", got)
}
if item.SummaryServices[1].IsDefault {
t.Fatal("非默认项不该被标成默认,客户端会选错模型")
}
}
// echomeet 在,但后台还没编排出任何服务:语言列表仍要是空数组;
// 「不限语言」这类标记即使列表为空也得照传(此时列表为空不代表不支持)。
func TestV3MeetCapabilitiesEmptySnapshot(t *testing.T) {
item := newV3Comp(&fakeEchomeet{caps: comm.MeetCapabilities{TranscribeAnyLanguage: true}}).meetCapabilities()
if item.TranscribeLanguages == nil || item.TranslateLanguages == nil || item.SummaryServices == nil {
t.Fatalf("空快照下列表仍须为空数组:%+v", item)
}
if !item.TranscribeAnyLanguage {
t.Fatal("列表为空时 any_language 更要照传,否则客户端会以为一种语言都不支持")
}
}
// v3 的响应契约:不再有 mcps(并入 thirdsvcs),必须有 meet。
// pb 重新生成或有人手改 proto 把字段动了,这里先炸,而不是等客户端解析失败。
func TestV3AppConfigRespContract(t *testing.T) {
resp := &pb.UserGetAppConfigV3Resp{
Env: map[string]string{},
Agents: []*pb.DBAgent{},
Products: []*pb.DBProduct{},
Thirdsvcs: []*pb.ThirdSvcItem{},
Meet: newV3Comp(nil).meetCapabilities(),
}
buf, err := json.Marshal(resp)
if err != nil {
t.Fatal(err)
}
var got map[string]any
if err = json.Unmarshal(buf, &got); err != nil {
t.Fatal(err)
}
for _, k := range []string{"env", "agents", "products", "thirdsvcs", "meet"} {
if _, ok := got[k]; !ok {
t.Fatalf("v3 响应缺字段 %s:%s", k, buf)
}
}
if _, ok := got["mcps"]; ok {
t.Fatalf("v3 不该再下发 mcps(已并入 thirdsvcs):%s", buf)
}
}
// 客户端靠 categories 含 "10" 从 thirdsvcs 里挑出 MCP 服务——v3 去掉 mcps 后这是唯一识别方式。
func TestV3McpIdentifiedByCategories(t *testing.T) {
svcs := []*pb.ThirdSvcItem{
{Id: "svc_llm", Categories: "1,2"},
{Id: "svc_mcp", Categories: fmt.Sprintf("3,%d", comm.SvcCatMCP)},
{Id: "svc_mcp_only", Categories: fmt.Sprintf("%d", comm.SvcCatMCP)},
}
mcps := make([]string, 0, len(svcs))
for _, s := range svcs {
if comm.CategoriesHasMCP(s.Categories) {
mcps = append(mcps, s.Id)
}
}
if !reflect.DeepEqual(mcps, []string{"svc_mcp", "svc_mcp_only"}) {
t.Fatalf("MCP 识别错,实际 %+v", mcps)
}
}
// 路由靠反射按方法名注册:方法改名 / 签名不合规 → 路由静默消失,客户端 404。
// Version 在 Init 里置为 "v3",这里按注册规则算出路由名对一遍。
func TestV3GetAppConfigRoute(t *testing.T) {
m, ok := reflect.TypeOf(&apiV3Comp{}).MethodByName("GetAppConfig")
if !ok {
t.Fatal("apiV3Comp 上找不到 GetAppConfig,路由 user_getappconfig_v3 会消失")
}
mt := m.Type
if mt.NumIn() != 3 || mt.NumOut() != 2 {
t.Fatalf("签名不合反射注册约定:入参 %d 出参 %d", mt.NumIn(), mt.NumOut())
}
if !mt.In(1).Implements(reflect.TypeOf((*comm.IUserSession)(nil)).Elem()) {
t.Fatalf("第一个入参须是 comm.IUserSession,实际 %s", mt.In(1))
}
if mt.In(2) != reflect.TypeOf(&pb.UserGetAppConfigV3Req{}) {
t.Fatalf("请求类型应为 *pb.UserGetAppConfigV3Req,实际 %s", mt.In(2))
}
if mt.Out(0) != reflect.TypeOf(&pb.UserGetAppConfigV3Resp{}) {
t.Fatalf("响应类型应为 *pb.UserGetAppConfigV3Resp,实际 %s", mt.Out(0))
}
if mt.Out(1) != reflect.TypeOf(&pb.ErrorData{}) {
t.Fatalf("第二个出参应为 *pb.ErrorData,实际 %s", mt.Out(1))
}
route := fmt.Sprintf("%s_%s_%s", comm.ModuleUser, strings.ToLower(m.Name), "v3")
if route != "user_getappconfig_v3" {
t.Fatalf("反射算出的路由是 %s,与客户端约定的 user_getappconfig_v3 不一致", route)
}
}
// ---------------------------------------------------------------------------
// ③ user_getagents_v3
// ---------------------------------------------------------------------------
// v3 下发的 voice_map 是「补全后」的:客户端不带音色表,切语言时得能直接查到。
func TestResolveAgentVoices(t *testing.T) {
a := &comm.AgentConfig{
Type: comm.AgentTypeChat,
Gender: comm.GenderFemale,
SupportLangs: "zh-CN, en-US ,ja-JP",
DefaultLang: "zh-CN", // 与 support_langs 重复,不该出两条
VoiceMap: []comm.AgentVoice{
{Language: "zh-CN", Gender: comm.GenderFemale, Voice: "zh-CN-XiaoyiNeural"}, // 运营选过,优先
},
}
got := resolveAgentVoices(a, comm.TTSProviderAzure)
if len(got) != 3 {
t.Fatalf("三种支持语言应各一条,实际 %d 条: %+v", len(got), got)
}
byLang := map[string]string{}
for _, v := range got {
if _, dup := byLang[v.Language]; dup {
t.Fatalf("语言 %s 出现重复", v.Language)
}
byLang[v.Language] = v.Voice
}
if byLang["zh-CN"] != "zh-CN-XiaoyiNeural" {
t.Fatalf("运营在后台选过的音色应优先,实际 %s", byLang["zh-CN"])
}
if byLang["en-US"] != "en-US-JennyNeural" || byLang["ja-JP"] != "ja-JP-NanamiNeural" {
t.Fatalf("未配的语言应按音色表取第一个女声,实际 %+v", byLang)
}
}
// 音色表覆盖不到的:不能瞎发一个别的语言的音色,宁可不发让客户端走服务商默认。
func TestResolveAgentVoicesSkipsUnknown(t *testing.T) {
a := &comm.AgentConfig{Type: comm.AgentTypeChat, Gender: comm.GenderMale, SupportLangs: "zh-CN,xx-YY"}
got := resolveAgentVoices(a, comm.TTSProviderAzure)
if len(got) != 1 || got[0].Language != "zh-CN" {
t.Fatalf("未知语言不该下发,实际 %+v", got)
}
// 服务商不在音色表里(火山等)且运营没手配 → 一条都不发
if n := len(resolveAgentVoices(a, "volcengine")); n != 0 {
t.Fatalf("音色表未覆盖的服务商应不下发音色,实际 %d 条", n)
}
// 但运营手填过的照发
a2 := &comm.AgentConfig{
Type: comm.AgentTypeChat, SupportLangs: "zh-CN",
VoiceMap: []comm.AgentVoice{{Language: "zh-CN", Voice: "BV700_streaming"}},
}
got2 := resolveAgentVoices(a2, "volcengine")
if len(got2) != 1 || got2[0].Voice != "BV700_streaming" {
t.Fatalf("手填音色应照常下发,实际 %+v", got2)
}
}
// 出声的是哪个服务:对话类看 TTS,端到端对话看 STS(音色候选按服务商区分,取错就全错)。
func TestVoiceSvcIdOf(t *testing.T) {
chat := &comm.AgentConfig{Type: comm.AgentTypeChat, TtsSvcId: "tts_azure", StsSvcId: "sts_x"}
if got := voiceSvcIdOf(chat); got != "tts_azure" {
t.Fatalf("chat 应取 tts_svc_id,实际 %s", got)
}
sts := &comm.AgentConfig{Type: comm.AgentTypeStsChat, TtsSvcId: "tts_azure", StsSvcId: "sts_x"}
if got := voiceSvcIdOf(sts); got != "sts_x" {
t.Fatalf("sts-chat 应取 sts_svc_id,实际 %s", got)
}
}
// ---------------------------------------------------------------------------
// ④ user_getchannelapp(渠道包版本判定)
// ---------------------------------------------------------------------------
// 客户端拿到的就是这三个布尔值,各端据此决定「提示更新 / 阻断使用 / 隐藏未过审功能」,
// 判定错了直接表现为线上事故,故逐个组合固化。
func TestEvalChannelUpgrade(t *testing.T) {
cfg := &comm.ChannelApp{Version: "1.10.0", MinVersion: "1.5.0", ForceUpdate: true, ReviewVersion: "1.11.0"}
cases := []struct {
name string
cfg *comm.ChannelApp
client string
need, force, reviewing bool
}{
{"低于最低支持版本:强更", cfg, "1.4.9", true, true, false},
{"高于最低支持、低于线上:只提示更新", cfg, "1.9.0", true, false, false},
{"已是线上最新:什么都不提示", cfg, "1.10.0", false, false, false},
{"高于线上版本(内测包):不提示", cfg, "1.11.0", false, false, true},
{"送审版本:标记审核期", cfg, "1.11.0", false, false, true},
{"客户端未上报版本:一律 false,不能把老客户端全挡住", cfg, "", false, false, false},
{"该渠道未配置:一律 false", nil, "1.0.0", false, false, false},
{"字符串比较陷阱:1.9 < 1.10 应判定为需更新", cfg, "1.9.9", true, false, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
need, force, reviewing := evalChannelUpgrade(c.cfg, c.client)
if need != c.need || force != c.force || reviewing != c.reviewing {
t.Fatalf("client=%q 得到 need=%v force=%v reviewing=%v,期望 need=%v force=%v reviewing=%v",
c.client, need, force, reviewing, c.need, c.force, c.reviewing)
}
})
}
}
// 运营把最低支持版本填得比线上版本还高时,不能出现「必须更新但没有更新可用」这种客户端无解的组合。
func TestEvalChannelUpgradeForceImpliesNeed(t *testing.T) {
cfg := &comm.ChannelApp{Version: "1.0.0", MinVersion: "2.0.0", ForceUpdate: true}
need, force, _ := evalChannelUpgrade(cfg, "1.5.0")
if !force {
t.Fatal("低于最低支持版本应强更")
}
if !need {
t.Fatal("强更时 needupdate 必须同时为 true,否则客户端会收到「必须更新但无更新」")
}
}
// 停用/草稿的渠道由 model 层过滤成 nil,这里确认 nil 不会 panic 也不会误判。
func TestEvalChannelUpgradeNilSafe(t *testing.T) {
if need, force, reviewing := evalChannelUpgrade(nil, "1.0.0"); need || force || reviewing {
t.Fatal("nil 配置必须全 false")
}
}
// ---------------------------------------------------------------------------
// ⑤ 老客户端兼容字段
// ---------------------------------------------------------------------------
// 老包按「非空 int」解析 factoryid,缺字段整个响应就解析不了。这里固化两件事:
// 值等于 brandid,且 brandid=0 时也要输出 0(不能被 omitempty 吃掉变成 null)。
func TestCompatProductFillsFactoryid(t *testing.T) {
cases := []struct {
name string
brandid uint32
}{
{"正常品牌商", 40981},
{"无品牌归属:仍要输出 0,不能缺字段", 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
p := compatProduct(&pb.DBProduct{Id: 45107, Brandid: c.brandid})
if p.Factoryid != c.brandid {
t.Fatalf("factoryid=%d,期望等于 brandid=%d", p.Factoryid, c.brandid)
}
buf, err := json.Marshal(p)
if err != nil {
t.Fatal(err)
}
var got map[string]any
if err = json.Unmarshal(buf, &got); err != nil {
t.Fatal(err)
}
v, ok := got["factoryid"]
if !ok || v == nil {
t.Fatalf("下发 JSON 缺 factoryid(老客户端会 TypeError):%s", buf)
}
if v.(float64) != float64(c.brandid) {
t.Fatalf("下发 factoryid=%v,期望 %d", v, c.brandid)
}
})
}
}
func TestCompatProductsNilSafe(t *testing.T) {
if compatProduct(nil) != nil {
t.Fatal("nil 入参不应造出对象")
}
compatProducts([]*pb.DBProduct{nil, {Brandid: 7}}) // 不能 panic
}
// factoryid 是纯下发字段:gorm 必须完全忽略它,否则 AutoMigrate 会给产品表建一个恒为 0 的多余列。
func TestFactoryidNotPersisted(t *testing.T) {
s, err := schema.Parse(&pb.DBProduct{}, &sync.Map{}, schema.NamingStrategy{})
if err != nil {
t.Fatal(err)
}
if f := s.LookUpField("factoryid"); f != nil {
t.Fatalf("factoryid 被 gorm 当成了库字段(列 %s),应由 gorm:\"-\" 忽略", f.DBName)
}
if s.LookUpField("brandid") == nil {
t.Fatal("brandid 应该是库字段,测试基准不成立")
}
}
// ---------------------------------------------------------------------------
// ⑥ 杂项(token / 加解密工具)
// ---------------------------------------------------------------------------
func Test_Claims(t *testing.T) {
claims := &jwt.RegisteredClaims{
Issuer: "Earphone",
@ -86,3 +513,151 @@ func Test_CBC(t *testing.T) {
phone := aes.AesDecryptCBC(token, "gDSiGlKJIGuu84ZNe+j4FQ==")
fmt.Println(token, phone)
}
// ---------------------------------------------------------------------------
// ⑦ 线上联调探针(会真发网络请求,`go test -short` 可跳过)
// ---------------------------------------------------------------------------
// 联调用:打一次线上接口,解密后反序列化成对象,方便打断点看数据。
// 每个探针的 url / token / key 都写在**它自己的函数里**,测哪个接口就只改那个函数,互不影响。
// key 留空 = 不解密(v1 之类不加密的接口)。⚠️ 数据含明文凭据,别外传。
//
// probeCall 打一次接口并返回(必要时已解密的)响应体明文。
func probeCall(t *testing.T, url, token, key string) string {
t.Helper()
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBufferString("{}"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
body := string(raw)
if resp.Header.Get("X-Encrypted") == "1" {
if key == "" {
t.Fatal("响应是加密的,请在调用方的 key 里填该环境的 GATEWAY_ENCRYPT_KEY")
}
body = aes.AesDecryptCBC(strings.TrimSpace(body), key)
if !strings.HasPrefix(strings.TrimSpace(body), "{") {
t.Fatalf("解密失败:key 与该环境的 GATEWAY_ENCRYPT_KEY 不一致(长度须 16/24/32,当前 %d)", len(key))
}
}
return body
}
// user_getappconfig_v2 的线上联调:
//
// go test ./modules/user/ -run TestApiProbeAppConfigV2 -v
func TestApiProbeAppConfigV2(t *testing.T) {
if testing.Short() {
t.Skip("联调探针会打线上接口,-short 下跳过")
}
// ↓↓↓ 换环境/换账号只改这三行 ↓↓↓
const (
url = "https://echomeet.voitrans.net/api/home/user_getappconfig_v2"
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJFYXJwaG9uZSIsInN1YiI6IlNnaW4iLCJuYmYiOjE3ODY3NjEzNTYsImlhdCI6MTc4Njc2MTM1NiwianRpIjoiMjA4ODQ1NDYxNjI2MDgwNDYwOCJ9.tAkiUVNNEJMF4_Gfl1qYseUmh73CWcU5Ls9govEOtEQ"
key = "83KNTeWwQ4GiQHhlNJH1H8RyN88HNUOf" // 该环境 .env 里的 GATEWAY_ENCRYPT_KEY
)
body := probeCall(t, url, token, key)
// ① 强类型对象——断点看它,字段能直接展开
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data pb.UserGetAppConfigResp `json:"data"`
}
if err := json.Unmarshal([]byte(body), &result); err != nil {
t.Fatalf("反序列化失败: %v", err)
}
env := result.Data.Env
agents := result.Data.Agents
mcps := result.Data.Mcps
products := result.Data.Products
// ② 通用对象——换了接口懒得改 Data 类型时,看这个
var generic map[string]interface{}
_ = json.Unmarshal([]byte(body), &generic)
// 断点打在这一行,watch: result / env / mcps / generic
t.Logf("code=%d msg=%s | env=%d agents=%d mcps=%d products=%d",
result.Code, result.Msg, len(env), len(agents), len(mcps), len(products))
for k, v := range env {
t.Logf(" env[%s] = %s", k, v)
}
for id, m := range mcps {
t.Logf(" mcp[%s] name=%s url=%s type=%v tools=%s", id, m.Servername, m.Url, m.Type, m.Tools)
}
for _, a := range agents {
t.Logf(" agent id=%s name=%s", a.Id, a.Name)
}
}
// user_getappconfig_v3 的线上联调:核对 v3 相对 v2 的两处变化真的落到了响应里
// —— 不再有 mcps(MCP 混在 thirdsvcs 里按 categories 含 10 识别)、多了 meet。
//
// go test ./modules/user/ -run TestApiProbeAppConfigV3 -v
func TestApiProbeAppConfigV3(t *testing.T) {
if testing.Short() {
t.Skip("联调探针会打线上接口,-short 下跳过")
}
// ↓↓↓ 换环境/换账号只改这三行 ↓↓↓(当前 = 云雁测试环境 app-dev / 8.138.86.223)
const (
url = "https://app-dev.voitrans.net/api/home/user_getappconfig_v3"
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJFYXJwaG9uZSIsInN1YiI6IlNnaW4iLCJuYmYiOjE3ODcwNDUwMTksImlhdCI6MTc4NzA0NTAxOSwianRpIjoiMjA4ODQ1NDYxNjI2MDgwNDYwOCJ9.jhhyUqi4KdjnLJ0ngEfsd5G6nXRJ94tbDo_G4rsfDy8"
key = "83KNTeWWQ4GiQHhINJH1H8RyN88HNUOf" // 该环境 .env 里的 GATEWAY_ENCRYPT_KEY
)
body := probeCall(t, url, token, key)
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data pb.UserGetAppConfigV3Resp `json:"data"`
}
if err := json.Unmarshal([]byte(body), &result); err != nil {
t.Fatalf("反序列化失败: %v", err)
}
if result.Code != 0 {
t.Fatalf("接口返回失败 code=%d msg=%s", result.Code, result.Msg)
}
d := &result.Data // 取指针:pb 结构体含锁,直接复制会被 go vet 拦下
// 断点打在下一行,watch: result / d.Env / d.Thirdsvcs / d.Meet
t.Logf("env=%d agents=%d products=%d thirdsvcs=%d", len(d.Env), len(d.Agents), len(d.Products), len(d.Thirdsvcs))
for k, v := range d.Env {
t.Logf(" env[%s] = %s", k, v)
}
for _, s := range d.Thirdsvcs {
t.Logf(" svc[%s] name=%s provider=%s categories=%s fields=%d mcp=%v",
s.Id, s.Name, s.Provider, s.Categories, len(s.Fields), comm.CategoriesHasMCP(s.Categories))
}
for _, a := range d.Agents {
t.Logf(" agent id=%s name=%s", a.Id, a.Name)
}
// meet 是 v3 新增的:为空可以(后台没编排会议记录),为 null 不行——客户端会崩。
if d.Meet == nil {
t.Fatal("v3 必须下发 meet 字段(没编排也要给空壳)")
}
t.Logf(" meet transcribe=%v(any=%v) translate=%v(any=%v) summary=%d(choice=%v)",
d.Meet.TranscribeLanguages, d.Meet.TranscribeAnyLanguage,
d.Meet.TranslateLanguages, d.Meet.TranslateAnyLanguage,
len(d.Meet.SummaryServices), d.Meet.SummaryClientChoice)
for _, s := range d.Meet.SummaryServices {
t.Logf(" summary svc=%s name=%s default=%v", s.SvcId, s.Name, s.IsDefault)
}
// v3 不再单独下发 mcps:真出现了说明有人往回加字段,客户端会重复处理 MCP。
var generic map[string]any
if err := json.Unmarshal([]byte(body), &generic); err == nil {
if data, ok := generic["data"].(map[string]any); ok {
if _, ok := data["mcps"]; ok {
t.Fatal("v3 响应里出现了 mcps,应只在 thirdsvcs 里下发 MCP")
}
}
}
}

74
apps/services/pb/db.pb.go

@ -136,7 +136,8 @@ func (Codetype) EnumDescriptor() ([]byte, []int) {
}
// 厂家数据管理
// 方案商所属厂商(分组维度)。一个厂商可以有多个方案,每个方案一条 DBSolutionProvider 记录。
// 方案商所属芯片厂商(分组维度)。一个芯片厂商可以有多套方案,每套方案一条 DBSolutionProvider 记录。
// 新增厂商在这里加值,同时补 modules/console/api_device.go 的 chipVendorNames 与前端 utils/chipvendors.ts。
type SolutionVendor int32
const (
@ -144,6 +145,7 @@ const (
SolutionVendor_VENDOR_JIELI SolutionVendor = 1 //杰里
SolutionVendor_VENDOR_LANXUN SolutionVendor = 2 //蓝汛
SolutionVendor_VENDOR_HENGXUAN SolutionVendor = 3 //恒玄
SolutionVendor_VENDOR_BOTONG SolutionVendor = 4 //博通
)
// Enum value maps for SolutionVendor.
@ -153,12 +155,14 @@ var (
1: "VENDOR_JIELI",
2: "VENDOR_LANXUN",
3: "VENDOR_HENGXUAN",
4: "VENDOR_BOTONG",
}
SolutionVendor_value = map[string]int32{
"VENDOR_UNKNOWN": 0,
"VENDOR_JIELI": 1,
"VENDOR_LANXUN": 2,
"VENDOR_HENGXUAN": 3,
"VENDOR_BOTONG": 4,
}
)
@ -1166,11 +1170,11 @@ func (x *DBAppConfigItem) GetDescription() string {
type DBSolutionProvider struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id" gorm:"id;primaryKey"` //@go_tags(`gorm:"id;primaryKey" json:"id"`) 方案商id(手工指定,不自增)
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name" gorm:"name;index"` //@go_tags(`gorm:"name;index" json:"name"`) 方案商名称
Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description" gorm:"description"` //@go_tags(`gorm:"description" json:"description"`) 描述
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name" gorm:"name;index"` //@go_tags(`gorm:"name;index" json:"name"`) 方案商公司名称
Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description" gorm:"description"` //@go_tags(`gorm:"description" json:"description"`) 方案说明(这套方案做什么)
Contactmails string `protobuf:"bytes,4,opt,name=contactmails,proto3" json:"contactmails" gorm:"contactmails"` //@go_tags(`gorm:"contactmails" json:"contactmails"`) 联系人邮箱
Createtime int64 `protobuf:"varint,5,opt,name=createtime,proto3" json:"createtime" gorm:"createtime"` //@go_tags(`gorm:"createtime" json:"createtime"`) 创建时间
Vendor SolutionVendor `protobuf:"varint,6,opt,name=vendor,proto3,enum=SolutionVendor" json:"vendor" gorm:"vendor;index"` //@go_tags(`gorm:"vendor;index" json:"vendor"`) 所属厂商分组(杰里/蓝汛/恒玄)
Vendor SolutionVendor `protobuf:"varint,6,opt,name=vendor,proto3,enum=SolutionVendor" json:"vendor" gorm:"vendor;index"` //@go_tags(`gorm:"vendor;index" json:"vendor"`) 所属芯片厂商分组(杰里/蓝汛/恒玄/博通)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -1252,11 +1256,14 @@ type DBBrand struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id" gorm:"id;primaryKey;autoIncrement"` //@go_tags(`gorm:"id;primaryKey;autoIncrement" json:"id"`) 品牌商id
Brand string `protobuf:"bytes,2,opt,name=brand,proto3" json:"brand" gorm:"brand"` //@go_tags(`gorm:"brand" json:"brand"`) 品牌商名称
Providerid uint32 `protobuf:"varint,3,opt,name=providerid,proto3" json:"providerid" gorm:"providerid;index"` //@go_tags(`gorm:"providerid;index" json:"providerid"`) 归属方案商id
// 【已废弃】品牌商不归属方案商——方案商是「产品用了哪套芯片方案」,同一品牌不同产品可以用不同方案,
// 所以归属挂在 DBProduct.providerid 上。本字段与 brand.providerid 列均已停写,仅为兼容旧数据保留,勿再使用。
Providerid uint32 `protobuf:"varint,3,opt,name=providerid,proto3" json:"providerid" gorm:"providerid;index"` //@go_tags(`gorm:"providerid;index" json:"providerid"`) [已废弃]原「归属方案商id」
Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description" gorm:"description"` //@go_tags(`gorm:"description" json:"description"`) 描述
Contactmails string `protobuf:"bytes,5,opt,name=contactmails,proto3" json:"contactmails" gorm:"contactmails"` //@go_tags(`gorm:"contactmails" json:"contactmails"`) 联系人邮箱 xxx@qq.com,xxx@gmail.com
Productlists string `protobuf:"bytes,7,opt,name=productlists,proto3" json:"productlists" gorm:"productlists"` //@go_tags(`gorm:"productlists" json:"productlists"`) 产品列表 1,2,3
Sharerate int32 `protobuf:"varint,8,opt,name=sharerate,proto3" json:"sharerate" gorm:"sharerate"` //@go_tags(`gorm:"sharerate" json:"sharerate"`) 分成比例(万分比):平台从订单实付里分给品牌商的总提成,渠道商再从这份里切
Createtime int64 `protobuf:"varint,9,opt,name=createtime,proto3" json:"createtime" gorm:"createtime"` //@go_tags(`gorm:"createtime" json:"createtime"`) 创建时间(秒);历史品牌商为0
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -1340,6 +1347,13 @@ func (x *DBBrand) GetSharerate() int32 {
return 0
}
func (x *DBBrand) GetCreatetime() int64 {
if x != nil {
return x.Createtime
}
return 0
}
// 渠道商(挂在品牌商下,一个品牌商可绑定多个渠道商)
// id 用 8 位可读短码而非自增:会印在生产台账/结算单上并对外沟通,短码可读、不暴露规模,
// 且作为统计与结算的维度键(string)跨库不冲突。新增走审批:status=0 时不可用于生产。
@ -1851,6 +1865,12 @@ type DBProduct struct {
// 产品归属的应用(应用中心 app_product.name,如 Voitrans,Lumi),CSV 可多选、空=未绑定。
// 品牌商/渠道商账号的「应用」下拉即由本字段推导:只列出自己品牌名下产品绑定过的应用。
Appnames string `protobuf:"bytes,35,opt,name=appnames,proto3" json:"appnames" gorm:"appnames;size:255"` //@go_tags(`gorm:"appnames;size:255" json:"appnames"`) 绑定应用名CSV(空=未绑定)
// 产品用的是哪套芯片方案(DBSolutionProvider.id)。生产设备码时的服务ID(CID)直接取这里——
// 以前从品牌商推导,但同一品牌的不同产品可能用不同方案商的方案,推导会串号,故改为产品级绑定,新建产品必填。
Providerid uint32 `protobuf:"varint,36,opt,name=providerid,proto3" json:"providerid" gorm:"providerid;index"` //@go_tags(`gorm:"providerid;index" json:"providerid"`) 归属方案商id(CID)
Createtime int64 `protobuf:"varint,37,opt,name=createtime,proto3" json:"createtime" gorm:"createtime"` //@go_tags(`gorm:"createtime" json:"createtime"`) 创建时间(秒);历史产品为0
// 产品说明:这款产品是什么、有什么特性,给后台运营与客户看的,与版本更新说明(updatedescription)无关。
Description string `protobuf:"bytes,38,opt,name=description,proto3" json:"description" gorm:"description"` //@go_tags(`gorm:"description" json:"description"`) 产品说明(特性描述)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -2123,6 +2143,27 @@ func (x *DBProduct) GetAppnames() string {
return ""
}
func (x *DBProduct) GetProviderid() uint32 {
if x != nil {
return x.Providerid
}
return 0
}
func (x *DBProduct) GetCreatetime() int64 {
if x != nil {
return x.Createtime
}
return 0
}
func (x *DBProduct) GetDescription() string {
if x != nil {
return x.Description
}
return ""
}
type DBProductVersion struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id" gorm:"id;primaryKey;autoIncrement"` //@go_tags(`gorm:"id;primaryKey;autoIncrement" json:"id"`) 版本id
@ -3550,7 +3591,7 @@ const file_db_proto_rawDesc = "" +
"\n" +
"createtime\x18\x05 \x01(\x03R\n" +
"createtime\x12'\n" +
"\x06vendor\x18\x06 \x01(\x0e2\x0f.SolutionVendorR\x06vendor\"\xd7\x01\n" +
"\x06vendor\x18\x06 \x01(\x0e2\x0f.SolutionVendorR\x06vendor\"\xf7\x01\n" +
"\aDBBrand\x12\x0e\n" +
"\x02id\x18\x01 \x01(\rR\x02id\x12\x14\n" +
"\x05brand\x18\x02 \x01(\tR\x05brand\x12\x1e\n" +
@ -3560,7 +3601,10 @@ const file_db_proto_rawDesc = "" +
"\vdescription\x18\x04 \x01(\tR\vdescription\x12\"\n" +
"\fcontactmails\x18\x05 \x01(\tR\fcontactmails\x12\"\n" +
"\fproductlists\x18\a \x01(\tR\fproductlists\x12\x1c\n" +
"\tsharerate\x18\b \x01(\x05R\tsharerate\"\xb9\x03\n" +
"\tsharerate\x18\b \x01(\x05R\tsharerate\x12\x1e\n" +
"\n" +
"createtime\x18\t \x01(\x03R\n" +
"createtime\"\xb9\x03\n" +
"\tDBChannel\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x18\n" +
"\abrandid\x18\x02 \x01(\rR\abrandid\x12\x12\n" +
@ -3617,7 +3661,7 @@ const file_db_proto_rawDesc = "" +
"\abatchno\x18\n" +
" \x01(\tR\abatchno\x12\x16\n" +
"\x06remark\x18\v \x01(\tR\x06remark\x12\x1c\n" +
"\tchannelid\x18\f \x01(\tR\tchannelid\"\x84\n" +
"\tchannelid\x18\f \x01(\tR\tchannelid\"\xe6\n" +
"\n" +
"\tDBProduct\x12\x0e\n" +
"\x02id\x18\x01 \x01(\rR\x02id\x12\x18\n" +
@ -3660,7 +3704,14 @@ const file_db_proto_rawDesc = "" +
"bassfilter\x12\"\n" +
"\fserialcursor\x18! \x01(\x03R\fserialcursor\x12\x1c\n" +
"\tfactoryid\x18\" \x01(\rR\tfactoryid\x12\x1a\n" +
"\bappnames\x18# \x01(\tR\bappnames\"\xb0\x01\n" +
"\bappnames\x18# \x01(\tR\bappnames\x12\x1e\n" +
"\n" +
"providerid\x18$ \x01(\rR\n" +
"providerid\x12\x1e\n" +
"\n" +
"createtime\x18% \x01(\x03R\n" +
"createtime\x12 \n" +
"\vdescription\x18& \x01(\tR\vdescription\"\xb0\x01\n" +
"\x10DBProductVersion\x12\x0e\n" +
"\x02id\x18\x01 \x01(\rR\x02id\x12\x1c\n" +
"\tproductid\x18\x02 \x01(\rR\tproductid\x12\x18\n" +
@ -3828,12 +3879,13 @@ const file_db_proto_rawDesc = "" +
"\bCodetype\x12\f\n" +
"\bAUTHCODE\x10\x00\x12\x0e\n" +
"\n" +
"DEVICECODE\x10\x01*^\n" +
"DEVICECODE\x10\x01*q\n" +
"\x0eSolutionVendor\x12\x12\n" +
"\x0eVENDOR_UNKNOWN\x10\x00\x12\x10\n" +
"\fVENDOR_JIELI\x10\x01\x12\x11\n" +
"\rVENDOR_LANXUN\x10\x02\x12\x13\n" +
"\x0fVENDOR_HENGXUAN\x10\x03B\x06Z\x04.;pbb\x06proto3"
"\x0fVENDOR_HENGXUAN\x10\x03\x12\x11\n" +
"\rVENDOR_BOTONG\x10\x04B\x06Z\x04.;pbb\x06proto3"
var (
file_db_proto_rawDescOnce sync.Once

115
apps/services/sys/pay/paypal/paypal.go

@ -4,16 +4,25 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand"
"net/http"
"strings"
"sync"
"time"
)
// ErrWebhookNotConfigured 缺 ClientID/Secret/WebhookID,无法向 PayPal 验签。
// 此时 VerifyWebhook 一律判为「不可信」——没有验签能力就不能放行通知,否则谁都能伪造一笔支付来领资源。
var ErrWebhookNotConfigured = errors.New("paypal webhook 未配置:缺少 ClientID/Secret/WebhookID,无法验签")
type PayPal struct {
options Options
httpClient *http.Client
// accessToken 由多个请求共享(webhook 是并发入口),读写一律走 mu
mu sync.Mutex
accessToken string
}
@ -41,11 +50,10 @@ func (p *PayPal) CreateAppOrder(ctx context.Context, outTradeNo string, totalFee
)
// 确保 token 可用
if p.accessToken == "" {
if err = p.refreshAccessToken(ctx); err != nil {
var accessToken string
if accessToken, err = p.token(ctx); err != nil {
return
}
}
// 构造下单请求体(简化版)
amount := fmt.Sprintf("%.2f", float64(totalFee)/100)
@ -77,7 +85,7 @@ func (p *PayPal) CreateAppOrder(ctx context.Context, outTradeNo string, totalFee
url := strings.TrimRight(p.options.BaseURL, "/") + "/v2/checkout/orders"
httpReq, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(bodyBytes)))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+p.accessToken)
httpReq.Header.Set("Authorization", "Bearer "+accessToken)
resp, err = p.httpClient.Do(httpReq)
if err != nil {
@ -100,11 +108,95 @@ func (p *PayPal) CreateAppOrder(ctx context.Context, outTradeNo string, totalFee
return
}
// VerifyWebhook 验证 PayPal Webhook(骨架:返回 true)。实际应调用 /v1/notifications/verify-webhook-signature
// VerifyWebhook 验证 PayPal Webhook 签名:把通知头 + 原始事件体交给 PayPal 的
// /v1/notifications/verify-webhook-signature 端点判定真伪,verification_status=SUCCESS 才算通过。
//
// 两个要点:
// - webhook_id 一律取**配置里的** WebhookID,绝不用请求头带来的——头是调用方给的,可以随便伪造;
// - webhook_event 必须是**原样的**通知体字节,重新序列化会改变字段顺序/空白,签名就对不上了。
//
// 未配置凭据时返回 ErrWebhookNotConfigured(判为不可信):没有验签能力就不该放行通知。
func (p *PayPal) VerifyWebhook(headers map[string]string, body []byte) (bool, error) {
// 这里保留骨架,实际实现需向 PayPal 验签端点提交:transmission_id、timestamp、signature、webhook_id、cert_url、auth_algo、body
// 为不影响现有代码结构,先返回 true
if p.options.ClientID == "" || p.options.Secret == "" || p.options.WebhookID == "" {
return false, ErrWebhookNotConfigured
}
if len(body) == 0 {
return false, errors.New("paypal webhook 通知体为空")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
reqBody := map[string]any{
"auth_algo": headers["PayPal-Auth-Algo"],
"cert_url": headers["PayPal-Cert-Url"],
"transmission_id": headers["PayPal-Transmission-Id"],
"transmission_sig": headers["PayPal-Transmission-Sig"],
"transmission_time": headers["PayPal-Transmission-Time"],
"webhook_id": p.options.WebhookID,
"webhook_event": json.RawMessage(body),
}
payload, err := json.Marshal(reqBody)
if err != nil {
return false, err
}
// token 过期时 PayPal 回 401,刷一次再试;仍失败就判为未通过
for attempt := 0; attempt < 2; attempt++ {
accessToken, terr := p.token(ctx)
if terr != nil {
return false, terr
}
url := strings.TrimRight(p.options.BaseURL, "/") + "/v1/notifications/verify-webhook-signature"
httpReq, rerr := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(payload)))
if rerr != nil {
return false, rerr
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+accessToken)
resp, derr := p.httpClient.Do(httpReq)
if derr != nil {
return false, derr
}
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized && attempt == 0 {
p.mu.Lock()
p.accessToken = "" // 作废当前令牌,下一轮重新换
p.mu.Unlock()
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return false, fmt.Errorf("paypal 验签接口返回 %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var out struct {
VerificationStatus string `json:"verification_status"`
}
if err = json.Unmarshal(raw, &out); err != nil {
return false, err
}
if out.VerificationStatus != "SUCCESS" {
return false, fmt.Errorf("paypal 验签未通过: %s", out.VerificationStatus)
}
return true, nil
}
return false, errors.New("paypal 验签失败:访问令牌无效")
}
// token 取可用的访问令牌,没有就先换一个。并发安全(webhook 可能被 PayPal 并发推送)。
func (p *PayPal) token(ctx context.Context) (string, error) {
p.mu.Lock()
tk := p.accessToken
p.mu.Unlock()
if tk != "" {
return tk, nil
}
if err := p.refreshAccessToken(ctx); err != nil {
return "", err
}
p.mu.Lock()
tk = p.accessToken
p.mu.Unlock()
return tk, nil
}
// 刷新访问令牌(Client Credentials)
@ -130,22 +222,23 @@ func (p *PayPal) refreshAccessToken(ctx context.Context) error {
if err = json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return err
}
p.mu.Lock()
p.accessToken = tokenResp.AccessToken
p.mu.Unlock()
return nil
}
// CaptureOrder 执行订单扣款(服务端)并返回原始响应 JSON(包含状态)
func (p *PayPal) CaptureOrder(ctx context.Context, orderID string) (result string, err error) {
// 确保 token 可用
if p.accessToken == "" {
if err = p.refreshAccessToken(ctx); err != nil {
var accessToken string
if accessToken, err = p.token(ctx); err != nil {
return
}
}
url := strings.TrimRight(p.options.BaseURL, "/") + "/v2/checkout/orders/" + orderID + "/capture"
httpReq, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader("{}"))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+p.accessToken)
httpReq.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := p.httpClient.Do(httpReq)
if err != nil {
return

4
deploy/admin/dev-deploy.sh

@ -52,14 +52,14 @@ env_profile() {
ENV_DESC="云雁测试环境 · 广州阿里云"
registry_profile yunyan # 镜像仓库:云雁(registry.voitrans.net,就在这台机上)
DEV_HOST="8.138.86.223" # ← 云雁测试服务器 IP(2026-07 由腾讯云 124.222.232.218 迁来)
DEV_KEY=~/.ssh/aliyun_guangzhou.pem # 本机访问该服务器的 SSH 私钥
DEV_KEY="/Users/liwei1dao/work/密钥/dev-guangzhou.pem" # 本机访问该服务器的 SSH 私钥(2026-08-18 由 ~/.ssh/aliyun_guangzhou.pem 迁到此处)
DEV_DIR="/home/work/starpivot/admin"
;;
along)
ENV_DESC="阿龙测试环境 · 阿里云"
registry_profile along # 镜像仓库:就在这台机上(registry.ymaikj.com,账号 admin)
DEV_HOST="8.133.166.29" # ← 阿龙测试服务器 IP
DEV_KEY=~/.ssh/along.pem # 原件在 ~/Documents/阿龙/loginscre.pem,因 macOS TCC 挡文稿目录已拷贝到 ~/.ssh
DEV_KEY="/Users/liwei1dao/liwei/密钥/along/releae-guangzhou.pem" # 本机访问该服务器的 SSH 私钥(2026-08-18 由 ~/.ssh/along.pem 迁到此处;文件名 releae 是原文件笔误)
DEV_DIR="/home/work/starpivot/admin"
;;
*) red "✗ 未知环境: ${DEV_ENV}(应为 ${ENVS[*]// /|})"; exit 1 ;;

2
deploy/admin/prod-deploy.sh

@ -60,7 +60,7 @@ env_profile() {
ENV_DESC="阿龙生产环境 · 阿里云"
registry_profile along || return 1 # 镜像仓库:registry.ymaikj.com(实体在阿龙测试机 8.133.166.29,账号 admin)
DEPLOY_HOST="47.116.104.181" # ← 阿龙生产服务器 IP
DEPLOY_KEY=~/.ssh/along.pem # 与测试机同一把钥匙;原件在 ~/Documents/阿龙/loginscre.pem,因 macOS TCC 挡文稿目录已拷贝到 ~/.ssh
DEPLOY_KEY="/Users/liwei1dao/liwei/密钥/along/releae-guangzhou.pem" # 与阿龙测试机同一把钥匙(2026-08-18 由 ~/.ssh/along.pem 迁到此处;文件名 releae 是原文件笔误)
DEPLOY_DIR="/home/work/starpivot/admin"
;;
*) red "✗ 未知环境: ${DEPLOY_ENV}(应为 ${PROD_ENVS[*]// /|})"; exit 1 ;;

25
deploy/app/README.md

@ -56,10 +56,19 @@ cd deploy/voitrans
./prod-deploy.sh shanghai 0.1.1 # 部署 0.1.1 到上海
./prod-deploy.sh japan 0.1.1 # 部署到日本
./prod-deploy.sh singapore 0.1.1 # 部署到新加坡
./prod-deploy.sh voitrans along 0.1.1 # 部署到阿龙正式机(镜像走阿龙仓库,需先 ./prod-build.sh 0.1.1 along)
./prod-deploy.sh ym along 0.1.4 # 部署 ym 到阿龙正式机(目录 /home/work/ym-a11,容器 ym-a11;镜像走阿龙仓库,需先 ./prod-build.sh 0.1.4 along)
./prod-deploy.sh shanghai rollback # 上海回滚到上一版本(与当前版本来回切换)
./prod-deploy.sh # 不带参数 → 交互引导:选应用 / 区域 / 版本
```
`prod-deploy.sh` 的三项参数(应用 / 区域 / tag|rollback)**按内容识别、顺序随意、可任意省略**,缺哪项就在交互里选哪项:
- 只给 `./prod-deploy.sh 0.1.4` → 交互里选应用和区域(以前会把 `0.1.4` 当成区域、把选区域那步整个跳过,最后报「未知区域: 0.1.4」);
- 认不出的参数当场报错,不会被猜成别的东西;
- ③ 选版本这步会**列出仓库里已推的 tag**(Docker Registry v2 API,取不到就退回手工输入),
并标出该应用远端「当前运行 / 上一版」,也可以直接敲 tag 或 `rollback`;
- 省略应用但给了区域+版本时,仍按旧用法落到默认应用(`voitrans`)—— 会有黄色提示,且在终端里会多一次确认。
> 改了服务器上的 `.env` 后,必须经 `up`(脚本里的 `docker compose up -d`)**重建容器**才生效,`restart` 不会重读环境变量。
> prod 的版本/回滚记录存服务器上的 `.deploy_tag` / `.deploy_tag.prev`,不动你维护的 `.env`。
@ -68,7 +77,11 @@ cd deploy/voitrans
- **部署目标与凭据**:写死在各脚本顶部。
- `dev-deploy.sh`:`env_profile()` 里各测试环境(`lingpu` / `yunyan` / `along`)的 `REGISTRY/DEV_HOST/DEV_KEY/DEV_DIR`。
- `prod-deploy.sh`:`region_profile()` 里各区域(`shanghai` / `japan` / `singapore` / `along`)的 `DEPLOY_HOST`;
另有 `REGION_COMPANY`(该区域拉哪个公司的仓库,可用环境变量 `COMPANY` 覆盖)与 `DEPLOY_DIR_OVERRIDE`(区域目录规范与 `APPS` 不同时用)。
另有 `REGION_COMPANY`(该区域拉哪个公司的仓库,可用环境变量 `COMPANY` 覆盖)。
远端目录只由 `APPS` 表决定(`ym` → `/home/work/ym-a11`),区域里**不再覆盖目录**;容器名由应用 key 推导为 `<应用>-a11`,
部署时以 `CONTAINER_NAME` 注入 compose 并写回服务器 `.env`,所以同一台机可以并存多个应用。
`docker-compose.yml` 也由脚本按应用生成后下发(模板里占位的 service `app` 会被改写成 `<应用>-a11`),
与远端一致就跳过、不一致才备份 `.bak` 后更新;服务器上只需维护 `.env` 与 `confs/*.yaml`。
- 仓库账号 `admin` / 密码已写死;`DEPLOY_KEY` 默认 `~/.ssh/id_rsa`。
- ⚠️ 本目录脚本纳入 git,**公开仓库前务必清空** `*_HOST` 与 `REGISTRY_PASS`。
- **基础设施端点**:填服务器 `.env` 的 `ETCD_SERVERS / DB_ADMIN_DSN / DB_DEVICE_DSN / REDIS_* / NATS_URL`,
@ -77,8 +90,12 @@ cd deploy/voitrans
- **confs 引用的附属文件**(按需放入服务器部署目录,会随工作目录挂入容器 `/app`):
- `home.yaml`:`./smart-bluetooth-447104-2b269474bd72.json`(Google)、firebase/google 登录 json、
`./apiclient_key.pem`(微信支付)、`confs/ip2region_v4.xdb` 与 `v6.xdb`(IP 库)、
`wordfilter/*.txt`(敏感词,不需要则删掉 `home.yaml` 的 `wordfilter` 段)。
- 缺这些文件会导致对应服务启动失败(容器反复重启),用不到的集成请删掉对应配置段。
`wordfilter/*.txt`(敏感词,模板里**默认已全部注释**,要用才放文件并取消注释)。
- `confs/ip2region_v4.xdb` / `v6.xdb` 由 `dev-deploy.sh` / `prod-deploy.sh` **自动下发**(prod 只在远端缺失时传一次),不用手工放。
- ⚠️ 缺这些文件会让对应 sys 在初始化时 **panic**,entrypoint 随即杀掉整个容器 → 无限重启。
这种失败的日志有欺骗性:最扎眼的往往是 `Table 'xxx.userdevice' doesn't exist`(那些表本该由 home 里的
user 模块启动时建,home 起不来自然没表),真凶却是上面几十行处的 `panic: init sys.wordfilter err: no found file:...`。
`prod-deploy.sh` 现在会在部署前做一次前置检查,缺文件直接拦下并告诉你缺哪些。
## 端口(容器内,对外经反代/NPM 暴露)

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

@ -18,26 +18,30 @@ sys:
Name: "home"
MaxReconnects: -1
ReconnectWait: 2
# 敏感词过滤:需把词库 txt 放入 deploy/voitrans/wordfilter/。无需要时整段删除即可。
# 敏感词过滤:默认【全部注释掉 = 不加载任何词库】。
# ⚠️ 这些 txt 不在 git 里,要用就得自己放到部署目录的 wordfilter/ 下(会随工作目录挂进容器 /app)。
# 列表里只要有一个文件在服务器上不存在,home 就会 `panic: init sys.wordfilter err: no found file:...`
# → entrypoint 杀掉整个容器 → 无限重启;而表象常常是 api 报「Table 'xxx.userdevice' doesn't exist」
# (建表由 home 里的 user 模块负责,home 起不来自然没表)。2026-08-18 部署 ym 时踩过这个坑。
wordfilter:
WorldFile:
- ./wordfilter/暴恐词库.txt
- ./wordfilter/补充词库.txt
- ./wordfilter/反动词库.txt
- ./wordfilter/非法网址.txt
- ./wordfilter/广告类型.txt
- ./wordfilter/老版本词库.txt
- ./wordfilter/零时-Tencent.txt
- ./wordfilter/民生词库.txt
- ./wordfilter/色情词库.txt
- ./wordfilter/色情类型.txt
- ./wordfilter/涉枪涉爆.txt
- ./wordfilter/贪腐词库.txt
- ./wordfilter/网易前端过滤敏感词库.txt
- ./wordfilter/新思想启蒙.txt
- ./wordfilter/政治类型.txt
- ./wordfilter/COVID-19词库.txt
- ./wordfilter/GFW补充词库.txt
# - ./wordfilter/暴恐词库.txt
# - ./wordfilter/补充词库.txt
# - ./wordfilter/反动词库.txt
# - ./wordfilter/非法网址.txt
# - ./wordfilter/广告类型.txt
# - ./wordfilter/老版本词库.txt
# - ./wordfilter/零时-Tencent.txt
# - ./wordfilter/民生词库.txt
# - ./wordfilter/色情词库.txt
# - ./wordfilter/色情类型.txt
# - ./wordfilter/涉枪涉爆.txt
# - ./wordfilter/贪腐词库.txt
# - ./wordfilter/网易前端过滤敏感词库.txt
# - ./wordfilter/新思想启蒙.txt
# - ./wordfilter/政治类型.txt
# - ./wordfilter/COVID-19词库.txt
# - ./wordfilter/GFW补充词库.txt
mysql: #业务库(用户/设备/统计/任务等 per-app 业务数据)
MySQLDsn: ${DB_ADMIN_DSN:-}
postgres: #公共库(厂家/产品/设备码/公码等跨 app 共享数据)

11
deploy/app/dev-deploy.sh

@ -52,14 +52,14 @@ env_profile() {
ENV_DESC="云雁测试环境 · 广州阿里云"
registry_profile yunyan # 镜像仓库:云雁(registry.voitrans.net,就在这台机上)
DEV_HOST="8.138.86.223" # ← 云雁测试服务器 IP(2026-07 由腾讯云 124.222.232.218 迁来)
DEV_KEY=~/.ssh/aliyun_guangzhou.pem # 本机访问该服务器的 SSH 私钥
DEV_KEY="/Users/liwei1dao/work/密钥/dev-guangzhou.pem" # 本机访问该服务器的 SSH 私钥(2026-08-18 由 ~/.ssh/aliyun_guangzhou.pem 迁到此处)
DEV_DIR="/home/work/starpivot/app"
;;
along)
ENV_DESC="阿龙测试环境 · 阿里云"
registry_profile along # 镜像仓库:就在这台机上(registry.ymaikj.com,账号 admin)
DEV_HOST="8.133.166.29" # ← 阿龙测试服务器 IP
DEV_KEY=~/.ssh/along.pem # 原件在 ~/Documents/阿龙/loginscre.pem,因 macOS TCC 挡文稿目录已拷贝到 ~/.ssh
DEV_KEY="/Users/liwei1dao/liwei/密钥/along/releae-guangzhou.pem" # 本机访问该服务器的 SSH 私钥(2026-08-18 由 ~/.ssh/along.pem 迁到此处;文件名 releae 是原文件笔误)
DEV_DIR="/home/work/starpivot/app"
;;
*) red "✗ 未知环境: ${DEV_ENV}(应为 ${ENVS[*]// /|})"; exit 1 ;;
@ -142,6 +142,11 @@ if ! _ssh "test -f '$DEV_DIR/.env'"; then
exit 0
fi
_ssh "bash -lc 'for f in gateway api home mcp timer; do if [ ! -f \"$DEV_DIR/confs/\$f.yaml\" ] && [ -f \"$DEV_DIR/confs/\$f.yaml.example\" ]; then cp \"$DEV_DIR/confs/\$f.yaml.example\" \"$DEV_DIR/confs/\$f.yaml\"; fi; done'"
_ssh "cd '$DEV_DIR' && REGISTRY='$REGISTRY' IMAGE_NAME='$IMAGE_NAME' TAG='$TAG' docker compose --env-file .env pull && REGISTRY='$REGISTRY' IMAGE_NAME='$IMAGE_NAME' TAG='$TAG' docker compose --env-file .env up -d --force-recreate && docker image prune -f"
# compose 里的 service 名由 starpivot-app 改成通用的 app 后,服务器上按旧 service 名建的容器
# 既不属于新 service、又占着容器名 starpivot-app,直接 up 会报 "container name is already in use"。
# 它反正要被 --force-recreate 重建,这里先按 compose 标签认一下,不是本 service 的同名容器就删掉。
_ssh "old=\$(docker inspect -f '{{index .Config.Labels \"com.docker.compose.service\"}}' starpivot-app 2>/dev/null || true); if [ -n \"\$old\" ] && [ \"\$old\" != 'app' ]; then echo \"→ 移除旧 service '\$old' 留下的同名容器 starpivot-app\"; docker rm -f starpivot-app >/dev/null 2>&1 || true; fi"
# --remove-orphans:按旧 service 名建的容器在本项目里已成孤儿,一并清掉。
_ssh "cd '$DEV_DIR' && REGISTRY='$REGISTRY' IMAGE_NAME='$IMAGE_NAME' TAG='$TAG' docker compose --env-file .env pull && REGISTRY='$REGISTRY' IMAGE_NAME='$IMAGE_NAME' TAG='$TAG' docker compose --env-file .env up -d --force-recreate --remove-orphans && docker image prune -f"
green "✓ app dev[$DEV_ENV] 已发布: $FULL_IMAGE → $DEV_HOST:$DEV_DIR"

14
deploy/app/docker-compose.yml

@ -1,15 +1,23 @@
# app 业务单镜像部署
#
# starpivot-app: gateway + home + api + mcp + timer 五个集群服务(shell 脚本管理多进程)。
# gateway + home + api + mcp + timer 五个集群服务(shell 脚本管理多进程)打成单镜像。
# 服务名固定为 app,容器名由部署脚本按「应用」注入(CONTAINER_NAME,如 ym → ym-a11),
# 同一台机上多个应用(voitrans / ym / lumi …)各占一个目录、一个容器,互不撞名。
# 镜像版本通过 TAG 控制;接入外部网络(默认 1panel-network),与 etcd/mysql/redis/nats 通过容器名互通。
# 不直接 docker compose 调用,统一经 dev-deploy.sh / prod-deploy.sh(它们把 env/<env>.env 落为 .env)。
services:
starpivot-app:
# 这里的 app 只是模板占位:prod-deploy.sh 下发时会把这一行改写成本应用名
# (ym → `ym-a11:`),所以服务器上的 docker-compose.yml 里 service 名 = 容器名 = <应用>-a11,
# docker compose ps / 面板里一眼看得出是哪个应用。别在仓库模板里写死某个应用名。
# dev-deploy.sh 不改写(测试机单份部署),保持 app + 默认容器名 starpivot-app。
app:
# 镜像名由部署脚本注入(IMAGE_NAME,来自 deploy/registry-profiles.sh 的 prod_image_name),
# 这里的默认值只在手工 docker compose 时兜底——别在此写死,写死过一次就和脚本漂移了。
image: ${REGISTRY:-registry.voitrans.net}/${IMAGE_NAME:-starpivot-app}:${TAG:-latest}
container_name: starpivot-app
# 容器名按「应用」区分:prod-deploy.sh 注入 CONTAINER_NAME=<应用>-a11(ym → ym-a11),
# 并写回 .env,手工 docker compose 时也用同一个名字。默认值只给 dev / 未注入时兜底。
container_name: ${CONTAINER_NAME:-starpivot-app}
restart: unless-stopped
platform: ${PLATFORM:-linux/amd64}

4
deploy/app/env/env.example

@ -6,6 +6,10 @@
# TAG = 当前运行的镜像版本。dev 固定 dev-latest;prod 部署成功后由 prod-deploy.sh 自动写回为本次版本号(如 0.2.1),
# 所以服务器上 cat .env 的这一行就是当前实际运行版本,便于排查是否环境/版本问题。
TAG=dev-latest
# CONTAINER_NAME = 容器名,按「应用」区分,同一台机可并存多个应用(voitrans-a11 / ym-a11 …)。
# 生产由 prod-deploy.sh 按所选应用注入并写回本行(<应用>-a11),一般不用手填;
# 留空/缺失则回落到 compose 里的默认 starpivot-app(dev 单份部署就是走默认)。
CONTAINER_NAME=starpivot-app
PLATFORM=linux/amd64
DOCKER_NETWORK=1panel-network

282
deploy/app/prod-deploy.sh

@ -2,10 +2,14 @@
# 生产环境部署:选择「应用 + 区域 + 镜像版本 tag」部署,支持回滚。
#
# 镜像由 ./prod-build.sh 预先构建推送;本脚本只做「远端拉取 + 重启」。
# 所有应用共用同一镜像 starpivot-app 与本目录的模板,仅「远端运行目录」按应用区分(见下方 APPS)。
# 所有应用共用同一镜像 starpivot-app 与本目录的模板,「远端运行目录」和「容器名」按应用区分(见下方 APPS):
# ym → 目录 /home/work/ym-a11,容器 ym-a11;voitrans → /home/work/voitrans-a11,容器 voitrans-a11 …
# 这样同一台机可以并存多个应用,不会因为容器名相同互相顶掉。
# 各区域一套服务器(见下方 region_profile)。
# 仅做镜像部署:服务器上的 docker-compose.yml / .env / confs/ 全部由你自行维护,本脚本只下发 *.example 模板、绝不覆盖真实文件
# (首次照同步来的 docker-compose.yml.example / env.example / confs/*.yaml.example 在各应用远端目录复制成真实文件并填值)。
# 配置归属:.env 与 confs/*.yaml 由你在服务器维护(脚本只下发 *.example 模板,绝不覆盖);
# docker-compose.yml 则由本脚本统一下发覆盖——它不含任何环境相关内容(全部走 ${VAR},值来自 .env),
# 手工维护只会与仓库模板漂移:ym 首次部署时服务器上那份旧 compose 就把 container_name 写死成 starpivot-app,
# 导致改了模板也部不出 ym-a11。覆盖前会在远端留一份 docker-compose.yml.bak。
#
# 用法(在 deploy/voitrans/ 下):
# ./prod-deploy.sh # 不带参数 → 交互式引导:逐步选「应用 / 区域 / 版本」,部署前确认
@ -26,6 +30,7 @@ source "$REPO_ROOT/deploy/registry-profiles.sh"
blue() { printf "\033[34m%s\033[0m\n" "$*"; }
green() { printf "\033[32m%s\033[0m\n" "$*"; }
red() { printf "\033[31m%s\033[0m\n" "$*"; }
yellow(){ printf "\033[33m%s\033[0m\n" "$*"; }
# ── 私有仓库凭据(写死;公开仓库前清空 REGISTRY_PASS)──
# 镜像仓库按「区域所属公司」自动选(见 region_profile 里的 REGION_COMPANY:三个云雁区域走 yunyan,
@ -38,8 +43,11 @@ IMAGE_HINT="$(prod_image_name "$DEFAULT_COMPANY" app)" || exit 1
DOCKER_NETWORK="1panel-network" # compose 外部网络名(与服务器一致)
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ 应用列表 —— 共用镜像 starpivot-app 与本目录模板,仅「远端部署目录」按应用区分。 ║
# ║ 应用列表 —— 共用镜像 starpivot-app 与本目录模板,「远端部署目录」按应用区分。 ║
# ║ 格式: "key|显示名|远端部署目录" ║
# ║ 容器名由 key 推导:<key>-a11(见下方 CONTAINER_NAME),与目录 basename 一致。 ║
# ║ ⚠️ 目录只在此表配置,各区域一律照此表走——不要再在 region_profile 里覆盖, ║
# ║ 否则会出现「配了 ym-a11 却部到别处」(2026-08-18 部署 ym 时踩过)。 ║
# ║ 新增应用:在此加一行(远端目录在目标服务器上自行备好 .env/confs/compose)。 ║
# ╚══════════════════════════════════════════════════════════════════════╝
APPS=(
@ -50,6 +58,7 @@ APPS=(
"deapsound|DeapSound|/home/work/deapsound-a11"
"allhelp|AllHelp|/home/work/allhelp-a11"
"deepglass|DeepGlass|/home/work/deepglass-a11"
"ym|Ym|/home/work/ym-a11"
)
app_keys() { local e k; for e in "${APPS[@]}"; do IFS='|' read -r k _ <<<"$e"; printf '%s\n' "$k"; done; }
app_field() { # $1=key $2=字段序号(2=显示名 3=远端目录)
@ -72,9 +81,8 @@ DEFAULT_APP="${APPS[0]%%|*}" # 省略应用时的默认(首个)
# ╚══════════════════════════════════════════════════════════════════════╝
REGIONS=(shanghai japan singapore along) # 可选环境/区域(交互菜单按此顺序展示)
region_profile() { # 远端部署目录由「应用」决定(见 APPS),此处只管各区域服务器连接信息
region_profile() { # 远端部署目录/容器名由「应用」决定(见 APPS),此处只管各区域服务器连接信息
REGION_COMPANY="$DEFAULT_COMPANY" # 该区域镜像仓库所属公司(默认云雁;可被环境变量 COMPANY 覆盖)
DEPLOY_DIR_OVERRIDE="" # 非空则覆盖 APPS 里该应用的远端目录(个别区域目录规范不同)
case "$REGION" in
shanghai)
DEPLOY_HOST="139.224.212.237" # ← 上海生产服务器 IP(必填)
@ -98,9 +106,11 @@ region_profile() { # 远端部署目录由「应
DEPLOY_HOST="47.116.104.181" # ← 阿龙生产服务器 IP
DEPLOY_PORT=22
DEPLOY_USER=root
DEPLOY_KEY=~/.ssh/along.pem # 与阿龙测试机同一把钥匙;原件在 ~/Documents/阿龙/loginscre.pem,因 macOS TCC 挡文稿目录已拷贝到 ~/.ssh
DEPLOY_KEY="/Users/liwei1dao/liwei/密钥/along/releae-guangzhou.pem" # 与阿龙测试机同一把钥匙(2026-08-18 由 ~/.ssh/along.pem 迁到此处;文件名 releae 是原文件笔误)
REGION_COMPANY="along" # 镜像从 registry.ymaikj.com 拉(实体在阿龙测试机 8.133.166.29,账号 admin)
DEPLOY_DIR_OVERRIDE="/home/work/starpivot/app" # 阿龙机沿用测试环境的目录规范,不用 APPS 里的 /home/work/<应用>-a11
# 目录不在这里覆盖:一律走 APPS 表(ym → /home/work/ym-a11)。
# 早期这里曾写死 DEPLOY_DIR_OVERRIDE=/home/work/starpivot/app,导致部 ym 时落到该目录、
# 且与已有容器 starpivot-app 撞名,2026-08-18 移除。阿龙机上那份旧部署保持原样、不再由本脚本管理。
;;
*) red "✗ 未知区域: ${REGION}(应为 ${REGIONS[*]// /|})"; exit 1 ;;
esac
@ -111,48 +121,73 @@ region_host_of() { ( REGION="$1" region_profile >/dev/null 2>&1; printf '%s' "${
# ── 用法 ──
usage() {
blue "生产部署(共用镜像 ${IMAGE_HINT},仅远端目录按应用区分)"
blue "生产部署(共用镜像 ${IMAGE_HINT},远端目录与容器名按应用区分)"
cat >&2 <<EOF
用法: ./prod-deploy.sh <应用> <环境/区域> <tag|rollback>
用法: ./prod-deploy.sh [应用] [环境/区域] [tag|rollback]
三项参数顺序随意、可任意省略,脚本按「内容」认(不是按位置);缺哪项就在交互里选哪项。
应用 : $(app_keys | paste -sd '/' -)
环境/区域 : ${REGIONS[*]// /, }
tag : 由 ./prod-build.sh <tag> 预先构建推送的版本号,如 0.1.1
rollback : 回滚到上一次部署版本(与当前版本来回切换)
示例:
./prod-deploy.sh voitrans shanghai 0.1.1 # 部署 voitrans 0.1.1 到上海
./prod-deploy.sh voitrans japan rollback # voitrans 日本回滚到上一版本
./prod-deploy.sh ym along 0.1.4 # 部署 ym 0.1.4 到阿龙正式机
./prod-deploy.sh 0.1.4 # 只给版本 → 交互里选应用、区域
./prod-deploy.sh ym along # 给应用+区域 → 交互里选版本(会列出仓库里已有的 tag)
./prod-deploy.sh ym along rollback # ym 阿龙回滚到上一版本
./prod-deploy.sh shanghai 0.1.1 # 省略应用 → 默认首个应用(${DEFAULT_APP:-?})
提示: 不带参数(或参数不全)运行将进入交互式引导,逐步选择应用 / 环境 / 版本。
EOF
}
# ── 参数解析:<应用> <区域> <tag|rollback>;省略应用兼容旧式 <区域> <tag>(默认首个应用)──
# ── 参数解析:按「内容」识别,顺序随意 ──
# 以前按位置解析(<应用> <区域> <tag>,或旧式 <区域> <tag>),只给一个参数时它会被当成区域:
# ./prod-deploy.sh 0.1.4 → REGION=0.1.4,② 选区域整步被跳过,最后才报「未知区域: 0.1.4」。
# 现在每个参数各自归位,认不出的当场报错,绝不猜。
case "${1:-}" in -h|--help|help) usage; exit 0 ;; esac
ARGS=("$@")
APP_KEY=""; REGION=""; ARG2=""; INTERACTIVE=0
if [ "${#ARGS[@]}" -gt 0 ] && is_app "${ARGS[0]:-}"; then
APP_KEY="${ARGS[0]}"; REGION="${ARGS[1]:-}"; ARG2="${ARGS[2]:-}"
else
REGION="${ARGS[0]:-}"; ARG2="${ARGS[1]:-}" # 旧式:省略应用
fi
is_region() { local r; for r in "${REGIONS[@]}"; do [ "$r" = "$1" ] && return 0; done; return 1; }
is_tag_like() { case "$1" in ''|*[!A-Za-z0-9._-]*) return 1 ;; *) return 0 ;; esac; }
APP_KEY=""; REGION=""; TAG_ARG=""; MODE=""; INTERACTIVE=0
for a in "$@"; do
if is_app "$a"; then
[ -z "$APP_KEY" ] || { red "✗ 应用指定了两次: $APP_KEY / $a"; exit 1; }
APP_KEY="$a"
elif is_region "$a"; then
[ -z "$REGION" ] || { red "✗ 区域指定了两次: $REGION / $a"; exit 1; }
REGION="$a"
elif [ "$a" = "rollback" ]; then
MODE="rollback"
elif is_tag_like "$a"; then
[ -z "$TAG_ARG" ] || { red "✗ 版本指定了两次: $TAG_ARG / $a"; exit 1; }
TAG_ARG="$a"
else
red "✗ 无法识别的参数: '$a'"
red " 应用应为 $(app_keys | paste -sd '/' -);区域应为 ${REGIONS[*]// /|};版本只能含字母数字与 . _ -"
exit 1
fi
done
if [ -n "$MODE" ] && [ -n "$TAG_ARG" ]; then red "✗ rollback 与版本号 '$TAG_ARG' 不能同时给"; exit 1; fi
# 缺任一项就进交互(仅当不是「旧式两参数」这种完整非交互调用)
# 进入交互引导(打印一次用法抬头);同时标记为交互模式,末尾会二次确认
enter_interactive() { [ "$INTERACTIVE" = "1" ] && return 0; INTERACTIVE=1; usage; echo >&2; }
need_tty() { [ -t 0 ] || { red "✗ $1(当前无 TTY,无法交互,请在命令行显式给出)"; exit 1; }; }
# ① 选择应用
if [ -z "$APP_KEY" ]; then
if [ -n "$REGION" ] && [ -n "$ARG2" ]; then
APP_KEY="$DEFAULT_APP" # 旧式两参数:默认首个应用,不打断
if [ -n "$REGION" ] && { [ -n "$TAG_ARG" ] || [ -n "$MODE" ]; }; then
APP_KEY="$DEFAULT_APP" # 旧式「<区域> <tag>」:默认首个应用,不打断
yellow "⚠ 未指定应用,按旧式用法使用默认应用: ${APP_KEY}(其它应用请写成 ./prod-deploy.sh <应用> ${REGION} ${TAG_ARG:-rollback})"
if [ -t 0 ]; then INTERACTIVE=1; fi # 有终端时末尾仍二次确认,避免默认应用部错
else
need_tty "未指定应用"
enter_interactive
blue "① 选择应用:"
i=1
while IFS= read -r k; do
printf " %d) %-12s → 远端目录 %s\n" "$i" "$k" "$(app_field "$k" 3)" >&2
printf " %d) %-12s → 远端目录 %s 容器 %s\n" "$i" "$k" "$(app_field "$k" 3)" "${k}-a11" >&2
i=$((i+1))
done < <(app_keys)
napp="$(app_keys | wc -l | tr -d ' ')"
@ -166,12 +201,14 @@ if [ -z "$APP_KEY" ]; then
fi
is_app "$APP_KEY" || { red "✗ 未知应用: ${APP_KEY}(应为 $(app_keys | paste -sd '/' -))"; exit 1; }
# 解析应用 → 远端部署目录(镜像与模板共用 voitrans 这套)
# 解析应用 → 远端部署目录 + 容器名(镜像与模板共用同一套)
APP_DISPLAY="$(app_field "$APP_KEY" 2)"
DEPLOY_DIR="$(app_field "$APP_KEY" 3)"
CONTAINER_NAME="${APP_KEY}-a11" # 容器名按应用区分(ym → ym-a11),注入 compose 的 ${CONTAINER_NAME}
# ② 选择环境 / 区域
if [ -z "$REGION" ]; then
need_tty "未指定环境/区域"
enter_interactive
blue "② 选择部署环境 / 区域:"
i=1
@ -188,17 +225,83 @@ if [ -z "$REGION" ]; then
green " ✓ 环境: $REGION"
fi
# 区域定了才知道连哪台机、拉哪个仓库(③ 列版本时就要用,故提到这里)
region_profile
: "${DEPLOY_HOST:?请在 prod-deploy.sh 的 region_profile($REGION) 段填该区域 IP}"
: "${DEPLOY_KEY:?请在 prod-deploy.sh 的 region_profile($REGION) 段填该区域 SSH 私钥}"
COMPANY="${COMPANY_OVERRIDE:-$REGION_COMPANY}" # 默认跟区域走,环境变量 COMPANY 可强制覆盖
registry_profile "$COMPANY" || exit 1
IMAGE_NAME="$(prod_image_name "$COMPANY" app)" || exit 1
_ssh() { ssh -p "$DEPLOY_PORT" -i "${DEPLOY_KEY/#\~/$HOME}" -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 "$DEPLOY_USER@$DEPLOY_HOST" "$@"; }
_scp() { scp -P "$DEPLOY_PORT" -i "${DEPLOY_KEY/#\~/$HOME}" -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 "$@"; }
_dest() { echo "$DEPLOY_USER@$DEPLOY_HOST:$1"; }
# 仓库里已推送的 tag(Docker Registry v2 API);取不到就返回非 0,交互里退回手工输入
_sort_desc() { if printf '1\n' | sort -V >/dev/null 2>&1; then sort -Vr; else sort -r; fi; }
list_remote_tags() {
command -v curl >/dev/null 2>&1 || return 1
local json all nums rest
json="$(curl -fsS --max-time 10 -u "$REGISTRY_USER:$REGISTRY_PASS" "https://$REGISTRY/v2/$IMAGE_NAME/tags/list" 2>/dev/null)" || return 1
# 清空白只能用 sed —— tr -d '[:space:]' 会把换行一并删掉,整串 tag 会挤成一行
all="$(printf '%s' "$json" | sed -n 's/.*"tags":[[]\([^]]*\)[]].*/\1/p' | tr ',' '\n' | tr -d '"' | sed 's/[[:space:]]//g; /^$/d')"
[ -n "$all" ] || return 1
nums="$(printf '%s\n' "$all" | grep -E '^[0-9]' | _sort_desc || true)" # 版本号:新的排前面
rest="$(printf '%s\n' "$all" | grep -vE '^[0-9]' | sort || true)" # latest / dev-latest 之类排后面
printf '%s\n%s\n' "$nums" "$rest" | sed '/^$/d' | head -15
}
# 远端当前/上一次部署版本(读不到不影响部署,仅作展示)
remote_tags_state() { _ssh "printf 'CUR=%s\n' \"\$(cat '$DEPLOY_DIR/.deploy_tag' 2>/dev/null | tr -d '[:space:]')\"; printf 'PREV=%s\n' \"\$(cat '$DEPLOY_DIR/.deploy_tag.prev' 2>/dev/null | tr -d '[:space:]')\"" 2>/dev/null || true; }
# ③ 选择版本 / 动作
if [ -z "$ARG2" ]; then
if [ -z "$MODE" ] && [ -z "$TAG_ARG" ]; then
need_tty "未指定版本 tag"
enter_interactive
echo >&2
blue "③ 选择版本(输入由 prod-build.sh 推送的 tag,或输入 rollback 回滚):"
printf " tag 或 rollback: " >&2
read -r ARG2 || { red "✗ 已取消"; exit 1; }
[ -n "$ARG2" ] || { red "✗ 未输入版本"; exit 1; }
blue "③ 选择版本(镜像 ${REGISTRY}/${IMAGE_NAME},目标 ${DEPLOY_HOST}:${DEPLOY_DIR}):"
st="$(remote_tags_state)"
CUR_REMOTE="$(printf '%s\n' "$st" | sed -n 's/^CUR=//p' | head -1)"
PREV_REMOTE="$(printf '%s\n' "$st" | sed -n 's/^PREV=//p' | head -1)"
if [ -n "$CUR_REMOTE" ]; then
printf " 远端当前运行: %s%s\n" "$CUR_REMOTE" "${PREV_REMOTE:+ 上一版: $PREV_REMOTE}" >&2
else
printf " 远端尚无部署记录(.deploy_tag 为空,应是该应用首次部署)\n" >&2
fi
TAGS=()
while IFS= read -r t; do [ -n "$t" ] && TAGS+=("$t"); done < <(list_remote_tags || true)
if [ "${#TAGS[@]}" -gt 0 ]; then
i=1
for t in "${TAGS[@]}"; do
mark=""
[ "$t" = "$CUR_REMOTE" ] && mark=" ← 当前运行"
[ "$t" = "$PREV_REMOTE" ] && mark=" ← 上一版"
printf " %d) %s%s\n" "$i" "$t" "$mark" >&2
i=$((i+1))
done
else
yellow " (没能从仓库读到 tag 列表,请直接输入版本号)"
fi
printf " r) rollback%s\n" "${PREV_REMOTE:+ → 回到 $PREV_REMOTE}" >&2
printf " 请输入序号 / 版本号 / rollback: " >&2
read -r sel || { red "✗ 已取消"; exit 1; }
sel="$(printf '%s' "$sel" | tr -d '[:space:]')"
case "$sel" in
'') red "✗ 未输入"; exit 1 ;;
r|rollback) MODE="rollback" ;;
*[!0-9]*) TAG_ARG="$sel" ;; # 含非数字 → 当成版本号(如 0.1.4)
*) # 纯数字 → 序号;超出范围时才当版本号(如 20260818)
if [ "${#TAGS[@]}" -gt 0 ] && [ "$sel" -ge 1 ] && [ "$sel" -le "${#TAGS[@]}" ]; then
TAG_ARG="${TAGS[$((sel-1))]}"
else
TAG_ARG="$sel"
fi
;;
esac
green " ✓ 版本: ${TAG_ARG:-rollback}"
fi
if [ "$ARG2" = "rollback" ]; then MODE="rollback"; NEW_TAG=""; else MODE="deploy"; NEW_TAG="$ARG2"; fi
if [ "$MODE" != "rollback" ]; then MODE="deploy"; NEW_TAG="$TAG_ARG"; else NEW_TAG=""; fi
# deploy 模式校验 tag:去空白 + 非空 + 合法字符(避免空/空格 tag 让远端 docker 报 invalid reference format)
if [ "$MODE" = "deploy" ]; then
@ -209,46 +312,86 @@ if [ "$MODE" = "deploy" ]; then
esac
fi
region_profile
: "${DEPLOY_HOST:?请在 prod-deploy.sh 的 region_profile($REGION) 段填该区域 IP}"
: "${DEPLOY_KEY:?请在 prod-deploy.sh 的 region_profile($REGION) 段填该区域 SSH 私钥}"
# 区域定了才知道拉哪个仓库:默认跟区域走,环境变量 COMPANY 可强制覆盖
COMPANY="${COMPANY_OVERRIDE:-$REGION_COMPANY}"
registry_profile "$COMPANY" || exit 1
IMAGE_NAME="$(prod_image_name "$COMPANY" app)" || exit 1
# 个别区域(阿龙)目录规范与 APPS 里不同,此处覆盖
if [ -n "$DEPLOY_DIR_OVERRIDE" ]; then DEPLOY_DIR="$DEPLOY_DIR_OVERRIDE"; fi
# 交互模式下,部署前二次确认(带全参数的非交互/CI 调用不打断)
if [ "$INTERACTIVE" = "1" ]; then
echo >&2
blue "即将部署 → 应用: $APP_DISPLAY($APP_KEY) 镜像: $IMAGE_NAME 环境: $REGION 目标: $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_DIR 动作: $MODE${NEW_TAG:+ $NEW_TAG}"
blue "即将部署 → 应用: $APP_DISPLAY($APP_KEY) 镜像: $IMAGE_NAME 容器: $CONTAINER_NAME 环境: $REGION 目标: $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_DIR 动作: $MODE${NEW_TAG:+ $NEW_TAG}"
printf "确认执行?[y/N]: " >&2
read -r yn || true
case "$yn" in [yY]|[yY][eE][sS]) ;; *) red "已取消"; exit 0 ;; esac
fi
_ssh() { ssh -p "$DEPLOY_PORT" -i "${DEPLOY_KEY/#\~/$HOME}" -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" "$@"; }
_scp() { scp -P "$DEPLOY_PORT" -i "${DEPLOY_KEY/#\~/$HOME}" -o StrictHostKeyChecking=accept-new "$@"; }
_dest() { echo "$DEPLOY_USER@$DEPLOY_HOST:$1"; }
# 同步「模板」(只传 *.example,绝不覆盖你在服务器维护的真实 docker-compose.yml / .env / confs/*.yaml)
blue ">>> [$APP_KEY/$REGION] 同步模板 → $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_DIR"
blue ">>> [$APP_KEY/$REGION] 同步 compose 与模板 → $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_DIR"
_ssh "mkdir -p '$DEPLOY_DIR/confs' '$DEPLOY_DIR/log'"
_scp "$SVC_DIR/docker-compose.yml" "$(_dest "$DEPLOY_DIR/docker-compose.yml.example")"
# ── docker-compose.yml:按「应用」现生成再下发 ──
# 仓库模板里的 service 名是占位的 app,这里改写成本应用名(ym → ym-a11),
# 让 docker compose ps / 1Panel 面板里一眼看得出是哪个应用;container_name 仍由 ${CONTAINER_NAME} 注入。
# 下发策略:与远端现有文件逐字节比对,一致就跳过(等于「已存在且没变化就不处理」),
# 不一致才备份成 .bak 再覆盖 —— 纯粹「存在就不碰」会让服务器上的旧 compose 永远不更新,
# ym 首次部署容器名一直是 starpivot-app 就是这么来的。
COMPOSE_RENDERED="$(mktemp -t starpivot-compose)"
trap 'rm -f "$COMPOSE_RENDERED"' EXIT
sed "s|^ app:| ${CONTAINER_NAME}:|" "$SVC_DIR/docker-compose.yml" > "$COMPOSE_RENDERED"
grep -q "^ ${CONTAINER_NAME}:" "$COMPOSE_RENDERED" || { red "✗ 生成 compose 失败:模板 $SVC_DIR/docker-compose.yml 里没找到 service 定义行「 app:」"; exit 1; }
LOCAL_SUM="$(md5 -q "$COMPOSE_RENDERED" 2>/dev/null || md5sum "$COMPOSE_RENDERED" | cut -d' ' -f1)"
REMOTE_SUM="$(_ssh "md5sum '$DEPLOY_DIR/docker-compose.yml' 2>/dev/null | cut -d' ' -f1" || true)"
COMPOSE_CHANGED=0
if [ "$LOCAL_SUM" = "$REMOTE_SUM" ]; then
echo " docker-compose.yml 已是最新(service=${CONTAINER_NAME}),跳过"
else
COMPOSE_CHANGED=1
_ssh "[ -f '$DEPLOY_DIR/docker-compose.yml' ] && cp -a '$DEPLOY_DIR/docker-compose.yml' '$DEPLOY_DIR/docker-compose.yml.bak' || true"
_scp "$COMPOSE_RENDERED" "$(_dest "$DEPLOY_DIR/docker-compose.yml")"
echo " docker-compose.yml 已更新(service=${CONTAINER_NAME},旧文件备份为 docker-compose.yml.bak)"
fi
# 其余只发模板,绝不覆盖你维护的真实 .env / confs/*.yaml
_scp "$SVC_DIR"/confs/*.yaml.example "$(_dest "$DEPLOY_DIR/confs/")"
_scp "$SVC_DIR/env/env.example" "$(_dest "$DEPLOY_DIR/env.example")"
# ip2region 离线 IP 库:静态数据非凭据,home 的 sys.ipinfo 启动必需(缺了直接 panic)。
# 约 46MB,只在远端缺失时传一次(dev-deploy 是每次都传;prod 这边以前根本没传,新应用首次部署必挂)。
if ! _ssh "test -s '$DEPLOY_DIR/confs/ip2region_v4.xdb' && test -s '$DEPLOY_DIR/confs/ip2region_v6.xdb'"; then
blue ">>> [$APP_KEY/$REGION] 首次同步 ip2region 离线 IP 库(约 46MB,仅在远端缺失时传)"
_scp "$REPO_ROOT"/apps/services/sys/ipinfo/ip2region_v*.xdb "$(_dest "$DEPLOY_DIR/confs/")"
fi
# 启动前置检查:home.yaml 里引用的词库/IP 库文件必须真实存在,否则 home 在 sys 初始化阶段就 panic,
# entrypoint 随即杀掉整个容器 → 无限重启。这种失败的表象具有欺骗性:日志里最扎眼的是
# 「Table 'xxx.userdevice' doesn't exist」(那些表本该由 home 里的 user 模块建),
# 真凶却是上面几十行处的 panic。2026-08-18 部署 ym 时排查了半天,故在此提前拦一道。
MISSING="$(_ssh bash -s -- "$DEPLOY_DIR" <<'PREFLIGHT'
DIR="$1"; cd "$DIR" 2>/dev/null || exit 0
[ -f confs/home.yaml ] || exit 0
miss=""
for f in $(grep -v '^[[:space:]]*#' confs/home.yaml | grep -oE '\./wordfilter/[^ "]+\.txt' | sort -u); do
[ -f "$f" ] || miss="$miss $f"
done
for f in $(grep -v '^[[:space:]]*#' confs/home.yaml | grep -oE '\./confs/[^ "]+\.xdb' | sort -u); do
[ -f "$f" ] || miss="$miss $f"
done
printf '%s' "$miss"
PREFLIGHT
)"
if [ -n "$(printf '%s' "$MISSING" | tr -d '[:space:]')" ]; then
red "✗ 启动前置检查未通过 —— $DEPLOY_DIR 下缺少 confs/home.yaml 引用的文件:"
red " $MISSING"
red " 这些文件缺一个,home 就会在 sys 初始化时 panic,容器会无限重启(日志里却更像是「表不存在」)。"
red " 处理方式:"
red " · 词库 txt:不需要敏感词过滤就把 confs/home.yaml 里 WorldFile 那几行注释掉(模板已默认注释);"
red " 要用就把 txt 放到 $DEPLOY_DIR/wordfilter/ 下(它们不在 git 里)。"
red " · ip2region *.xdb:本脚本会自动下发,出现此提示说明上一步 scp 失败,检查网络/磁盘后重试。"
exit 1
fi
# 远端拉取并重启(不上传真实配置):版本历史存独立的 .deploy_tag;部署成功后会把当前 TAG 写回 .env 的 TAG 行(仅这一行)。
blue ">>> [$APP_KEY/$REGION] 远端部署($MODE${NEW_TAG:+ $NEW_TAG}) → $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_DIR"
_ssh bash -s -- "$DEPLOY_DIR" "$MODE" "$NEW_TAG" "$REGISTRY/$IMAGE_NAME" "$REGISTRY" "$REGISTRY_USER" "$REGISTRY_PASS" "$DOCKER_NETWORK" "$IMAGE_NAME" <<'REMOTE'
blue ">>> [$APP_KEY/$REGION] 远端部署($MODE${NEW_TAG:+ $NEW_TAG}) → $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_DIR 容器: $CONTAINER_NAME"
_ssh bash -s -- "$DEPLOY_DIR" "$MODE" "$NEW_TAG" "$REGISTRY/$IMAGE_NAME" "$REGISTRY" "$REGISTRY_USER" "$REGISTRY_PASS" "$DOCKER_NETWORK" "$IMAGE_NAME" "$CONTAINER_NAME" <<'REMOTE'
set -e
DIR="$1"; MODE="$2"; NEW_TAG="$3"; IMG_PREFIX="$4"; REG="$5"; REG_USER="$6"; REG_PASS="$7"; NET="$8"; IMG_NAME="${9}"
DIR="$1"; MODE="$2"; NEW_TAG="$3"; IMG_PREFIX="$4"; REG="$5"; REG_USER="$6"; REG_PASS="$7"; NET="$8"; IMG_NAME="${9}"; CNAME="${10}"
[ -f "$DIR/.env" ] || { echo "✗ 缺少 $DIR/.env(请照 env.example 在服务器填好真实 .env)"; exit 1; }
[ -f "$DIR/confs/gateway.yaml" ] || { echo "✗ 缺少 $DIR/confs/*.yaml(请照 confs/*.yaml.example 在服务器填好)"; exit 1; }
[ -f "$DIR/docker-compose.yml" ] || { echo "✗ 缺少 $DIR/docker-compose.yml(请照 docker-compose.yml.example 复制为 docker-compose.yml)"; exit 1; }
[ -f "$DIR/docker-compose.yml" ] || { echo "✗ 缺少 $DIR/docker-compose.yml(本应由部署脚本刚刚下发,检查上一步同步是否失败)"; exit 1; }
STATE="$DIR/.deploy_tag" # 当前部署 tag(独立文件,记录版本历史)
PREVF="$DIR/.deploy_tag.prev" # 上一次部署 tag
@ -266,9 +409,22 @@ fi
echo "$REG_PASS" | docker login "$REG" -u "$REG_USER" --password-stdin >/dev/null
docker network inspect "$NET" >/dev/null 2>&1 || docker network create "$NET"
docker pull "$IMG_PREFIX:$FINAL_TAG"
# service 名改过(app → <应用>-a11)之后,旧 compose 建的同名容器在新 compose 眼里既不是自己的服务、
# 又占着 container_name,直接 up 会报 "container name is already in use"。它反正要被 --force-recreate
# 重建,这里先按 compose 标签认一下:不是本 service 的同名容器就删掉。
OLD_SVC="$(docker inspect -f '{{index .Config.Labels "com.docker.compose.service"}}' "$CNAME" 2>/dev/null || true)"
if [ -n "$OLD_SVC" ] && [ "$OLD_SVC" != "$CNAME" ]; then
echo "→ 同名容器 $CNAME 属于旧 service '$OLD_SVC',先移除再按新 compose 重建"
docker rm -f "$CNAME" >/dev/null 2>&1 || true
fi
# REGISTRY/IMAGE_NAME/TAG 通过 inline 环境变量传给 compose(优先级高于 --env-file),
# 确保 compose 用的镜像和上面 docker pull 的完全是同一个——两者曾经漂移过(pull voitrans-a11、compose 找 starpivot-app)
cd "$DIR" && REGISTRY="$REG" IMAGE_NAME="$IMG_NAME" TAG="$FINAL_TAG" docker compose --env-file .env up -d --force-recreate
# CONTAINER_NAME 决定容器叫什么(<应用>-a11),同一台机上多个应用靠它区分;
# COMPOSE_PROJECT_NAME 同样按应用取,避免两个应用目录同名时 compose 项目撞车。
# --remove-orphans:旧 compose 里 service 名各不相同(starpivot-app / <应用>-a11 …),统一成 app 之后
# 那些旧容器会变成本项目的孤儿容器,不清掉会继续用旧名字跑着、甚至和新容器名撞车
cd "$DIR" && REGISTRY="$REG" IMAGE_NAME="$IMG_NAME" TAG="$FINAL_TAG" CONTAINER_NAME="$CNAME" COMPOSE_PROJECT_NAME="$CNAME" docker compose --env-file .env up -d --force-recreate --remove-orphans
docker image prune -f
# 记录当前运行版本:① 写回 .env 的 TAG 行(只改这一行,便于 cat .env 查看)② 维护 .deploy_tag 历史
@ -277,9 +433,23 @@ if grep -q '^TAG=' "$DIR/.env"; then
else
printf 'TAG=%s\n' "$FINAL_TAG" >> "$DIR/.env"
fi
# 容器名同样写回 .env,这样在服务器上手工 docker compose 时用的是同一个名字,不会退回默认 starpivot-app
if grep -q '^CONTAINER_NAME=' "$DIR/.env"; then
sed -i "s/^CONTAINER_NAME=.*/CONTAINER_NAME=$CNAME/" "$DIR/.env"
else
printf 'CONTAINER_NAME=%s\n' "$CNAME" >> "$DIR/.env"
fi
echo "$FINAL_TAG" > "$STATE"
[ -n "$FINAL_PREV" ] && echo "$FINAL_PREV" > "$PREVF" || true
echo "已部署版本 TAG=$FINAL_TAG (上一次=${FINAL_PREV:-无});已写回 .env 的 TAG 行"
echo "已部署版本 TAG=$FINAL_TAG 容器=$CNAME (上一次=${FINAL_PREV:-无});已写回 .env 的 TAG / CONTAINER_NAME 行"
# 兜底校验:容器名必须是按应用推导的那个。ym 首次部署就栽在这——服务器上那份手工 compose 写死了
# container_name: starpivot-app,脚本注入的 CONTAINER_NAME 根本没被引用,跑起来还是叫 starpivot-app。
if ! docker ps --format '{{.Names}}' | grep -qx "$CNAME"; then
echo "⚠ 容器名不是预期的 $CNAME —— 说明 $DIR/docker-compose.yml 没引用 CONTAINER_NAME 变量"
echo ' 正常应为: container_name: ${CONTAINER_NAME:-starpivot-app}'
docker compose ps 2>/dev/null || true
fi
REMOTE
green "✓ [$APP_KEY/$REGION] $MODE 完成"

4
deploy/console/dev-deploy.sh

@ -52,14 +52,14 @@ env_profile() {
ENV_DESC="云雁测试环境 · 广州阿里云"
registry_profile yunyan # 镜像仓库:云雁(registry.voitrans.net,就在这台机上)
DEV_HOST="8.138.86.223" # ← 云雁测试服务器 IP(2026-07 由腾讯云 124.222.232.218 迁来)
DEV_KEY=~/.ssh/aliyun_guangzhou.pem # 本机访问该服务器的 SSH 私钥
DEV_KEY="/Users/liwei1dao/work/密钥/dev-guangzhou.pem" # 本机访问该服务器的 SSH 私钥(2026-08-18 由 ~/.ssh/aliyun_guangzhou.pem 迁到此处)
DEV_DIR="/home/work/starpivot/console"
;;
along)
ENV_DESC="阿龙测试环境 · 阿里云"
registry_profile along # 镜像仓库:就在这台机上(registry.ymaikj.com,账号 admin)
DEV_HOST="8.133.166.29" # ← 阿龙测试服务器 IP
DEV_KEY=~/.ssh/along.pem # 原件在 ~/Documents/阿龙/loginscre.pem,因 macOS TCC 挡文稿目录已拷贝到 ~/.ssh
DEV_KEY="/Users/liwei1dao/liwei/密钥/along/releae-guangzhou.pem" # 本机访问该服务器的 SSH 私钥(2026-08-18 由 ~/.ssh/along.pem 迁到此处;文件名 releae 是原文件笔误)
DEV_DIR="/home/work/starpivot/console"
;;
*) red "✗ 未知环境: ${DEV_ENV}(应为 ${ENVS[*]// /|})"; exit 1 ;;

2
deploy/console/prod-deploy.sh

@ -60,7 +60,7 @@ env_profile() {
ENV_DESC="阿龙生产环境 · 阿里云"
registry_profile along || return 1 # 镜像仓库:registry.ymaikj.com(实体在阿龙测试机 8.133.166.29,账号 admin)
DEPLOY_HOST="47.116.104.181" # ← 阿龙生产服务器 IP
DEPLOY_KEY=~/.ssh/along.pem # 与测试机同一把钥匙;原件在 ~/Documents/阿龙/loginscre.pem,因 macOS TCC 挡文稿目录已拷贝到 ~/.ssh
DEPLOY_KEY="/Users/liwei1dao/liwei/密钥/along/releae-guangzhou.pem" # 与阿龙测试机同一把钥匙(2026-08-18 由 ~/.ssh/along.pem 迁到此处;文件名 releae 是原文件笔误)
DEPLOY_DIR="/home/work/starpivot/console"
;;
*) red "✗ 未知环境: ${DEPLOY_ENV}(应为 ${PROD_ENVS[*]// /|})"; exit 1 ;;

38
docs/migrations/2026-08-18-product-provider.sql

@ -0,0 +1,38 @@
-- 方案商归属从「品牌商」下移到「产品」。
-- 原因:方案商 = 这套产品用的芯片方案提供方,同一个品牌商的不同产品可能用不同方案商的方案,
-- 挂在品牌商上会串号——生产设备码的服务ID(CID)取的就是它。
-- brand.providerid 保留列但停止读写(后端已不再写入),待确认无回滚需求后再删列。
\set ON_ERROR_STOP on
BEGIN;
-- 1. 产品级方案商归属
ALTER TABLE product ADD COLUMN IF NOT EXISTS providerid integer DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_product_providerid ON product (providerid);
-- 2. 历史产品按原品牌商归属回填(品牌商没归属或方案商已不存在的,留 0 由后台补录)
UPDATE product p
SET providerid = b.providerid
FROM brand b
WHERE b.id = p.brandid
AND COALESCE(p.providerid, 0) = 0
AND COALESCE(b.providerid, 0) <> 0
AND EXISTS (SELECT 1 FROM solution_provider s WHERE s.id = b.providerid);
COMMIT;
-- 回填后核对:仍未绑定方案商的产品(后台产品管理里逐个补,补完才能正常生产设备码)
-- SELECT id, devicename, brandid FROM product WHERE COALESCE(providerid,0) = 0 ORDER BY id;
-- ── 同批:产品创建时间 + 产品说明 ──────────────────────────────────────────
-- createtime 只在新增时写一次(历史产品无从还原,留 0,后台显示为 "-");
-- description 是「这款产品是什么、有什么特性」,与版本更新说明 updatedescription 无关。
BEGIN;
ALTER TABLE product ADD COLUMN IF NOT EXISTS createtime bigint DEFAULT 0;
ALTER TABLE product ADD COLUMN IF NOT EXISTS description text DEFAULT '';
COMMIT;
-- ── 同批:品牌商创建时间 ───────────────────────────────────────────────────
-- 与产品一致,只在新增时写一次;历史品牌商无从还原,留 0,后台显示为 "-"。
BEGIN;
ALTER TABLE brand ADD COLUMN IF NOT EXISTS createtime bigint DEFAULT 0;
COMMIT;
Loading…
Cancel
Save