Browse Source

会员与算力:VIP 管准入、算力管计量;补上线落地文档

VIP 与算力两层权益各管一件事,判定与记账统一走 comm/compute.go:
- VIP 管功能准入(无 VIP 时 user_translate / echomeet_starttask 返回 VipRequired);
  自购记 user.vipexptime,设备赋予记在 device_mac 行上(激活日 + 天数),
  两者在 ResolveUserVip 合并成有效值下发,库里仍只记自购——以前绑定礼把天数
  累加进用户表,解绑时拆不开。
- 算力管用量计量,按后台系数折算,先扣设备赠送再扣用户余额。
  三个旧额度桶停用,存量余额由 home 启动时一次性折进 computebalance。
- 闸门 COMPUTE_GATE 默认关:user_usages 是用完才上报、没有预扣,
  现在开闸门形同虚设。

另含本次「阿龙测试服 → 正式服」上线落地文档(docs/),
以及 admin 侧商品/设备/产品/用户页的配套改动。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 4 weeks ago
parent
commit
37817f2e96
  1. 48
      apps/admin/app/pages/appgoods.vue
  2. 301
      apps/admin/app/pages/compute.vue
  3. 125
      apps/admin/app/pages/device.vue
  4. 101
      apps/admin/app/pages/product.vue
  5. 197
      apps/admin/app/pages/users.vue
  6. 1
      apps/admin/app/utils/menus.ts
  7. BIN
      apps/proto/.DS_Store
  8. 4
      apps/proto/api/api_msg.proto
  9. 48
      apps/proto/db.proto
  10. 8
      apps/proto/enum.proto
  11. 5
      apps/proto/errorcode.proto
  12. 52
      apps/proto/user/user_msg.proto
  13. 1
      apps/services/comm/appgoods.go
  14. 664
      apps/services/comm/compute.go
  15. 172
      apps/services/comm/compute_test.go
  16. 5
      apps/services/comm/svcpool.go
  17. BIN
      apps/services/lego/.DS_Store
  18. BIN
      apps/services/modules/.DS_Store
  19. 19
      apps/services/modules/api/api_dispatchresource.go
  20. 9
      apps/services/modules/api/model.go
  21. 427
      apps/services/modules/console/api_compute.go
  22. 4
      apps/services/modules/console/api_config.go
  23. 40
      apps/services/modules/console/api_device.go
  24. 2
      apps/services/modules/console/api_goods.go
  25. 43
      apps/services/modules/console/registry.go
  26. 50
      apps/services/modules/console/server.go
  27. 52
      apps/services/modules/echomeet/api_starttask.go
  28. 47
      apps/services/modules/pay/api.go
  29. 18
      apps/services/modules/user/api_bindauthcode.go
  30. 149
      apps/services/modules/user/api_binddevice.go
  31. 93
      apps/services/modules/user/api_getcompute.go
  32. 7
      apps/services/modules/user/api_getinfo.go
  33. 8
      apps/services/modules/user/api_sgin.go
  34. 7
      apps/services/modules/user/api_translate.go
  35. 148
      apps/services/modules/user/api_usages.go
  36. 2
      apps/services/modules/user/api_v2_getthirdsvcs.go
  37. 2
      apps/services/modules/user/api_v3_getappconfig.go
  38. 5
      apps/services/modules/user/model_config.go
  39. 51
      apps/services/modules/user/model_user.go
  40. 65
      apps/services/modules/user/module_test.go
  41. 49
      apps/services/modules/user/newuser_gift.go
  42. 59
      apps/services/modules/user/newuser_gift_test.go
  43. 24
      apps/services/modules/user/svcresolve.go
  44. 24
      apps/services/pb/api_msg.pb.go
  45. 202
      apps/services/pb/db.pb.go
  46. 20
      apps/services/pb/enum.pb.go
  47. 17
      apps/services/pb/errorcode.pb.go
  48. 810
      apps/services/pb/user_msg.pb.go
  49. BIN
      apps/services/services/.DS_Store
  50. 13
      deploy/app/dev-deploy.sh
  51. 642
      docs/2026-09-11-阿龙测试服上线正式服-落地文档.md
  52. 587
      docs/会员权益与用量统计-设计与开发文档.md

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

@ -5,7 +5,7 @@
<div class="flex items-center justify-between mb-5"> <div class="flex items-center justify-between mb-5">
<div> <div>
<h2 class="text-lg font-semibold text-slate-800">应用商品配置</h2> <h2 class="text-lg font-semibold text-slate-800">应用商品配置</h2>
<p class="text-xs text-slate-400 mt-1">应用的内购商品(VIP 时长、AI/会议/翻译资源点包等),支持按语言配置名称、货币与售价。统一存后台公共库,按应用隔离,同一应用的各区域部署共用一份。</p> <p class="text-xs text-slate-400 mt-1">应用的内购商品(VIP 时长、算力包),支持按语言配置名称、货币与售价。统一存后台公共库,按应用隔离,同一应用的各区域部署共用一份。</p>
</div> </div>
<button class="btn btn-primary btn-sm text-white" :disabled="!curAppName" @click="showForm()">+ 新增商品</button> <button class="btn btn-primary btn-sm text-white" :disabled="!curAppName" @click="showForm()">+ 新增商品</button>
</div> </div>
@ -115,25 +115,20 @@
<!-- 发放内容 --> <!-- 发放内容 -->
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6"> <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> <h3 class="text-sm font-semibold text-slate-600 mb-1">发放内容</h3>
<p class="text-xs text-slate-400 mb-4">购买成功后给用户加的资源,按需填写,不发的留 0。</p> <p class="text-xs text-slate-400 mb-4">购买成功后给用户加的资源,按需填写,不发的留 0。算力归账号、不过期;VIP 天数累加到自购到期时间。</p>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4"> <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">会员时长 (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"> <div class="form-control">
<label class="label"><span class="label-text font-medium">会议资源点 (meetnum)</span></label> <label class="label"><span class="label-text font-medium">会员时长(天)</span></label>
<input v-model.number="form.meetnum" type="number" class="input input-bordered w-full" /> <input v-model.number="form.viptime" type="number" min="0" class="input input-bordered w-full" />
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label"><span class="label-text font-medium">翻译资源点 (tradenum)</span></label> <label class="label"><span class="label-text font-medium">算力</span></label>
<input v-model.number="form.tradenum" type="number" class="input input-bordered w-full" /> <input v-model.number="form.compute" type="number" min="0" class="input input-bordered w-full" />
</div> </div>
</div> </div>
<p v-if="form.ainum || form.meetnum || form.tradenum" class="text-xs text-amber-600 mt-3">
这个商品还配着旧的资源点(AI {{ form.ainum }} / 会议 {{ form.meetnum }}s / 翻译 {{ form.tradenum }}s)。算力填 0 时发货会按当前换算系数把它们折成算力;填了算力则以算力为准。
</p>
</div> </div>
<!-- 多语言 --> <!-- 多语言 -->
@ -209,18 +204,22 @@ interface Goods {
meetnum: number meetnum: number
tradenum: number tradenum: number
viptime: number viptime: number
compute: number
sort: number sort: number
remark: string remark: string
locales: Record<string, GoodsLocale> locales: Record<string, GoodsLocale>
} }
// pb.UsageType:1翻译 2会议 3智能体 4购买VIP时长 // pb.UsageType:1翻译 2会议 3智能体 4购买VIP时长 5购买算力
// ⚠️ 1/2/3 是旧的资源点包类型,2026-09 起不再新建(三个额度桶已停用);
// 存量商品仍按这些类型分组展示,发货时旧资源点会按换算系数折成算力。
const USAGE_TYPES = [ const USAGE_TYPES = [
{ value: 0, label: '未指定' }, { value: 0, label: '未指定' },
{ value: 1, label: '翻译' }, { value: 1, label: '翻译(旧)' },
{ value: 2, label: '会议' }, { value: 2, label: '会议(旧)' },
{ value: 3, label: '智能体' }, { value: 3, label: '智能体(旧)' },
{ value: 4, label: 'VIP 时长' }, { value: 4, label: 'VIP 时长' },
{ value: 5, label: '算力包' },
] ]
const { consoleApi } = useApi() const { consoleApi } = useApi()
@ -248,6 +247,7 @@ const form = reactive({
meetnum: 0, meetnum: 0,
tradenum: 0, tradenum: 0,
viptime: 0, viptime: 0,
compute: 0,
sort: 0, sort: 0,
remark: '', remark: '',
locales: [] as Array<{ lang: string } & GoodsLocale>, locales: [] as Array<{ lang: string } & GoodsLocale>,
@ -268,9 +268,13 @@ function minutesText(sec: number) {
function rewardText(g: Goods) { function rewardText(g: Goods) {
const parts: string[] = [] const parts: string[] = []
if (g.viptime) parts.push(`VIP ${g.viptime} 天`) if (g.viptime) parts.push(`VIP ${g.viptime} 天`)
if (g.ainum) parts.push(`AI ${g.ainum} 点`) if (g.compute) parts.push(`算力 ${g.compute}`)
if (g.meetnum) parts.push(`会议 ${minutesText(g.meetnum)}`) else {
if (g.tradenum) parts.push(`翻译 ${minutesText(g.tradenum)}`) // 老商品:没配算力、还留着旧资源点,发货时会折算;列表上标出来免得以为它什么都不发
if (g.ainum) parts.push(`AI ${g.ainum} 点(旧)`)
if (g.meetnum) parts.push(`会议 ${minutesText(g.meetnum)}(旧)`)
if (g.tradenum) parts.push(`翻译 ${minutesText(g.tradenum)}(旧)`)
}
return parts.join(' · ') return parts.join(' · ')
} }

301
apps/admin/app/pages/compute.vue

@ -0,0 +1,301 @@
<template>
<div>
<div class="flex items-center gap-2 mb-1">
<h2 class="text-lg font-semibold text-slate-800">会员与算力</h2>
</div>
<p class="text-xs text-slate-400 mb-4">
VIP 管功能准入(无 VIP 时翻译与会议纪要不可用);算力管用量计量(翻译 / 会议 / AI 对话统一折算,先扣设备赠送、再扣用户余额)。
设备赠送的 VIP 天数与算力在「MAC 生成管理」按批次配置;用户余额在「用户查询」里调整。
</p>
<!-- 应用选择 -->
<div class="flex items-center gap-2 mb-5">
<span class="text-xs text-slate-500">应用</span>
<select v-model="curAppId" class="select select-bordered select-sm min-w-56" @change="onAppChange">
<option v-for="a in apps" :key="a.id" :value="a.id">{{ a.app_name || a.name }} · {{ regionLabel(a.region) }}</option>
</select>
<span v-if="!apps.length" class="text-xs text-slate-400">没有可选应用</span>
</div>
<div v-if="curAppId" class="grid grid-cols-1 xl:grid-cols-3 gap-5">
<!-- ===== 换算系数与闸门 ===== -->
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-6">
<h3 class="text-sm font-semibold text-slate-700 mb-1">换算系数</h3>
<!-- 读取失败必须显式说明:默认值 1/1/1 与真实配置在界面上长得一模一样,
不标出来运营会以为读到了配置,一保存就把默认值写进库里 -->
<div v-if="cfgError" class="alert alert-error text-xs py-2 mb-3">
<span>读取失败,下面显示的是默认值、<b>不是当前配置</b>:{{ cfgError }}</span>
</div>
<p class="text-xs text-slate-400 mb-4">
改动只影响之后的消耗,历史流水不重算。存本应用业务库 config 表,随应用配置下发客户端(仅用于本地预估显示,上报的永远是原始量)。
</p>
<div class="space-y-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">翻译 · 算力 / 分钟</span></label>
<input v-model.number="cfg.translate" type="number" min="1" class="input input-bordered input-sm w-full" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">会议纪要 · 算力 / 分钟</span></label>
<input v-model.number="cfg.meeting" type="number" min="1" class="input input-bordered input-sm w-full" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">AI 对话 · 算力 / 100 字</span><span class="label-text-alt text-slate-400">输入 + 输出字符数</span></label>
<input v-model.number="cfg.aichat" type="number" min="1" class="input input-bordered input-sm w-full" />
</div>
<div class="divider my-2"></div>
<label class="flex items-start gap-3 cursor-pointer">
<input v-model="cfg.gate" type="checkbox" class="toggle toggle-error toggle-sm mt-0.5" />
<div>
<div class="text-sm font-medium text-slate-700">算力闸门</div>
<div class="text-xs text-slate-400 mt-0.5">
关(默认):余额不够也放行,差额记为「超额」。开:余额不足即拒绝。
</div>
<div v-if="cfg.gate" class="text-xs text-red-600 mt-1 font-medium">
⚠️ 开启前需确认预扣逻辑已上线:现在是用完才上报,余额只剩 1 的用户仍可先用满再报,没有预扣的闸门形同虚设。
</div>
</div>
</label>
</div>
<div class="divider my-3"></div>
<h3 class="text-sm font-semibold text-slate-700 mb-1">运营参数</h3>
<p class="text-xs text-slate-400 mb-3">四项都可以填 0:开户礼填 0 = 注册不送,提示阈值填 0 = 不提示。</p>
<div class="space-y-3">
<div class="grid grid-cols-2 gap-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">开户礼 · VIP 天数</span></label>
<input v-model.number="cfg.newuser_gift_vipdays" type="number" min="0" max="3650" class="input input-bordered input-sm w-full" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">开户礼 · 算力</span></label>
<input v-model.number="cfg.newuser_gift_compute" type="number" min="0" class="input input-bordered input-sm w-full" />
</div>
</div>
<p class="text-[11px] text-slate-400 -mt-1">注册即到账,只影响改动之后注册的新用户。</p>
<div class="grid grid-cols-2 gap-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">提示 · VIP 到期前(天)</span></label>
<input v-model.number="cfg.vip_warn_days" type="number" min="0" max="365" class="input input-bordered input-sm w-full" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">提示 · 算力低于</span></label>
<input v-model.number="cfg.compute_warn" type="number" min="0" class="input input-bordered input-sm w-full" />
</div>
</div>
<p class="text-[11px] text-slate-400 -mt-1">随会员状态接口下发给客户端,由客户端决定怎么展示。</p>
</div>
<div class="flex items-center gap-2 mt-5">
<button class="btn btn-primary btn-sm text-white" :disabled="saving || !cfgDirty || !!cfgError" @click="saveCfg">
<span v-if="saving" class="loading loading-spinner loading-xs"></span>保存
</button>
<button class="btn btn-ghost btn-sm" :disabled="!cfgDirty" @click="Object.assign(cfg, cfgBase)">撤销</button>
</div>
</div>
<!-- ===== 看板 ===== -->
<div class="xl:col-span-2 space-y-5">
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-slate-700">算力消耗</h3>
<div class="flex items-center gap-2">
<select v-model.number="rangeDays" class="select select-bordered select-xs" @change="loadStats">
<option :value="7">近 7 天</option>
<option :value="30">近 30 天</option>
<option :value="90">近 90 天</option>
</select>
<button class="btn btn-ghost btn-xs" :disabled="statsLoading" @click="loadStats">刷新</button>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-5">
<div class="stat-tile"><div class="k">区间消耗</div><div class="v">{{ fmt(totalCompute) }}</div><div class="s">算力</div></div>
<div class="stat-tile"><div class="k">翻译</div><div class="v">{{ fmt(stats?.kinds?.translate?.compute) }}</div><div class="s">{{ minutes(stats?.kinds?.translate?.raw) }} · {{ stats?.kinds?.translate?.times || 0 }} 次</div></div>
<div class="stat-tile"><div class="k">会议纪要</div><div class="v">{{ fmt(stats?.kinds?.meeting?.compute) }}</div><div class="s">{{ minutes(stats?.kinds?.meeting?.raw) }} · {{ stats?.kinds?.meeting?.times || 0 }} 次</div></div>
<div class="stat-tile"><div class="k">AI 对话</div><div class="v">{{ fmt(stats?.kinds?.aichat?.compute) }}</div><div class="s">{{ stats?.kinds?.aichat?.times || 0 }} 次</div></div>
</div>
<!-- 按日条形 -->
<div v-if="statsError" class="alert alert-error text-xs py-2">
<span>看板读取失败,上面的 0 不代表没有消耗:{{ statsError }}</span>
</div>
<div v-else-if="stats?.days?.length" class="overflow-x-auto">
<div class="flex items-end gap-1 h-32 min-w-[520px]">
<div v-for="d in stats.days" :key="d.day" class="flex-1 flex flex-col items-center justify-end gap-1" :title="`${d.day}:${d.compute} 算力 · ${d.users} 人`">
<div class="w-full bg-indigo-400/80 rounded-t" :style="{ height: barH(d.compute) + '%' }"></div>
<div class="text-[9px] text-slate-400 tabular-nums">{{ d.day.slice(5) }}</div>
</div>
</div>
</div>
<div v-else class="text-xs text-slate-400">区间内没有消耗记录</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-6">
<h3 class="text-sm font-semibold text-slate-700 mb-1">用户消耗 Top {{ stats?.top?.length || 0 }}</h3>
<p class="text-xs text-slate-400 mb-3">额度闸门取消后单用户成本不封顶,这里是异常用量的发现入口。</p>
<table class="table table-xs">
<thead><tr><th>#</th><th>UID</th><th class="text-right">算力</th></tr></thead>
<tbody>
<tr v-for="(t, i) in stats?.top || []" :key="t.uid">
<td class="text-slate-400">{{ i + 1 }}</td>
<td class="font-mono text-xs">{{ t.uid }}</td>
<td class="text-right tabular-nums">{{ fmt(t.compute) }}</td>
</tr>
<tr v-if="!stats?.top?.length"><td colspan="3" class="text-slate-400 text-center">无</td></tr>
</tbody>
</table>
</div>
<div class="bg-white rounded-2xl border border-slate-100 shadow-sm p-6">
<h3 class="text-sm font-semibold text-slate-700 mb-1">设备赠送算力</h3>
<p class="text-xs text-slate-400 mb-3">已激活且带算力的设备(跨应用汇总)。</p>
<div class="grid grid-cols-3 gap-3">
<div class="stat-tile"><div class="k">已发放</div><div class="v">{{ fmt(stats?.device?.grant) }}</div><div class="s">{{ stats?.device?.devices || 0 }} 台</div></div>
<div class="stat-tile"><div class="k">已消耗</div><div class="v">{{ fmt(stats?.device?.used) }}</div></div>
<div class="stat-tile"><div class="k">剩余</div><div class="v">{{ fmt(stats?.device?.left) }}</div></div>
</div>
</div>
</div>
</div>
</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 { useAuthStore } from '~/stores/auth'
import { regionLabel } from '~/utils/regions'
definePageMeta({ title: '会员与算力' })
const { webApi, consoleApi } = useApi()
const { success, error } = useToast()
const auth = useAuthStore()
interface AppItem { id: number; name?: string; app_name?: string; region?: number }
const apps = ref<AppItem[]>([])
const curAppId = ref<number | ''>('')
interface Cfg {
translate: number; meeting: number; aichat: number; gate: boolean
newuser_gift_vipdays: number; newuser_gift_compute: number
vip_warn_days: number; compute_warn: number
}
const CFG_DEFAULT: Cfg = {
translate: 1, meeting: 1, aichat: 1, gate: false,
newuser_gift_vipdays: 30, newuser_gift_compute: 100,
vip_warn_days: 7, compute_warn: 100,
}
const cfg = reactive<Cfg>({ ...CFG_DEFAULT })
const cfgBase = reactive<Cfg>({ ...CFG_DEFAULT })
const cfgDirty = computed(() => (Object.keys(cfgBase) as (keyof Cfg)[]).some((k) => cfg[k] !== cfgBase[k]))
const saving = ref(false)
const cfgError = ref('')
interface StatsKind { compute: number; raw: number; times: number }
interface Stats {
days: { day: string; compute: number; users: number }[]
kinds: { translate: StatsKind; meeting: StatsKind; aichat: StatsKind }
top: { uid: string; compute: number }[]
device: { grant: number; used: number; left: number; devices: number }
}
const stats = ref<Stats | null>(null)
const statsLoading = ref(false)
const statsError = ref('')
const rangeDays = ref(30)
const totalCompute = computed(() => (stats.value?.days || []).reduce((s, d) => s + Number(d.compute || 0), 0))
const maxDay = computed(() => Math.max(1, ...(stats.value?.days || []).map((d) => Number(d.compute || 0))))
function barH(v: number) { return Math.max(2, Math.round((Number(v || 0) / maxDay.value) * 100)) }
function fmt(v: any) { return Number(v || 0).toLocaleString() }
function minutes(sec: any) { return `${Math.round(Number(sec || 0) / 60)} 分钟` }
async function loadApps() {
// 与用户查询页同源:超管/管理员走 apps/list,其余角色走 getmyapps(403 时回退)。
try {
apps.value = (await consoleApi<AppItem[]>('apps/list', {})) || []
} catch {
try {
const d = await webApi<{ apps: AppItem[] }>('getmyapps', {})
apps.value = d?.apps ?? []
} catch (e: any) {
error(e?.message || '获取应用列表失败')
}
}
if (!curAppId.value) {
const preferred = apps.value.find((a) => a.id === auth.currentAppId) || apps.value[0]
if (preferred) curAppId.value = preferred.id
}
if (curAppId.value) await onAppChange()
}
async function onAppChange() {
if (!curAppId.value) return
auth.setCurrentApp(Number(curAppId.value))
await Promise.all([loadCfg(), loadStats()])
}
async function loadCfg() {
try {
const d = await webApi<Cfg>('getcomputeconfig', {})
// ⚠️ 运营参数用 ?? 不用 ||:0 是合法配置(不送 / 不提示),用 || 会把它当成「没配」而显示成默认值
const snap: Cfg = {
translate: Number(d?.translate || 1),
meeting: Number(d?.meeting || 1),
aichat: Number(d?.aichat || 1),
gate: !!d?.gate,
newuser_gift_vipdays: Number(d?.newuser_gift_vipdays ?? CFG_DEFAULT.newuser_gift_vipdays),
newuser_gift_compute: Number(d?.newuser_gift_compute ?? CFG_DEFAULT.newuser_gift_compute),
vip_warn_days: Number(d?.vip_warn_days ?? CFG_DEFAULT.vip_warn_days),
compute_warn: Number(d?.compute_warn ?? CFG_DEFAULT.compute_warn),
}
Object.assign(cfg, snap)
Object.assign(cfgBase, snap)
cfgError.value = ''
} catch (e: any) {
cfgError.value = e?.message || '读取算力配置失败'
error(cfgError.value)
}
}
async function saveCfg() {
if (cfg.gate && !cfgBase.gate) {
if (!confirm('确定开启算力闸门?开启后余额不足的用户会被直接拒绝。请先确认预扣逻辑已上线。')) return
}
saving.value = true
try {
await webApi('savecomputeconfig', { ...cfg })
Object.assign(cfgBase, cfg)
success('已保存,业务服务 30 秒内生效')
} catch (e: any) {
error(e?.message || '保存失败')
} finally {
saving.value = false
}
}
async function loadStats() {
statsLoading.value = true
try {
const to = Math.floor(Date.now() / 1000)
const from = to - rangeDays.value * 86400
stats.value = await webApi<Stats>('getcomputestats', { from, to, top: 20 })
statsError.value = ''
} catch (e: any) {
stats.value = null
statsError.value = e?.message || '读取看板失败'
error(statsError.value)
} finally {
statsLoading.value = false
}
}
onMounted(loadApps)
</script>
<style scoped>
.stat-tile { @apply rounded-xl bg-slate-50 border border-slate-100 px-4 py-3; }
.stat-tile .k { @apply text-[11px] text-slate-400; }
.stat-tile .v { @apply text-xl font-semibold text-slate-800 tabular-nums mt-0.5; }
.stat-tile .s { @apply text-[11px] text-slate-400 mt-0.5; }
</style>

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

@ -16,7 +16,10 @@
<option v-for="ch in filterChannelOptions" :key="ch.id" :value="ch.id">{{ ch.name }} ({{ ch.id }})</option> <option v-for="ch in filterChannelOptions" :key="ch.id" :value="ch.id">{{ ch.name }} ({{ ch.id }})</option>
</select> </select>
<select v-model.number="filterProduct" class="select select-bordered select-sm" style="min-width:180px;" @change="onProductChange"> <select v-model.number="filterProduct" class="select select-bordered select-sm" style="min-width:180px;" @change="onProductChange">
<option :value="0">请选择产品</option> <!-- 产品受限的账号(运营账号绑了 productids)不给「全部产品」这一项:
后端 getauthcodes 只认单个 productid,0 = 不限产品,会把绑定之外的产品也查出来。 -->
<option v-if="!productScoped" :value="0">全部产品</option>
<option v-else :value="0">请选择产品</option>
<option v-for="p in filterProductOptions" :key="p.id" :value="p.id">{{ p.devicename || '' }}</option> <option v-for="p in filterProductOptions" :key="p.id" :value="p.id">{{ p.devicename || '' }}</option>
</select> </select>
</div> </div>
@ -25,7 +28,7 @@
<!-- ========== Tab 1: MAC查询 ========== --> <!-- ========== Tab 1: MAC查询 ========== -->
<div v-show="tab === 'codes'" class="bg-white rounded-xl shadow-sm border border-slate-100"> <div v-show="tab === 'codes'" class="bg-white rounded-xl shadow-sm border border-slate-100">
<div class="flex items-center justify-between px-5 py-3 border-b border-slate-100 flex-wrap gap-2"> <div class="flex items-center justify-between px-5 py-3 border-b border-slate-100 flex-wrap gap-2">
<h3 class="text-sm font-semibold text-slate-700">MAC记录<span class="text-xs text-slate-400 font-normal ml-2">默认按生产时间倒序</span></h3> <h3 class="text-sm font-semibold text-slate-700">MAC记录<span class="text-xs text-slate-400 font-normal ml-2">一条 = 一台设备,默认按生产时间倒序</span></h3>
<div class="flex items-end gap-2 flex-wrap"> <div class="flex items-end gap-2 flex-wrap">
<div class="form-control"> <div class="form-control">
<label class="label py-0"><span class="label-text text-xs">状态</span></label> <label class="label py-0"><span class="label-text text-xs">状态</span></label>
@ -82,22 +85,23 @@
<thead> <thead>
<tr> <tr>
<th class="w-8"><input v-model="selectAll" type="checkbox" class="checkbox checkbox-xs" @change="toggleSelectAll" /></th> <th class="w-8"><input v-model="selectAll" type="checkbox" class="checkbox checkbox-xs" @change="toggleSelectAll" /></th>
<th>MAC地址</th><th>批次</th><th>用户UID</th><th>激活时间</th><th>状态</th><th>操作</th> <th>MAC地址</th><th>产品</th><th>批次</th><th>用户UID</th><th>激活时间</th><th>状态</th><th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-if="!filterProduct"> <tr v-if="!canQueryCodes">
<td colspan="7" class="text-center text-slate-400 py-8">请先选择产品</td> <td colspan="8" class="text-center text-slate-400 py-8">请先选择产品</td>
</tr> </tr>
<tr v-else-if="devicesError"> <tr v-else-if="devicesError">
<td colspan="7" class="text-center text-red-400 py-8">查询失败: {{ devicesError }}</td> <td colspan="8" class="text-center text-red-400 py-8">查询失败: {{ devicesError }}</td>
</tr> </tr>
<tr v-else-if="!devices.length"> <tr v-else-if="!devices.length">
<td colspan="7" class="text-center text-slate-400 py-8">暂无数据</td> <td colspan="8" class="text-center text-slate-400 py-8">暂无数据</td>
</tr> </tr>
<tr v-for="(d, i) in devices" v-else :key="i"> <tr v-for="(d, i) in devices" v-else :key="i">
<td><input v-model="selectedCodes" type="checkbox" class="checkbox checkbox-xs" :value="d.code || ''" /></td> <td><input v-model="selectedCodes" type="checkbox" class="checkbox checkbox-xs" :value="d.code || ''" /></td>
<td class="font-mono text-xs font-medium">{{ d.devicemac || '-' }}</td> <td class="font-mono text-xs font-medium">{{ d.devicemac || '-' }}</td>
<td class="text-xs">{{ nameOfProduct(d.productid || 0) }}</td>
<td class="font-mono text-xs">{{ d.batchno || '' }}</td> <td class="font-mono text-xs">{{ d.batchno || '' }}</td>
<td class="text-xs">{{ d.uid || '-' }}</td> <td class="text-xs">{{ d.uid || '-' }}</td>
<td class="text-xs text-slate-500">{{ d.usedtime ? fmtTime(d.usedtime) : '-' }}</td> <td class="text-xs text-slate-500">{{ d.usedtime ? fmtTime(d.usedtime) : '-' }}</td>
@ -110,7 +114,7 @@
<div class="flex gap-1"> <div class="flex gap-1">
<button v-if="d.disabled !== 0" class="btn btn-ghost btn-xs text-warning" @click="batchSetDisabled(0, [d.code])">恢复</button> <button v-if="d.disabled !== 0" class="btn btn-ghost btn-xs text-warning" @click="batchSetDisabled(0, [d.code])">恢复</button>
<button v-else class="btn btn-ghost btn-xs text-error" @click="batchSetDisabled(-1, [d.code])">禁用</button> <button v-else class="btn btn-ghost btn-xs text-error" @click="batchSetDisabled(-1, [d.code])">禁用</button>
<button v-if="d.status !== 0" class="btn btn-ghost btn-xs text-orange-500" @click="resetDevice(d.code)">重置</button> <button v-if="d.status !== 0" class="btn btn-ghost btn-xs text-orange-500" @click="resetDevice(d)">重置</button>
</div> </div>
</td> </td>
</tr> </tr>
@ -118,11 +122,11 @@
</table> </table>
</div> </div>
<div class="flex items-center justify-between px-5 py-3 border-t border-slate-100"> <div class="flex items-center justify-between px-5 py-3 border-t border-slate-100">
<span class="text-xs text-slate-500">{{ filterProduct ? '共 ' + devTotal + ' 条' : '-' }}</span> <span class="text-xs text-slate-500">{{ canQueryCodes ? '共 ' + devTotal + ' 条' : '-' }}</span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button class="btn btn-ghost btn-xs" :disabled="devPage <= 0 || !filterProduct" @click="changePage(-1)">上一页</button> <button class="btn btn-ghost btn-xs" :disabled="devPage <= 0 || !canQueryCodes" @click="changePage(-1)">上一页</button>
<span class="text-xs text-slate-500">{{ pgInfoText }}</span> <span class="text-xs text-slate-500">{{ pgInfoText }}</span>
<button class="btn btn-ghost btn-xs" :disabled="!filterProduct || (devPage + 1) >= totalPages" @click="changePage(1)">下一页</button> <button class="btn btn-ghost btn-xs" :disabled="!canQueryCodes || (devPage + 1) >= totalPages" @click="changePage(1)">下一页</button>
</div> </div>
</div> </div>
</div> </div>
@ -280,20 +284,26 @@
<!-- 绑定赠送按批次配:同款产品不同批次(不同渠道/不同活动)送的东西经常不一样 --> <!-- 绑定赠送按批次配:同款产品不同批次(不同渠道/不同活动)送的东西经常不一样 -->
<div class="rounded-lg border border-slate-200 px-4 py-3 mb-4"> <div class="rounded-lg border border-slate-200 px-4 py-3 mb-4">
<div class="text-xs text-slate-500 mb-2"> <div class="text-xs text-slate-500 mb-2">
绑定赠送(这一批设备被用户绑定时发放,<b>生产时冻结</b>;三项都填 0 则回退到产品档案上的旧配置) 设备权益(<b>生产时冻结</b>;设备首次被绑定=激活时快照到设备行,之后改批次不影响已激活设备。VIP 天数与算力都填 0 则回退到产品档案上的旧配置)
</div> </div>
<div class="grid grid-cols-3 gap-4"> <div class="grid grid-cols-3 gap-4">
<div class="form-control"> <div class="form-control">
<label class="label py-1"><span class="label-text text-xs">赠送VIP(天)</span></label> <label class="label py-1"><span class="label-text text-xs">赠送VIP(天)</span></label>
<input v-model.number="cRewardVip" type="number" min="0" max="3650" class="input input-bordered input-sm w-full" placeholder="0" /> <input v-model.number="cRewardVip" type="number" min="0" max="3650" class="input input-bordered input-sm w-full" placeholder="0" />
<span class="text-[11px] text-slate-400 mt-1">激活日起连续计,解绑不暂停</span>
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label py-1"><span class="label-text text-xs">赠送翻译时长(分钟)</span></label> <label class="label py-1"><span class="label-text text-xs">VIP 等级</span></label>
<input v-model.number="cRewardTranslate" type="number" min="0" max="525600" class="input input-bordered input-sm w-full" placeholder="0" /> <select v-model.number="cVipLevel" class="select select-bordered select-sm w-full">
<option :value="1">VIP</option>
<option :value="2">VIP-Pro(预留)</option>
</select>
<span class="text-[11px] text-slate-400 mt-1">仅在赠送天数 &gt; 0 时有意义</span>
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label py-1"><span class="label-text text-xs">赠送会议时长(分钟)</span></label> <label class="label py-1"><span class="label-text text-xs">赠送算力</span></label>
<input v-model.number="cRewardMeeting" type="number" min="0" max="525600" class="input input-bordered input-sm w-full" placeholder="0" /> <input v-model.number="cComputeGrant" type="number" min="0" max="10000000" class="input input-bordered input-sm w-full" placeholder="0" />
<span class="text-[11px] text-slate-400 mt-1">归设备、随设备流转,用完为止</span>
</div> </div>
</div> </div>
</div> </div>
@ -324,9 +334,8 @@
<!-- 按产品类型说明最终产量 --> <!-- 按产品类型说明最终产量 -->
<div v-if="cProduct" class="rounded-lg bg-indigo-50 border border-indigo-100 px-4 py-3 text-sm text-slate-700 mt-3 mb-3"> <div v-if="cProduct" class="rounded-lg bg-indigo-50 border border-indigo-100 px-4 py-3 text-sm text-slate-700 mt-3 mb-3">
<div class="flex flex-wrap gap-x-6 gap-y-1"> <div class="flex flex-wrap gap-x-6 gap-y-1">
<span>产品类型:<b>{{ createProductInfo.isClassicBT ? '经典蓝牙耳机' : '通用产品' }}</b></span>
<span>每副份数:<b>{{ createProductInfo.codeCount }}</b></span> <span>每副份数:<b>{{ createProductInfo.codeCount }}</b></span>
<span v-if="createMode === 'production'">最终生成:<b class="text-indigo-600">{{ createFinalTotal }}</b> 个{{ createProductInfo.isClassicBT ? 'MAC' : '码' }} <span v-if="createMode === 'production'">最终生成:<b class="text-indigo-600">{{ createFinalTotal }}</b> 个 MAC
<template v-if="createProductInfo.codeCount > 1">({{ cNumber || 0 }} 副 × {{ createProductInfo.codeCount }} 份)</template> <template v-if="createProductInfo.codeCount > 1">({{ cNumber || 0 }} 副 × {{ createProductInfo.codeCount }} 份)</template>
</span> </span>
<span v-else>最终生成:<b class="text-indigo-600">{{ importMacCount }}</b> 条(按导入的 MAC 条数)</span> <span v-else>最终生成:<b class="text-indigo-600">{{ importMacCount }}</b> 条(按导入的 MAC 条数)</span>
@ -482,7 +491,6 @@ interface Product {
brandid: number brandid: number
providerid?: number // 归属方案公司 = BID;CID 再由该方案商的 vendor 推出 providerid?: number // 归属方案公司 = BID;CID 再由该方案商的 vendor 推出
oemfactoryid?: number // 默认代工厂(oem_factory.id),生产表单只读带出、批次归属按它落库 oemfactoryid?: number // 默认代工厂(oem_factory.id),生产表单只读带出、批次归属按它落库
devicetype?: number
devicecodecount?: number devicecodecount?: number
} }
interface Channel { interface Channel {
@ -601,7 +609,11 @@ const devPageSize = ref(200)
// ── 共享筛选 ── // ── 共享筛选 ──
const filterChannel = ref('') const filterChannel = ref('')
// 0 = 全部产品(默认)。产品受限的账号除外,见 productScoped。
const filterProduct = ref(0) const filterProduct = ref(0)
// 账号被 productids 绑死了可见产品(运营账号)。这类账号不给「全部产品」这一项——
// 后端 getauthcodes 的 productid=0 是「不限产品」,传 0 等于绕开这层收敛。
const productScoped = ref(false)
const filterStart = ref('') const filterStart = ref('')
const filterEnd = ref('') const filterEnd = ref('')
@ -623,8 +635,8 @@ const cSharepct = ref(0)
// 绑定赠送三项,按批次配:同款产品不同批次(不同渠道/不同活动)送的东西经常不一样。 // 绑定赠送三项,按批次配:同款产品不同批次(不同渠道/不同活动)送的东西经常不一样。
// 三项都是 0 时服务端会回退到产品档案上的旧配置,所以「全 0」= 不覆盖,而不是「不送」。 // 三项都是 0 时服务端会回退到产品档案上的旧配置,所以「全 0」= 不覆盖,而不是「不送」。
const cRewardVip = ref(0) const cRewardVip = ref(0)
const cRewardTranslate = ref(0) const cVipLevel = ref(1)
const cRewardMeeting = ref(0) const cComputeGrant = ref(0)
const codeMac = ref('') const codeMac = ref('')
const codeUid = ref('') const codeUid = ref('')
const codePageSizeSel = ref('200') const codePageSizeSel = ref('200')
@ -668,7 +680,9 @@ const revokeConfirmTarget = computed(() => revokeCtx.value ? revokeCtx.value.bat
const revokeConfirmOk = computed(() => !!revokeCtx.value && revokeConfirmInput.value.trim() === revokeConfirmTarget.value) const revokeConfirmOk = computed(() => !!revokeCtx.value && revokeConfirmInput.value.trim() === revokeConfirmTarget.value)
const totalPages = computed(() => Math.max(1, Math.ceil(devTotal.value / devPageSize.value))) const totalPages = computed(() => Math.max(1, Math.ceil(devTotal.value / devPageSize.value)))
const pgInfoText = computed(() => filterProduct.value ? '第 ' + (devPage.value + 1) + ' / ' + totalPages.value + ' 页' : '-') // 能不能发查询:不受限的账号任何时候都能(0=全部产品),受限账号必须先选一个产品。
const canQueryCodes = computed(() => !productScoped.value || !!filterProduct.value)
const pgInfoText = computed(() => canQueryCodes.value ? '第 ' + (devPage.value + 1) + ' / ' + totalPages.value + ' 页' : '-')
// 顶部产品/渠道商下拉:都列全部。原来按所选品牌商收窄,品牌商筛选去掉后这层过滤没了意义—— // 顶部产品/渠道商下拉:都列全部。原来按所选品牌商收窄,品牌商筛选去掉后这层过滤没了意义——
// 而且产品与渠道商现在的 brandid 基本都是 0,按它筛只会筛出空列表。 // 而且产品与渠道商现在的 brandid 基本都是 0,按它筛只会筛出空列表。
@ -698,9 +712,8 @@ const vendorLabel = computed(() => {
// 核对页的一行摘要:三项都为 0 时说清楚会回退到产品配置,别让人以为是「不送」 // 核对页的一行摘要:三项都为 0 时说清楚会回退到产品配置,别让人以为是「不送」
const rewardSummary = computed(() => { const rewardSummary = computed(() => {
const parts: string[] = [] const parts: string[] = []
if (Number(cRewardVip.value) > 0) parts.push(`VIP ${cRewardVip.value} 天`) if (Number(cRewardVip.value) > 0) parts.push(`${Number(cVipLevel.value) === 2 ? 'VIP-Pro' : 'VIP'} ${cRewardVip.value} 天`)
if (Number(cRewardTranslate.value) > 0) parts.push(`翻译 ${cRewardTranslate.value} 分钟`) if (Number(cComputeGrant.value) > 0) parts.push(`算力 ${cComputeGrant.value}`)
if (Number(cRewardMeeting.value) > 0) parts.push(`会议 ${cRewardMeeting.value} 分钟`)
return parts.length ? parts.join(' + ') : '未配置(按产品档案上的旧配置发放)' return parts.length ? parts.join(' + ') : '未配置(按产品档案上的旧配置发放)'
}) })
@ -712,11 +725,14 @@ const createChannelOptions = computed(() => channels.value.filter((ch) => ch.sta
// 产品已经不带品牌商了(品牌归属改成生产时按批次定,见产品管理表单), // 产品已经不带品牌商了(品牌归属改成生产时按批次定,见产品管理表单),
// 再按 brandid 筛会因为产品的 brandid=0 而永远筛出空列表、一个产品都选不到。 // 再按 brandid 筛会因为产品的 brandid=0 而永远筛出空列表、一个产品都选不到。
const createProductOptions = computed(() => products.value) const createProductOptions = computed(() => products.value)
// 生产表单所选产品的类型说明(经典蓝牙=1 → 设备码;每副份数 devicecodecount) // 生产表单所选产品的产量说明:每副产品生产几份设备码(devicecodecount,0/1=单份,2=双份)。
// ⚠️ 这里原先还按 devicetype==1 分「经典蓝牙耳机 / 通用产品」,文案上区分「个 MAC / 个码」——
// 那个区分从来就不成立:生产模式下所有产品一视同仁地生成 MAC,code 就是 MAC
// (见 api_createauthcodes.go)。devicetype 已于 2026-09-08 废弃,这里一并去掉。
const createProductInfo = computed(() => { const createProductInfo = computed(() => {
const p = allProducts.value.find((x) => x.id === cProduct.value) const p = allProducts.value.find((x) => x.id === cProduct.value)
const codeCount = p?.devicecodecount && p.devicecodecount > 0 ? p.devicecodecount : 1 const codeCount = p?.devicecodecount && p.devicecodecount > 0 ? p.devicecodecount : 1
return { isClassicBT: p?.devicetype === 1, codeCount } return { codeCount }
}) })
const createFinalTotal = computed(() => (Number(cNumber.value) || 0) * createProductInfo.value.codeCount) const createFinalTotal = computed(() => (Number(cNumber.value) || 0) * createProductInfo.value.codeCount)
const importMacCount = computed(() => cMacaddresses.value.split(',').map((s) => s.trim()).filter(Boolean).length) const importMacCount = computed(() => cMacaddresses.value.split(',').map((s) => s.trim()).filter(Boolean).length)
@ -832,13 +848,18 @@ async function init() {
const allowProducts = auth.products.map((p) => parseInt(p)).filter((n) => !isNaN(n) && n > 0) const allowProducts = auth.products.map((p) => parseInt(p)).filter((n) => !isNaN(n) && n > 0)
if (auth.identity !== 1 && allowProducts.length > 0) { if (auth.identity !== 1 && allowProducts.length > 0) {
products.value = allProducts.value.filter((p) => allowProducts.indexOf(p.id) >= 0) products.value = allProducts.value.filter((p) => allowProducts.indexOf(p.id) >= 0)
productScoped.value = true
} else { } else {
products.value = allProducts.value.slice() products.value = allProducts.value.slice()
productScoped.value = false
} }
// 默认选第一个可见产品 → 自动加载该产品下的所有数据 // 默认「全部产品」(MAC查询看全部设备、生产记录看全部批次)。
if (products.value.length > 0) { // 产品受限的账号没有这一项,退回第一个可见产品。
if (productScoped.value && products.value.length > 0) {
filterProduct.value = products.value[0].id filterProduct.value = products.value[0].id
} else {
filterProduct.value = 0
} }
reloadAll() reloadAll()
} }
@ -967,7 +988,8 @@ async function loadDevices(resetPage: boolean) {
if (resetPage) devPage.value = 0 if (resetPage) devPage.value = 0
selectedCodes.value = [] selectedCodes.value = []
selectAll.value = false selectAll.value = false
if (!filterProduct.value) { // productid=0 = 全部产品,后端认这个值;只有产品受限的账号必须先选产品。
if (!canQueryCodes.value) {
devices.value = [] devices.value = []
devTotal.value = 0 devTotal.value = 0
devicesError.value = '' devicesError.value = ''
@ -994,12 +1016,13 @@ function changePage(delta: number) {
loadDevices(false) loadDevices(false)
} }
async function resetDevice(license: string) { async function resetDevice(d: DeviceRow) {
if (!window.confirm('确定重置该设备授权状态?')) return if (!window.confirm('确定重置该设备授权状态?')) return
try { try {
// 必须带 productid:设备分表(license_<pid>)按产品切,而 code 就是 MAC, // productid 取这一行自己的,不取顶部筛选——「全部产品」视图下 filterProduct 是 0,
// 导入进来的 MAC 前两字节是外部厂商的 OUI,服务端反解出来会是别人的产品。 // 而服务端 pid=0 会退化成「从 code 反解产品」,导入的 MAC 前两字节是厂商 OUI,
await webApi('resetlicensestatus', { licenses: license, productid: Number(filterProduct.value) || 0 }) // 反解出来是别人的产品。
await webApi('resetlicensestatus', { licenses: d.code, productid: Number(d.productid || filterProduct.value) || 0 })
success('重置成功') success('重置成功')
loadDevices(false) loadDevices(false)
loadNotes() loadNotes()
@ -1017,16 +1040,32 @@ function toggleSelectAll() {
} }
} }
// disabled: -1 禁用 / 0 恢复正常。codes 省略时取勾选项 // disabled: -1 禁用 / 0 恢复正常。codes 省略时取勾选项。
// ⚠️ 后端 disableauthcodes 一次只认一个 productid(合表后批量更新必须显式带产品作用域),
// 而「全部产品」视图下勾选项可能跨产品,所以这里按各行自己的 productid 分组、逐组下发。
async function batchSetDisabled(disabled: number, codes?: string[]) { async function batchSetDisabled(disabled: number, codes?: string[]) {
if (!filterProduct.value) { warn('请先选择产品'); return }
const list = codes ?? selectedCodes.value.slice() const list = codes ?? selectedCodes.value.slice()
if (!list.length) { warn('请先勾选记录'); return } if (!list.length) { warn('请先勾选记录'); return }
const action = disabled === -1 ? '禁用' : '恢复' const action = disabled === -1 ? '禁用' : '恢复'
if (!window.confirm('确定' + action + ' ' + list.length + ' 条记录?' + (disabled === -1 ? '\n禁用后对应设备将无法绑定。' : ''))) return if (!window.confirm('确定' + action + ' ' + list.length + ' 条记录?' + (disabled === -1 ? '\n禁用后对应设备将无法绑定。' : ''))) return
// code → productid,取自当前页数据;查不到就退回顶部筛选值
const pidOf = new Map<string, number>()
devices.value.forEach((d) => { if (d.code) pidOf.set(d.code, Number(d.productid || 0)) })
const groups = new Map<number, string[]>()
for (const code of list) {
const pid = pidOf.get(code) || Number(filterProduct.value) || 0
if (!pid) { warn('有记录无法确定所属产品,请先在顶部选中具体产品'); return }
const g = groups.get(pid)
if (g) g.push(code)
else groups.set(pid, [code])
}
try { try {
const data = await webApi<{ affected?: number }>('disableauthcodes', { productid: filterProduct.value, codes: list, disabled }) let affected = 0
success(action + '成功(' + (data?.affected ?? 0) + ' 条)') for (const [pid, group] of groups) {
const data = await webApi<{ affected?: number }>('disableauthcodes', { productid: pid, codes: group, disabled })
affected += data?.affected ?? 0
}
success(action + '成功(' + affected + ' 条)')
loadDevices(false) loadDevices(false)
} catch (e: any) { } catch (e: any) {
error(e?.message ?? (action + '失败')) error(e?.message ?? (action + '失败'))
@ -1039,8 +1078,8 @@ function showCreateForm() {
cProduct.value = 0 cProduct.value = 0
cSharepct.value = 0 // 比例是一批一谈的,沿用上一批的值风险太大 cSharepct.value = 0 // 比例是一批一谈的,沿用上一批的值风险太大
cRewardVip.value = 0 // 赠送同理,且宁可漏配(回退产品配置)也不要多发 cRewardVip.value = 0 // 赠送同理,且宁可漏配(回退产品配置)也不要多发
cRewardTranslate.value = 0 cVipLevel.value = 1
cRewardMeeting.value = 0 cComputeGrant.value = 0
cNumber.value = null cNumber.value = null
cMacaddresses.value = '' cMacaddresses.value = ''
cRemark.value = '' cRemark.value = ''
@ -1097,8 +1136,8 @@ function buildCreateParams(): Record<string, unknown> | null {
if (pct < 0 || pct > 100) { warn('分成比例需在 0~100% 之间'); return null } if (pct < 0 || pct > 100) { warn('分成比例需在 0~100% 之间'); return null }
params.sharerate = Math.round(pct * 100) params.sharerate = Math.round(pct * 100)
params.rewardvip = Math.max(0, Number(cRewardVip.value) || 0) params.rewardvip = Math.max(0, Number(cRewardVip.value) || 0)
params.rewardtranslate = Math.max(0, Number(cRewardTranslate.value) || 0) params.viplevel = Number(cVipLevel.value) === 2 ? 2 : 1
params.rewardmeeting = Math.max(0, Number(cRewardMeeting.value) || 0) params.computegrant = Math.max(0, Number(cComputeGrant.value) || 0)
if (createMode.value === 'production') { if (createMode.value === 'production') {
params.createtype = 1 params.createtype = 1
params.number = Number(cNumber.value) params.number = Number(cNumber.value)
@ -1178,7 +1217,7 @@ function buildCSV(devs: DeviceRow[]) {
// 导出当前筛选条件下的「全部」记录(分页拉取,而非仅当前页) // 导出当前筛选条件下的「全部」记录(分页拉取,而非仅当前页)
async function exportDevicesCSV() { async function exportDevicesCSV() {
if (!filterProduct.value) { warn('请先选择产品'); return } if (!canQueryCodes.value) { warn('请先选择产品'); return }
try { try {
const base = buildDeviceFilterParams() const base = buildDeviceFilterParams()
const PAGE = 2000 // 后端单页上限 const PAGE = 2000 // 后端单页上限

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

@ -7,16 +7,11 @@
<button class="btn btn-primary btn-sm text-white" @click="showForm()">+ 新增产品</button> <button class="btn btn-primary btn-sm text-white" @click="showForm()">+ 新增产品</button>
</div> </div>
<!-- 筛选:品牌商 / 方案商 / 关键字 --> <!-- 筛选:方案商 / 关键字。
品牌商筛选已去掉:产品建档阶段不再有品牌商(brandid 恒为 0,表单里也早就没这一栏),
留着只会是个永远筛不出东西的下拉。 -->
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 mb-4"> <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="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"> <div class="form-control">
<label class="label py-1"><span class="label-text text-xs text-slate-500">方案商</span></label> <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;"> <select v-model.number="filterProvider" class="select select-bordered select-sm" style="min-width:200px;">
@ -44,18 +39,16 @@
<th class="cursor-pointer select-none" @click="toggleSort('id')">PID{{ sortMark('id') }}</th> <th class="cursor-pointer select-none" @click="toggleSort('id')">PID{{ sortMark('id') }}</th>
<th>图片</th> <th>图片</th>
<th class="cursor-pointer select-none" @click="toggleSort('devicename')">名称{{ sortMark('devicename') }}</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 class="cursor-pointer select-none" @click="toggleSort('provider')">方案商{{ sortMark('provider') }}</th>
<th>绑定应用</th> <th>绑定应用</th>
<th class="cursor-pointer select-none" @click="toggleSort('devicetype')">类型{{ sortMark('devicetype') }}</th>
<th>版本</th><th>功能</th> <th>版本</th><th>功能</th>
<th class="cursor-pointer select-none" @click="toggleSort('createtime')">创建时间{{ sortMark('createtime') }}</th> <th class="cursor-pointer select-none" @click="toggleSort('createtime')">创建时间{{ sortMark('createtime') }}</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-if="loading"><td colspan="11" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td></tr> <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="!viewRows.length"><td colspan="11" class="text-center text-slate-400 py-8">暂无数据</td></tr> <tr v-else-if="!viewRows.length"><td colspan="9" class="text-center text-slate-400 py-8">暂无数据</td></tr>
<tr v-for="p in viewRows" :key="p.id"> <tr v-for="p in viewRows" :key="p.id">
<td class="font-mono text-sm"><span class="badge badge-primary badge-outline badge-sm">0x{{ hex4(p.id) }}</span></td> <td class="font-mono text-sm"><span class="badge badge-primary badge-outline badge-sm">0x{{ hex4(p.id) }}</span></td>
<td> <td>
@ -68,7 +61,6 @@
<div class="font-medium">{{ p.devicename }}</div> <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> <div v-if="p.description" class="text-xs text-slate-400 max-w-xs truncate" :title="p.description">{{ p.description }}</div>
</td> </td>
<td>{{ brands[p.brandid] || '' }}</td>
<td> <td>
<template v-if="p.providerid"> <template v-if="p.providerid">
<span class="badge badge-outline badge-sm">{{ providerLabel(p.providerid) }}</span> <span class="badge badge-outline badge-sm">{{ providerLabel(p.providerid) }}</span>
@ -81,7 +73,6 @@
</div> </div>
<span v-else class="text-slate-300 text-xs">未绑定</span> <span v-else class="text-slate-300 text-xs">未绑定</span>
</td> </td>
<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 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><span class="text-xs text-slate-500">{{ featureCount(p) }}/14</span></td>
<td class="text-xs text-slate-500">{{ formatTs(p.createtime) }}</td> <td class="text-xs text-slate-500">{{ formatTs(p.createtime) }}</td>
@ -180,12 +171,6 @@
<input v-model="form.devicename" type="text" class="input input-bordered w-full" required /> <input v-model="form.devicename" type="text" class="input input-bordered w-full" required />
</div> </div>
<!-- 品牌商已从产品上移除:生产阶段不关心产品属于哪个品牌,品牌归属在 MAC生成 时按批次选。 --> <!-- 品牌商已从产品上移除:生产阶段不关心产品属于哪个品牌,品牌归属在 MAC生成 时按批次选。 -->
<div class="form-control mb-4">
<label class="label"><span class="label-text font-medium">设备类型</span></label>
<select v-model.number="form.devicetype" class="select select-bordered w-full">
<option v-for="(label, val) in deviceTypeOptions" :key="val" :value="Number(val)">{{ label }}</option>
</select>
</div>
<div class="form-control mb-4"> <div class="form-control mb-4">
<label class="label"> <label class="label">
<span class="label-text font-medium">产品说明</span> <span class="label-text font-medium">产品说明</span>
@ -212,19 +197,16 @@
</div> </div>
</div> </div>
<div class="grid grid-cols-2 gap-4 mb-4"> <!-- 扫描UUID 已于 2026-09-08 整体删除(表单、库列、客户端逻辑一并删):
<div class="form-control"> 设备校验早就改成按 MAC 走,绑定奖励也是按 MAC 判「这台设备有没有被绑过」,
<label class="label"><span class="label-text font-medium">扫描UUID</span></label> 跟这个 UUID 没关系。服务端仍下发 scanuuid 空串,只为老 App 包不解析报错。 -->
<input v-model="form.scanuuid" type="text" class="input input-bordered w-full" /> <div class="form-control mb-4">
</div>
<div class="form-control">
<label class="label"><span class="label-text font-medium">设备码数量(每副)</span></label> <label class="label"><span class="label-text font-medium">设备码数量(每副)</span></label>
<select v-model.number="form.devicecodecount" class="select select-bordered w-full"> <select v-model.number="form.devicecodecount" class="select select-bordered w-full max-w-xs">
<option :value="1">单份 (1个/副)</option> <option :value="1">单份 (1个/副)</option>
<option :value="2">双份 (2个/副)</option> <option :value="2">双份 (2个/副)</option>
</select> </select>
</div> </div>
</div>
<!-- 产品图片 --> <!-- 产品图片 -->
<div class="form-control mb-4"> <div class="form-control mb-4">
@ -270,7 +252,6 @@
<div class="flex"><span class="text-slate-500 w-28">方案(BID)</span> <div class="flex"><span class="text-slate-500 w-28">方案(BID)</span>
<span class="font-medium">{{ providerLabel(form.providerid) }}<span class="font-mono text-slate-400 ml-2">0x{{ hex4(form.providerid) }}</span></span></div> <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>{{ factoryName(form.oemfactoryid) }}</span></div> <div class="flex"><span class="text-slate-500 w-28">生产工厂</span><span>{{ factoryName(form.oemfactoryid) }}</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.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> <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">PID 为上面手工指定的值,会一并写进通知邮件。</p> <p class="text-xs text-slate-400 pt-1">PID 为上面手工指定的值,会一并写进通知邮件。</p>
@ -278,12 +259,12 @@
<label class="flex items-center gap-2 cursor-pointer mb-3"> <label class="flex items-center gap-2 cursor-pointer mb-3">
<input v-model="notify.enabled" type="checkbox" class="checkbox checkbox-sm checkbox-primary" /> <input v-model="notify.enabled" type="checkbox" class="checkbox checkbox-sm checkbox-primary" />
<span class="text-sm font-medium">创建后发送产品通知邮件(方案商ID / 品牌商ID / 产品ID)</span> <span class="text-sm font-medium">创建后发送产品通知邮件(芯片ID / 方案ID / 产品ID)</span>
</label> </label>
<div v-if="notify.enabled" class="space-y-3"> <div v-if="notify.enabled" class="space-y-3">
<div class="form-control"> <div class="form-control">
<label class="label py-1"><span class="label-text text-xs">收件人(品牌方)*</span></label> <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="品牌方联系邮箱" /> <input v-model="notify.to" type="email" class="input input-bordered input-sm w-full" placeholder="方案商联系邮箱" />
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label py-1"><span class="label-text text-xs">抄送</span></label> <label class="label py-1"><span class="label-text text-xs">抄送</span></label>
@ -441,11 +422,11 @@ import { useTableSort } from '~/composables/useTableSort'
definePageMeta({ title: '产品管理' }) definePageMeta({ title: '产品管理' })
interface Product { interface Product {
id: number; devicename: string; brandid: number; providerid?: number; devicetype: number id: number; devicename: string; providerid?: number
description?: string // 产品说明(特性描述) description?: string // 产品说明(特性描述)
createtime?: number createtime?: number
oemfactoryid?: number // 代工厂 id(oem_factory.id),0/缺失 = 未指定 oemfactoryid?: number // 代工厂 id(oem_factory.id),0/缺失 = 未指定
scanuuid: string; devicecodecount: number; productimage: string devicecodecount: number; productimage: string
channel: string; channelextra: string channel: string; channelextra: string
appnames?: string // 绑定应用名 CSV(空=未绑定) appnames?: string // 绑定应用名 CSV(空=未绑定)
bindrewardvpitime: number; bindrewardtranslate: number; bindrewardmeeting: number bindrewardvpitime: number; bindrewardtranslate: number; bindrewardmeeting: number
@ -454,23 +435,16 @@ interface Product {
[key: string]: unknown [key: string]: unknown
} }
interface Version { id: number; version: string; description: string; updatepackageaddress: string; productid: number } 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 } interface Provider { id: number; name: string; vendor?: number; contactmails?: string }
const { webApi, consoleApi, uploadFile } = useApi() const { webApi, consoleApi, uploadFile } = useApi()
const { success, error, warn } = useToast() const { success, error, warn } = useToast()
const auth = useAuthStore() 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: '激活码卡片' }
const view = ref<'list' | 'form' | 'version'>('list') const view = ref<'list' | 'form' | 'version'>('list')
const rows = ref<Product[]>([]) const rows = ref<Product[]>([])
const brands = ref<Record<number, string>>({})
const brandOptions = ref<Brand[]>([])
const providers = ref<Provider[]>([]) const providers = ref<Provider[]>([])
// 列表筛选:品牌商 / 方案商(-1=未绑定)/ 关键字 // 列表筛选:方案商(-1=未绑定)/ 关键字
const filterBrand = ref(0)
const filterProvider = ref(0) const filterProvider = ref(0)
const keyword = ref('') const keyword = ref('')
// 列表默认按产品 ID 升序(后端返回顺序不保证),表头可切换排序字段 // 列表默认按产品 ID 升序(后端返回顺序不保证),表头可切换排序字段
@ -482,7 +456,7 @@ const isEditing = ref(false)
const form = reactive({ const form = reactive({
// id 是当前 PID,idHex 是编辑框里的十六进制值;改 PID 时两者不同,靠 id 做定位、idHex 做新值 // id 是当前 PID,idHex 是编辑框里的十六进制值;改 PID 时两者不同,靠 id 做定位、idHex 做新值
id: 0, idHex: '', devicename: '', description: '', brandid: 0, providerid: 0, devicetype: 0, scanuuid: '', devicecodecount: 1, id: 0, idHex: '', devicename: '', description: '', providerid: 0, devicecodecount: 1,
// chipid 只用于筛下面的方案下拉,不落库——芯片由方案商推导,见模板注释 // chipid 只用于筛下面的方案下拉,不落库——芯片由方案商推导,见模板注释
chipid: 0, oemfactoryid: 0, chipid: 0, oemfactoryid: 0,
productimage: '', channel: 'official', appnames: [] as string[], productimage: '', channel: 'official', appnames: [] as string[],
@ -517,7 +491,6 @@ function formatTs(ts?: number) {
' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).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 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 // 方案商展示:公司名(芯片分组),认不出就退回 hex id
function providerLabel(id?: number) { function providerLabel(id?: number) {
if (!id) return '未绑定' if (!id) return '未绑定'
@ -541,7 +514,6 @@ const providerGroups = computed(() => {
const { toggleSort, sortMark, applySort } = useTableSort<Product>({ const { toggleSort, sortMark, applySort } = useTableSort<Product>({
id: (a, b) => Number(a.id) - Number(b.id), 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), 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,同一套方案的产品挨着排 // 芯片方案商:先按芯片厂商分组,再按方案商 id,同一套方案的产品挨着排
provider: (a, b) => { provider: (a, b) => {
const pa = providers.value.find((x) => x.id === a.providerid) const pa = providers.value.find((x) => x.id === a.providerid)
@ -550,7 +522,6 @@ const { toggleSort, sortMark, applySort } = useTableSort<Product>({
(Number(a.providerid || 0) - Number(b.providerid || 0)) || (Number(a.providerid || 0) - Number(b.providerid || 0)) ||
(Number(a.id) - Number(b.id)) (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,顺序才稳定 // 历史产品没有创建时间(0),相等时退回按 ID,顺序才稳定
createtime: (a, b) => Number(a.createtime || 0) - Number(b.createtime || 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') }, 'id')
@ -558,7 +529,6 @@ const { toggleSort, sortMark, applySort } = useTableSort<Product>({
const viewRows = computed(() => { const viewRows = computed(() => {
const kw = keyword.value.trim().toLowerCase() const kw = keyword.value.trim().toLowerCase()
const list = rows.value.filter((p) => { 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 === -1 && Number(p.providerid || 0) !== 0) return false
if (filterProvider.value > 0 && Number(p.providerid || 0) !== filterProvider.value) return false if (filterProvider.value > 0 && Number(p.providerid || 0) !== filterProvider.value) return false
if (!kw) return true if (!kw) return true
@ -567,7 +537,6 @@ const viewRows = computed(() => {
return applySort(list) return applySort(list)
}) })
function resetFilter() { function resetFilter() {
filterBrand.value = 0
filterProvider.value = 0 filterProvider.value = 0
keyword.value = '' keyword.value = ''
} }
@ -587,10 +556,7 @@ function featureCount(p: Product) { return TOGGLE_KEYS.filter((k) => !!p[k]).len
async function loadData() { async function loadData() {
loading.value = true loading.value = true
try { try {
await Promise.all([loadBrands(), loadProviders()]) await loadProviders()
const fmap: Record<number, string> = {}
brandOptions.value.forEach((b) => { fmap[b.id] = b.brand })
brands.value = fmap
const pd = await webApi<{ products: Product[] }>('getproducts', {}) const pd = await webApi<{ products: Product[] }>('getproducts', {})
rows.value = pd?.products ?? [] rows.value = pd?.products ?? []
} catch (e: any) { } catch (e: any) {
@ -601,13 +567,6 @@ async function loadData() {
} }
} }
async function loadBrands() {
try {
const fd = await webApi<{ brands: Brand[] }>('getbrands', {})
brandOptions.value = fd?.brands ?? []
} catch { brandOptions.value = [] }
}
// 方案公司:产品必须绑一个。BID 取它的 id,CID 取它的 vendor(芯片公司) // 方案公司:产品必须绑一个。BID 取它的 id,CID 取它的 vendor(芯片公司)
async function loadProviders() { async function loadProviders() {
try { try {
@ -635,7 +594,6 @@ async function loadAppOptions() {
function showList() { view.value = 'list'; loadData() } function showList() { view.value = 'list'; loadData() }
function showForm(p?: Product) { function showForm(p?: Product) {
loadBrands()
loadProviders() loadProviders()
loadAppOptions() loadAppOptions()
loadFactories() loadFactories()
@ -649,8 +607,6 @@ function showForm(p?: Product) {
// 芯片不落库,由所选方案商反推,保证展示的芯片永远等于方案商当前归属 // 芯片不落库,由所选方案商反推,保证展示的芯片永远等于方案商当前归属
form.chipid = Number(providers.value.find((x) => x.id === p.providerid)?.vendor || 0) form.chipid = Number(providers.value.find((x) => x.id === p.providerid)?.vendor || 0)
form.oemfactoryid = p.oemfactoryid || 0 form.oemfactoryid = p.oemfactoryid || 0
form.devicetype = p.devicetype || 0
form.scanuuid = p.scanuuid || ''
form.devicecodecount = p.devicecodecount === 2 ? 2 : 1 form.devicecodecount = p.devicecodecount === 2 ? 2 : 1
form.productimage = p.productimage || '' form.productimage = p.productimage || ''
form.appnames = appNamesOf(p) form.appnames = appNamesOf(p)
@ -663,8 +619,6 @@ function showForm(p?: Product) {
form.devicename = '' form.devicename = ''
form.description = '' form.description = ''
form.providerid = 0 form.providerid = 0
form.devicetype = 0
form.scanuuid = ''
form.devicecodecount = 1 form.devicecodecount = 1
form.productimage = '' form.productimage = ''
form.appnames = [] form.appnames = []
@ -674,14 +628,12 @@ function showForm(p?: Product) {
function buildProductPayload(): Record<string, unknown> { function buildProductPayload(): Record<string, unknown> {
// 只下发本表单实际编辑的字段。 // 只下发本表单实际编辑的字段。
// 品牌商 / 渠道方(channel,channelextra) / 绑定奖励 / 功能开关 已从表单移除,故不再下发—— // 品牌商 / 渠道方(channel,channelextra) / 扫描UUID / 绑定奖励 / 功能开关 已从表单移除,故不再下发——
// 服务端 updateProduct 会把这几项从旧记录原样保留,编辑产品不会把它们清空。 // 服务端 updateProduct 会把这几项从旧记录原样保留,编辑产品不会把它们清空。
const product: Record<string, unknown> = { const product: Record<string, unknown> = {
devicename: form.devicename, devicename: form.devicename,
description: form.description, description: form.description,
providerid: Number(form.providerid) || 0, providerid: Number(form.providerid) || 0,
devicetype: Number(form.devicetype) || 0,
scanuuid: form.scanuuid,
devicecodecount: Number(form.devicecodecount) || 1, devicecodecount: Number(form.devicecodecount) || 1,
productimage: form.productimage, productimage: form.productimage,
appnames: form.appnames.join(','), appnames: form.appnames.join(','),
@ -741,12 +693,10 @@ const emailModal = ref<HTMLDialogElement | null>(null)
const mailSending = ref(false) const mailSending = ref(false)
const notify = reactive({ enabled: true, to: '', cc: '', subject: '', content: '' }) const notify = reactive({ enabled: true, to: '', cc: '', subject: '', content: '' })
// 抄送候选:品牌方剩下的邮箱 + 方案商邮箱,点一下加进抄送 // 抄送候选:方案商剩下的联系邮箱,点一下加进抄送
const ccCandidates = computed(() => { const ccCandidates = computed(() => {
const used = new Set([...mailsOf(notify.to), ...mailsOf(notify.cc)]) const used = new Set([...mailsOf(notify.to), ...mailsOf(notify.cc)])
const out: { mail: string; from: string }[] = [] 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) 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: '方案商' }) }) mailsOf(pv?.contactmails).forEach((m) => { if (!used.has(m) && !out.some((x) => x.mail === m)) out.push({ mail: m, from: '方案商' }) })
return out return out
@ -758,10 +708,11 @@ function addCc(mail: string) {
notify.cc = cur.join(',') notify.cc = cur.join(',')
} }
// 收件人默认品牌方第一个联系邮箱,其余进抄送 // 收件人默认方案商第一个联系邮箱,其余进抄送。
// 原先默认取品牌方邮箱,但品牌商已从产品上移除(brandid 恒为 0),那一路查出来永远是空。
function prepareNotify() { function prepareNotify() {
const b = brandOptions.value.find((x) => x.id === form.brandid) const pv = providers.value.find((x) => x.id === form.providerid)
const mails = mailsOf(b?.contactmails) const mails = mailsOf(pv?.contactmails)
notify.enabled = true notify.enabled = true
notify.to = mails[0] || '' notify.to = mails[0] || ''
notify.cc = mails.slice(1).join(',') notify.cc = mails.slice(1).join(',')
@ -772,7 +723,7 @@ function prepareNotify() {
function closeConfirm() { confirmModal.value?.close() } function closeConfirm() { confirmModal.value?.close() }
async function doCreate() { async function doCreate() {
if (notify.enabled && !notify.to.trim()) { warn('请填写通知邮件的收件人(品牌方邮箱)'); return } if (notify.enabled && !notify.to.trim()) { warn('请填写通知邮件的收件人(方案商邮箱)'); return }
if (!checkPid()) { closeConfirm(); return } if (!checkPid()) { closeConfirm(); return }
submitting.value = true submitting.value = true
try { try {
@ -796,7 +747,6 @@ async function doCreate() {
// 邮件正文给客户录入用:芯片ID(CID) / 方案ID(BID) / 产品ID(PID) 三个十六进制标识 // 邮件正文给客户录入用:芯片ID(CID) / 方案ID(BID) / 产品ID(PID) 三个十六进制标识
// 口径固定:CID=芯片公司、BID=方案公司、PID=产品。CID 由所选方案商的 vendor 推出,不单独存。 // 口径固定:CID=芯片公司、BID=方案公司、PID=产品。CID 由所选方案商的 vendor 推出,不单独存。
function buildNotifyMail(pid: number) { 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 pv = providers.value.find((x) => x.id === form.providerid)
const today = new Date().toLocaleDateString() const today = new Date().toLocaleDateString()
notify.subject = '【产品创建通知】' + form.devicename + ' - ' + today notify.subject = '【产品创建通知】' + form.devicename + ' - ' + today
@ -806,7 +756,6 @@ function buildNotifyMail(pid: number) {
'芯片ID(CID): 0x' + hex4(chipOfForm.value) + ' ' + chipVendorName(chipOfForm.value) + '\n' + '芯片ID(CID): 0x' + hex4(chipOfForm.value) + ' ' + chipVendorName(chipOfForm.value) + '\n' +
'方案ID(BID): 0x' + hex4(form.providerid) + ' ' + (pv ? pv.name : '') + '\n' + '方案ID(BID): 0x' + hex4(form.providerid) + ' ' + (pv ? pv.name : '') + '\n' +
'产品ID(PID): 0x' + hex4(pid) + ' ' + form.devicename + '\n' + '产品ID(PID): 0x' + hex4(pid) + ' ' + form.devicename + '\n' +
'设备类型: ' + (deviceTypeOptions[form.devicetype] || form.devicetype) + '\n' +
(form.description ? '产品说明: ' + form.description + '\n' : '') + (form.description ? '产品说明: ' + form.description + '\n' : '') +
'设备码数量(每副): ' + (form.devicecodecount === 2 ? '2 个' : '1 个') + '\n' + '设备码数量(每副): ' + (form.devicecodecount === 2 ? '2 个' : '1 个') + '\n' +
'绑定应用: ' + (form.appnames.length ? form.appnames.join('、') : '未绑定') + '\n\n' + '绑定应用: ' + (form.appnames.length ? form.appnames.join('、') : '未绑定') + '\n\n' +

197
apps/admin/app/pages/users.vue

@ -84,15 +84,15 @@
<thead> <thead>
<tr> <tr>
<th>用户</th><th>联系方式</th><th>状态</th><th>会员</th> <th>用户</th><th>联系方式</th><th>状态</th><th>会员</th>
<th class="text-right">翻译</th><th class="text-right">会议</th><th class="text-right">AI</th> <th class="text-right">算力</th>
<th>实名</th><th>注册时间</th><th>最后登录</th><th>操作</th> <th>实名</th><th>注册时间</th><th>最后登录</th><th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-if="loading"><td colspan="11" class="text-center py-10"><span class="loading loading-spinner loading-sm"></span></td></tr> <tr v-if="loading"><td colspan="9" class="text-center py-10"><span class="loading loading-spinner loading-sm"></span></td></tr>
<tr v-else-if="!qRegion"><td colspan="11" class="text-center text-slate-400 py-10">请先选择应用与区域</td></tr> <tr v-else-if="!qRegion"><td colspan="9" class="text-center text-slate-400 py-10">请先选择应用与区域</td></tr>
<tr v-else-if="listError"><td colspan="11" class="text-center text-red-400 py-10">{{ listError }}</td></tr> <tr v-else-if="listError"><td colspan="9" class="text-center text-red-400 py-10">{{ listError }}</td></tr>
<tr v-else-if="!rows.length"><td colspan="11" class="text-center text-slate-400 py-10">没有符合条件的用户</td></tr> <tr v-else-if="!rows.length"><td colspan="9" class="text-center text-slate-400 py-10">没有符合条件的用户</td></tr>
<tr v-for="u in rows" v-else :key="u.uid" class="hover cursor-pointer" @click="openDetail(u)"> <tr v-for="u in rows" v-else :key="u.uid" class="hover cursor-pointer" @click="openDetail(u)">
<td> <td>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@ -118,9 +118,7 @@
<span v-else class="text-slate-300">-</span> <span v-else class="text-slate-300">-</span>
<div v-if="u.vipexptime" class="text-[11px] text-slate-400 mt-0.5">{{ fmtDate(u.vipexptime) }}</div> <div v-if="u.vipexptime" class="text-[11px] text-slate-400 mt-0.5">{{ fmtDate(u.vipexptime) }}</div>
</td> </td>
<td class="text-right text-xs">{{ fmtMin(u.tradeintegral) }}</td> <td class="text-right text-xs tabular-nums">{{ Number(u.computebalance || 0).toLocaleString() }}</td>
<td class="text-right text-xs">{{ fmtMin(u.meetintegral) }}</td>
<td class="text-right text-xs">{{ u.aichatintegral || 0 }}</td>
<td><span class="badge badge-sm" :class="u.idverified ? 'badge-success badge-outline' : 'badge-ghost'">{{ u.idverified ? '已实名' : '未实名' }}</span></td> <td><span class="badge badge-sm" :class="u.idverified ? 'badge-success badge-outline' : 'badge-ghost'">{{ u.idverified ? '已实名' : '未实名' }}</span></td>
<td class="text-xs text-slate-500 whitespace-nowrap">{{ fmtTs(u.createtime) }}</td> <td class="text-xs text-slate-500 whitespace-nowrap">{{ fmtTs(u.createtime) }}</td>
<td class="text-xs text-slate-500 whitespace-nowrap">{{ fmtTs(u.lastsgintime) }}</td> <td class="text-xs text-slate-500 whitespace-nowrap">{{ fmtTs(u.lastsgintime) }}</td>
@ -160,16 +158,62 @@
</div> </div>
<div class="ml-auto flex items-center gap-2 flex-wrap"> <div class="ml-auto flex items-center gap-2 flex-wrap">
<span class="badge badge-sm" :class="stateClass(user.State)">{{ stateLabel(user.State) }}</span> <span class="badge badge-sm" :class="stateClass(user.State)">{{ stateLabel(user.State) }}</span>
<span class="badge badge-primary badge-outline">VIP {{ user.viplv || 0 }}</span> <span class="badge badge-primary badge-outline" :title="vipBadgeTitle(user)">{{ vipBadgeText(user) }}</span>
<span class="badge badge-ghost">算力 {{ Number(user.computebalance || 0).toLocaleString() }}<template v-if="Number(user.compute_deviceleft || 0)"> + 设备 {{ Number(user.compute_deviceleft).toLocaleString() }}</template></span>
<button v-if="giftAllowed" class="btn btn-success btn-sm text-white" @click="openGift">赠送资源点</button> <button v-if="giftAllowed" class="btn btn-success btn-sm text-white" @click="openGift">赠送资源点</button>
</div> </div>
</div> </div>
<!-- 绑定设备赋予的权益:VIP 期与算力都挂在设备行上,按当前绑定关系取(解绑即失去、随设备流转) -->
<div class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
<div class="px-5 py-3 border-b border-slate-50 flex items-center justify-between">
<span class="text-sm font-semibold text-slate-700">绑定设备赋予的权益</span>
<span class="text-xs text-slate-400">设备 VIP 期 = 激活日 + 天数,按日历连续走;设备算力共用一份、已用不重置</span>
</div>
<table class="table table-sm">
<thead><tr><th>设备</th><th>MAC</th><th>激活日</th><th>VIP</th><th>VIP 到期</th><th class="text-right">算力 已用 / 总量</th><th class="text-right">剩余</th></tr></thead>
<tbody>
<tr v-for="d in (user.compute_devices || [])" :key="d.devicemac">
<td>{{ d.devicename || '-' }}</td>
<td class="font-mono text-xs">{{ d.devicemac }}</td>
<td class="text-xs">{{ d.usedtime ? fmtTs(d.usedtime) : '未激活' }}</td>
<td class="text-xs">{{ d.vipdays ? (Number(d.viplevel) >= 2 ? 'VIP-Pro' : 'VIP') + ` ${d.vipdays} 天` : '-' }}</td>
<td class="text-xs" :class="Number(d.vipexptime) * 1000 > Date.now() ? 'text-emerald-600' : 'text-slate-400'">{{ d.vipexptime ? fmtTs(d.vipexptime) : '-' }}</td>
<td class="text-right text-xs tabular-nums">{{ Number(d.computeused || 0).toLocaleString() }} / {{ Number(d.computegrant || 0).toLocaleString() }}</td>
<td class="text-right text-xs tabular-nums font-medium">{{ Number(d.computeleft || 0).toLocaleString() }}</td>
</tr>
<tr v-if="!(user.compute_devices || []).length"><td colspan="7" class="text-center text-slate-400 py-4">当前没有绑定设备</td></tr>
</tbody>
</table>
</div>
<!-- 算力流水 -->
<div class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
<div class="px-5 py-3 border-b border-slate-50 flex items-center justify-between">
<span class="text-sm font-semibold text-slate-700">算力流水 <span class="text-xs text-slate-400 font-normal">最近 {{ logs.length }} / 共 {{ logsTotal }} 条</span></span>
<button class="btn btn-ghost btn-xs" @click="loadLogs">刷新</button>
</div>
<table class="table table-sm">
<thead><tr><th>时间</th><th>类型</th><th>原始量</th><th class="text-right">算力</th><th>来源拆分</th><th>备注</th></tr></thead>
<tbody>
<tr v-for="l in logs" :key="l.id">
<td class="text-xs whitespace-nowrap">{{ fmtTs(l.ts) }}</td>
<td class="text-xs">{{ logKind(l) }}</td>
<td class="text-xs">{{ logRaw(l) }}</td>
<td class="text-right text-xs tabular-nums" :class="Number(l.compute) < 0 ? 'text-red-500' : 'text-emerald-600'">{{ Number(l.compute || 0) > 0 ? '+' : '' }}{{ Number(l.compute || 0) }}</td>
<td class="text-xs text-slate-500">{{ logSource(l) }}</td>
<td class="text-xs text-slate-400 truncate max-w-[220px]" :title="l.extra">{{ l.extra }}</td>
</tr>
<tr v-if="!logs.length"><td colspan="6" class="text-center text-slate-400 py-4">暂无流水</td></tr>
</tbody>
</table>
</div>
<!-- 设置参数:仅超管/管理员。代理要发资源走「赠送资源点」,那条路会扣自己的余额 --> <!-- 设置参数:仅超管/管理员。代理要发资源走「赠送资源点」,那条路会扣自己的余额 -->
<div v-if="canEdit" class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden"> <div v-if="canEdit" class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
<div class="px-5 py-3 border-b border-slate-50 flex items-center justify-between"> <div class="px-5 py-3 border-b border-slate-50 flex items-center justify-between">
<span class="text-sm font-semibold text-slate-700">设置参数</span> <span class="text-sm font-semibold text-slate-700">设置参数</span>
<span class="text-xs text-slate-400">只提交你改过的项;积分填的是<b>余额绝对值</b>,不是增量</span> <span class="text-xs text-slate-400">只提交你改过的项。VIP 到期填的是<b>自购部分</b>;设备赋予的 VIP 由设备行决定,不在这里改</span>
</div> </div>
<div class="px-5 py-4"> <div class="px-5 py-4">
<div class="grid grid-cols-2 md:grid-cols-3 gap-4"> <div class="grid grid-cols-2 md:grid-cols-3 gap-4">
@ -194,17 +238,25 @@
<input v-model="edit.vipexpDate" type="datetime-local" class="input input-bordered input-sm" /> <input v-model="edit.vipexpDate" type="datetime-local" class="input input-bordered input-sm" />
<span class="label-text-alt text-slate-400 mt-1">清空 = 不设到期(等于没有会员)</span> <span class="label-text-alt text-slate-400 mt-1">清空 = 不设到期(等于没有会员)</span>
</div> </div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-xs">翻译余额(分钟)</span></label>
<input v-model.number="edit.trademin" type="number" min="0" class="input input-bordered input-sm" />
</div> </div>
<div class="form-control"> <!-- 算力余额:只能按增量调整并留备注(走 AdminAdjust 流水),不能直接改绝对值 -->
<label class="label py-1"><span class="label-text text-xs">会议余额(分钟)</span></label> <div class="mt-4 pt-4 border-t border-slate-100">
<input v-model.number="edit.meetmin" type="number" min="0" class="input input-bordered input-sm" /> <div class="text-xs font-semibold text-slate-600 mb-2">
算力余额 <span class="font-mono text-slate-800">{{ Number(user.computebalance || 0).toLocaleString() }}</span>
<span class="text-slate-400 font-normal ml-2">按增量调整,负数为扣减,余额不能低于 0;每次调整写一条流水</span>
</div> </div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 items-end">
<div class="form-control"> <div class="form-control">
<label class="label py-1"><span class="label-text text-xs">AI 余额(次)</span></label> <label class="label py-1"><span class="label-text text-xs">增量(可负)</span></label>
<input v-model.number="edit.aichat" type="number" min="0" class="input input-bordered input-sm" /> <input v-model.number="adj.delta" type="number" class="input input-bordered input-sm" placeholder="如 100 或 -50" />
</div>
<div class="form-control md:col-span-2">
<label class="label py-1"><span class="label-text text-xs">备注(必填)</span></label>
<input v-model="adj.remark" type="text" class="input input-bordered input-sm" placeholder="原因,如:客诉补偿 / 误发扣回" />
</div>
<button class="btn btn-warning btn-sm" :disabled="adjusting || !adj.delta || !adj.remark.trim()" @click="submitAdjust">
<span v-if="adjusting" class="loading loading-spinner loading-xs"></span>调整算力
</button>
</div> </div>
</div> </div>
<div class="flex items-center gap-2 mt-4"> <div class="flex items-center gap-2 mt-4">
@ -244,11 +296,11 @@
<input v-model.number="gift.vipday" type="number" class="input input-bordered" min="0" /> <input v-model.number="gift.vipday" type="number" class="input input-bordered" min="0" />
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label py-1"><span class="label-text text-sm">翻译分钟</span></label> <label class="label py-1"><span class="label-text text-sm">翻译分钟</span><span class="label-text-alt text-slate-400">按系数折成算力</span></label>
<input v-model.number="gift.trademin" type="number" class="input input-bordered" min="0" /> <input v-model.number="gift.trademin" type="number" class="input input-bordered" min="0" />
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label py-1"><span class="label-text text-sm">会议分钟</span></label> <label class="label py-1"><span class="label-text text-sm">会议分钟</span><span class="label-text-alt text-slate-400">按系数折成算力</span></label>
<input v-model.number="gift.meetmin" type="number" class="input input-bordered" min="0" /> <input v-model.number="gift.meetmin" type="number" class="input input-bordered" min="0" />
</div> </div>
</div> </div>
@ -325,8 +377,14 @@ const canEdit = computed(() => auth.isAdmin)
const giftAllowed = computed(() => [1, 2, 3].includes(auth.identity)) const giftAllowed = computed(() => [1, 2, 3].includes(auth.identity))
const saving = ref(false) const saving = ref(false)
const edit = reactive({ name: '', state: 0, viplv: 0, vipexpDate: '', trademin: 0, meetmin: 0, aichat: 0 }) const edit = reactive({ name: '', state: 0, viplv: 0, vipexpDate: '' })
const editBase = reactive({ name: '', state: 0, viplv: 0, vipexpDate: '', trademin: 0, meetmin: 0, aichat: 0 }) const editBase = reactive({ name: '', state: 0, viplv: 0, vipexpDate: '' })
// 算力调整(增量 + 备注),与「设置参数」分开提交
const adj = reactive({ delta: 0, remark: '' })
const adjusting = ref(false)
// 算力流水(详情打开时拉最近一页)
const logs = ref<Record<string, any>[]>([])
const logsTotal = ref(0)
const giftModal = ref<HTMLDialogElement | null>(null) const giftModal = ref<HTMLDialogElement | null>(null)
const gift = reactive({ vipday: 0, trademin: 0, meetmin: 0 }) const gift = reactive({ vipday: 0, trademin: 0, meetmin: 0 })
@ -412,11 +470,16 @@ const sections: Section[] = [
['lastbindproductid', '最后绑定产品ID'], ['lastbindchannelid', '最后绑定渠道商'], ['lastbindproductid', '最后绑定产品ID'], ['lastbindchannelid', '最后绑定渠道商'],
['isactivatecode', '已领公码', 'bool'], ['isgiveaway', '已领过赠送', 'bool'], ['isactivatecode', '已领公码', 'bool'], ['isgiveaway', '已领过赠送', 'bool'],
] }, ] },
{ title: '会员与积分', rows: [ { title: '会员(有效 = 自购 或 设备赋予)', rows: [
['viplv', 'VIP等级'], ['vipexptime', 'VIP到期', 'ts'], ['vip_effective_level', '有效VIP等级'], ['vip_effective_exptime', '有效VIP到期', 'ts'],
['aichatintegral', 'AI积分(次)'], ['aichattotalintegral', 'AI累计'], ['vip_effective_source', '当前来源'],
['tradeintegral', '翻译积分(秒)'], ['tradetotalintegral', '翻译累计(秒)'], ['viplv', '自购VIP等级'], ['vipexptime', '自购VIP到期', 'ts'],
['meetintegral', '会议积分(秒)'], ['meettotalintegral', '会议累计(秒)'], ] },
{ title: '算力', rows: [
['computebalance', '账户余额'], ['compute_deviceleft', '设备剩余合计'],
] },
{ title: '已停用的旧额度(2026-09 起不再作为闸门,存量已折算进算力)', rows: [
['aichatintegral', 'AI积分(次)'], ['tradeintegral', '翻译积分(秒)'], ['meetintegral', '会议积分(秒)'],
] }, ] },
{ title: '实名认证', rows: [ { title: '实名认证', rows: [
['idverified', '已实名', 'bool'], ['idverifiedtime', '实名时间', 'ts'], ['idverified', '已实名', 'bool'], ['idverifiedtime', '实名时间', 'ts'],
@ -504,8 +567,14 @@ async function openDetail(row: UserRow) {
auth.setCurrentApp(Number(lastAppId.value)) auth.setCurrentApp(Number(lastAppId.value))
const d = await webApi<UserRow>('getuserinfo', { uid: lastUid.value }) const d = await webApi<UserRow>('getuserinfo', { uid: lastUid.value })
if (d) { if (d) {
// 后端把有效 VIP 放在嵌套的 vip_effective 里;详情表按扁平 key 取值,这里拍平
const ve = (d.vip_effective || {}) as Record<string, any>
d.vip_effective_level = Number(ve.level || 0)
d.vip_effective_exptime = Number(ve.exptime || 0)
d.vip_effective_source = ve.level ? (ve.fromdevice ? '设备赋予' : '自购') : '无'
user.value = d user.value = d
fillEdit(d) fillEdit(d)
loadLogs()
} }
} catch (e: any) { } catch (e: any) {
// 详情拉不到不清空:列表那一行的数据仍然是对的,够看大部分字段 // 详情拉不到不清空:列表那一行的数据仍然是对的,够看大部分字段
@ -519,9 +588,6 @@ function fillEdit(u: UserRow) {
state: Number(u.State || 0), state: Number(u.State || 0),
viplv: Number(u.viplv || 0), viplv: Number(u.viplv || 0),
vipexpDate: unixToLocalInput(u.vipexptime), vipexpDate: unixToLocalInput(u.vipexptime),
trademin: Math.floor((Number(u.tradeintegral) || 0) / 60),
meetmin: Math.floor((Number(u.meetintegral) || 0) / 60),
aichat: Number(u.aichatintegral || 0),
} }
Object.assign(edit, snap) Object.assign(edit, snap)
Object.assign(editBase, snap) Object.assign(editBase, snap)
@ -534,6 +600,67 @@ const dirtyCount = computed(() =>
(Object.keys(editBase) as (keyof typeof editBase)[]).filter((k) => edit[k] !== editBase[k]).length, (Object.keys(editBase) as (keyof typeof editBase)[]).filter((k) => edit[k] !== editBase[k]).length,
) )
function vipBadgeText(u: UserRow) {
const lv = Number(u.vip_effective_level ?? u.viplv ?? 0)
if (!lv) return '无 VIP'
return `${lv >= 2 ? 'VIP-Pro' : 'VIP'}${u.vip_effective_source === '设备赋予' ? '(设备)' : ''}`
}
function vipBadgeTitle(u: UserRow) {
const exp = Number(u.vip_effective_exptime || 0)
return exp ? `有效到 ${fmtTs(exp)}` : ''
}
async function submitAdjust() {
if (!lastUid.value || !adj.delta || !adj.remark.trim()) return
const delta = Number(adj.delta)
if (!confirm(`确定给该用户${delta > 0 ? '增加' : '扣减'} ${Math.abs(delta)} 算力?`)) return
adjusting.value = true
try {
auth.setCurrentApp(Number(lastAppId.value))
await webApi('adjustusercompute', { uid: lastUid.value, delta, remark: adj.remark.trim() })
success('已调整')
adj.delta = 0
adj.remark = ''
await refreshCurrent()
} catch (e: any) {
error(e?.message || '调整失败')
} finally {
adjusting.value = false
}
}
async function loadLogs() {
if (!lastUid.value) return
try {
auth.setCurrentApp(Number(lastAppId.value))
const d = await webApi<{ logs: Record<string, any>[]; total: number }>('getusercomputelogs', { uid: lastUid.value, page: 1, page_size: 30 })
logs.value = d?.logs || []
logsTotal.value = Number(d?.total || 0)
} catch {
logs.value = []
}
}
const LOGTYPE: Record<number, string> = { 1: '购买', 2: '活动/激活', 3: '后台赠送', 4: '消耗', 5: '后台调整' }
function logKind(l: Record<string, any>) {
if (Number(l.addtradesecond) < 0) return '翻译'
if (Number(l.addmeetsecond) < 0) return '会议纪要'
if (Number(l.addagentintegral) < 0) return 'AI 对话'
return LOGTYPE[Number(l.logtype)] || String(l.logtype)
}
function logRaw(l: Record<string, any>) {
if (Number(l.addtradesecond) < 0) return `${Math.round(-Number(l.addtradesecond) / 60)} 分钟`
if (Number(l.addmeetsecond) < 0) return `${Math.round(-Number(l.addmeetsecond) / 60)} 分钟`
if (Number(l.addvipday) > 0) return `VIP +${l.addvipday} 天`
return ''
}
function logSource(l: Record<string, any>) {
const parts: string[] = []
if (Number(l.computedevice) > 0) parts.push(`设备 ${l.computedevice}`)
if (Number(l.computeuser) > 0) parts.push(`账户 ${l.computeuser}`)
if (Number(l.computeover) > 0) parts.push(`超额 ${l.computeover}`)
return parts.join(' · ')
}
async function saveEdit() { async function saveEdit() {
if (!lastUid.value || !dirtyCount.value) return if (!lastUid.value || !dirtyCount.value) return
const payload: Record<string, unknown> = { uid: lastUid.value } const payload: Record<string, unknown> = { uid: lastUid.value }
@ -541,10 +668,6 @@ async function saveEdit() {
if (edit.state !== editBase.state) payload.state = Number(edit.state) if (edit.state !== editBase.state) payload.state = Number(edit.state)
if (edit.viplv !== editBase.viplv) payload.viplv = Number(edit.viplv) if (edit.viplv !== editBase.viplv) payload.viplv = Number(edit.viplv)
if (edit.vipexpDate !== editBase.vipexpDate) payload.vipexptime = localInputToUnix(edit.vipexpDate) if (edit.vipexpDate !== editBase.vipexpDate) payload.vipexptime = localInputToUnix(edit.vipexpDate)
// 分钟 → 秒:库里存的是秒,表单填的是分钟
if (edit.trademin !== editBase.trademin) payload.tradeintegral = Math.max(0, Number(edit.trademin) || 0) * 60
if (edit.meetmin !== editBase.meetmin) payload.meetintegral = Math.max(0, Number(edit.meetmin) || 0) * 60
if (edit.aichat !== editBase.aichat) payload.aichatintegral = Math.max(0, Number(edit.aichat) || 0)
saving.value = true saving.value = true
try { try {
auth.setCurrentApp(Number(lastAppId.value)) auth.setCurrentApp(Number(lastAppId.value))
@ -564,8 +687,14 @@ async function refreshCurrent() {
auth.setCurrentApp(Number(lastAppId.value)) auth.setCurrentApp(Number(lastAppId.value))
const d = await webApi<UserRow>('getuserinfo', { uid: lastUid.value }) const d = await webApi<UserRow>('getuserinfo', { uid: lastUid.value })
if (d) { if (d) {
// 后端把有效 VIP 放在嵌套的 vip_effective 里;详情表按扁平 key 取值,这里拍平
const ve = (d.vip_effective || {}) as Record<string, any>
d.vip_effective_level = Number(ve.level || 0)
d.vip_effective_exptime = Number(ve.exptime || 0)
d.vip_effective_source = ve.level ? (ve.fromdevice ? '设备赋予' : '自购') : '无'
user.value = d user.value = d
fillEdit(d) fillEdit(d)
loadLogs()
} }
} catch { /* 详情刷新失败不影响已保存的结果 */ } } catch { /* 详情刷新失败不影响已保存的结果 */ }
loadUsers() loadUsers()

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

@ -66,6 +66,7 @@ export const MENU_GROUPS: MenuGroup[] = [
{ 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: '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: '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: 'appgoods', label: '应用商品配置', to: '/appgoods', icon: 'M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z' },
{ id: 'compute', label: '会员与算力', to: '/compute', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
{ 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: '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' },
// 版本控制 / 游客显隐控制:按应用配、不分渠道(存 app_release 表)。 // 版本控制 / 游客显隐控制:按应用配、不分渠道(存 app_release 表)。
// 这两组开关原先埋在「SaaS 管理 → 渠道分发」里按渠道各存一份,同一个开关有 8 处能改, // 这两组开关原先埋在「SaaS 管理 → 渠道分发」里按渠道各存一份,同一个开关有 8 处能改,

BIN
apps/proto/.DS_Store

Binary file not shown.

4
apps/proto/api/api_msg.proto

@ -480,7 +480,9 @@ message ApiCreateAuthCodesReq {
int32 sharerate = 11; //@go_tags(`json:"sharerate"`) 本批次渠道分成比例(万分比 0~10000),生产时冻结进批次台账 int32 sharerate = 11; //@go_tags(`json:"sharerate"`) 本批次渠道分成比例(万分比 0~10000),生产时冻结进批次台账
uint32 rewardvip = 12; //@go_tags(`json:"rewardvip"`) 本批次绑定赠送VIP天数(0=不送) uint32 rewardvip = 12; //@go_tags(`json:"rewardvip"`) 本批次绑定赠送VIP天数(0=不送)
uint32 rewardtranslate = 13; //@go_tags(`json:"rewardtranslate"`) 本批次绑定赠送翻译时长(分钟,0=不送) uint32 rewardtranslate = 13; //@go_tags(`json:"rewardtranslate"`) 本批次绑定赠送翻译时长(分钟,0=不送)
uint32 rewardmeeting = 14; //@go_tags(`json:"rewardmeeting"`) 本批次绑定赠送会议时长(分钟,0=不送) uint32 rewardmeeting = 14; //@go_tags(`json:"rewardmeeting"`) 【已停用】本批次绑定赠送会议时长(分钟),2026-09 起不再读写
int32 viplevel = 15; //@go_tags(`json:"viplevel"`) 本批次绑定赠送VIP等级(1=VIP 2=VIP-Pro,默认1)
int64 computegrant = 16; //@go_tags(`json:"computegrant"`) 本批次绑定赠送算力(0=不送)
} }
message ApiCreateAuthCodesResp { message ApiCreateAuthCodesResp {

48
apps/proto/db.proto

@ -62,6 +62,10 @@ message DBUser {
string lastbindchannelid = 50; //@go_tags(`gorm:"lastbindchannelid;size:16;index" json:"lastbindchannelid"`) 最后绑定设备的渠道商ID(充值/消耗按此渠道商归属统计与结算) string lastbindchannelid = 50; //@go_tags(`gorm:"lastbindchannelid;size:16;index" json:"lastbindchannelid"`) 最后绑定设备的渠道商ID(充值/消耗按此渠道商归属统计与结算)
bool idverified = 51; //@go_tags(`gorm:"idverified;index" json:"idverified"`) 是否已通过身份证实名校验(明细见 useridverify 表) bool idverified = 51; //@go_tags(`gorm:"idverified;index" json:"idverified"`) 是否已通过身份证实名校验(明细见 useridverify 表)
int64 idverifiedtime = 52; //@go_tags(`gorm:"idverifiedtime" json:"idverifiedtime"`) 实名校验通过时间(0=未通过) int64 idverifiedtime = 52; //@go_tags(`gorm:"idverifiedtime" json:"idverifiedtime"`) 实名校验通过时间(0=未通过)
// 用户算力余额(充值/后台调整获得,归属账号,不过期)。
// 三个旧额度桶(aichatintegral/tradeintegral/meetintegral)自 2026-09 起停用、不再作为闸门,
// 存量值已按换算系数折进本字段(见 docs/会员权益与用量统计-设计与开发文档.md §10 存量迁移)。
int64 computebalance = 53; //@go_tags(`gorm:"computebalance" json:"computebalance"`) 算力余额(账号自有,不过期)
} }
//用户实名认证记录(表 useridverify)。一个用户一行。 //用户实名认证记录(表 useridverify)。一个用户一行。
@ -294,16 +298,35 @@ message DBProductionBatch{
uint32 rewardvip = 15; //@go_tags(`gorm:"rewardvip" json:"rewardvip"`) 绑定赠送VIP天数(0=不送) uint32 rewardvip = 15; //@go_tags(`gorm:"rewardvip" json:"rewardvip"`) 绑定赠送VIP天数(0=不送)
uint32 rewardtranslate = 16; //@go_tags(`gorm:"rewardtranslate" json:"rewardtranslate"`) 绑定赠送翻译时长(分钟,0=不送) uint32 rewardtranslate = 16; //@go_tags(`gorm:"rewardtranslate" json:"rewardtranslate"`) 绑定赠送翻译时长(分钟,0=不送)
uint32 rewardmeeting = 17; //@go_tags(`gorm:"rewardmeeting" json:"rewardmeeting"`) 绑定赠送会议时长(分钟,0=不送) uint32 rewardmeeting = 17; //@go_tags(`gorm:"rewardmeeting" json:"rewardmeeting"`) 绑定赠送会议时长(分钟,0=不送)
// 2026-09 起绑定赠送只剩「VIP 天数 + VIP 等级 + 算力」三项:rewardvip 复用为天数,
// rewardtranslate/rewardmeeting **不再读写**(列保留,避免存量批次表结构变动)。
int32 viplevel = 18; //@go_tags(`gorm:"viplevel" json:"viplevel"`) 绑定赠送VIP等级(1=VIP 2=VIP-Pro,默认1)
int64 computegrant = 19; //@go_tags(`gorm:"computegrant" json:"computegrant"`) 绑定赠送算力(0=不送)
} }
//工厂产品 //工厂产品
message DBProduct{ message DBProduct{
uint32 id = 1; //@go_tags(`gorm:"id;primaryKey;autoIncrement" json:"id"`) 设备编码 uint32 id = 1; //@go_tags(`gorm:"id;primaryKey;autoIncrement" json:"id"`) 设备编码
uint32 brandid = 2; //@go_tags(`gorm:"brandid;index" json:"brandid"`) 品牌商id uint32 brandid = 2; //@go_tags(`gorm:"brandid;index" json:"brandid"`) 品牌商id
DeviceType devicetype = 3; //@go_tags(`gorm:"devicetype" json:"devicetype"`) 设备类型 //【已废弃 2026-09-08】产品上的「设备类型」已删除:服务端没有任何逻辑读它,
//后台表单也已去掉这一项。枚举把「连接方式」(经典/BLE单双端)和「产品形态」(录音笔/眼镜/卡片)
//混在一根轴上,客户端早已另建 DeviceCategories 做品类,这里只剩误配来源。
//⚠️ 字段保留为**纯下发兼容字段**,理由同下面的 factoryid:已发布的 App 包按「非空 int」解析
//devicetype,字段一旦不下发,整个 user_getappconfig 响应就抛 TypeError(产品表、设备列表全废)。
//gorm:"-" 不建列、不参与读写,恒为 0 下发。老包淘汰干净后可整条删除。
//⚠️ 字段号 3 不要复用。
DeviceType devicetype = 3; //@go_tags(`gorm:"-" json:"devicetype"`) [兼容]已废弃,恒为0
string devicename = 4; //@go_tags(`gorm:"devicename" json:"devicename"`) 设备名称 string devicename = 4; //@go_tags(`gorm:"devicename" json:"devicename"`) 设备名称
string productimage = 5; //@go_tags(`gorm:"productimage" json:"productimage"`) 产品图片 string productimage = 5; //@go_tags(`gorm:"productimage" json:"productimage"`) 产品图片
string scanuuid = 6; //@go_tags(`gorm:"scanuuid" json:"scanuuid"`) 设备扫描唯一id //【已废弃 2026-09-08】设备扫描UUID 已删除:设备校验早就换成按 MAC 走(device_mac 全局表,
//见 comm.TableDeviceMac),绑定奖励也是按 MAC 判「这台设备有没有被绑过」(codestate),
//跟这个 UUID 没有任何关系。后台产品表单里那一栏也已去掉。
//⚠️ 字段保留为**纯下发兼容字段**,理由同 devicetype / factoryid:已发布的 App 包按
//「非空 String」解析 scanuuid(`json['scanuuid'] as String`),字段一旦不下发,
//整个 user_getappconfig 响应就抛 TypeError(产品表、设备列表全废)。
//gorm:"-" 不建列、不参与读写,恒为空串下发。老包淘汰干净后可整条删除。
//⚠️ 字段号 6 不要复用。
string scanuuid = 6; //@go_tags(`gorm:"-" json:"scanuuid"`) [兼容]已废弃,恒为空
string version = 7; //@go_tags(`gorm:"version" json:"version"`) 版本 string version = 7; //@go_tags(`gorm:"version" json:"version"`) 版本
string updatedescription = 9; //@go_tags(`gorm:"updatedescription" json:"updatedescription"`) 描述 string updatedescription = 9; //@go_tags(`gorm:"updatedescription" json:"updatedescription"`) 描述
string updatepackageaddress = 10; //@go_tags(`gorm:"updatepackageaddress" json:"updatepackageaddress"`) 更新包地址=url string updatepackageaddress = 10; //@go_tags(`gorm:"updatepackageaddress" json:"updatepackageaddress"`) 更新包地址=url
@ -359,6 +382,10 @@ message DBProduct{
//也没法把耳机刷回旧固件反复验证 OTA 流程。 //也没法把耳机刷回旧固件反复验证 OTA 流程。
//⚠️ 正式产品别开:一次配置失误(比如后台挂了个旧版固件)就会把全量用户降级。 //⚠️ 正式产品别开:一次配置失误(比如后台挂了个旧版固件)就会把全量用户降级。
bool updateonmismatch = 40; //@go_tags(`gorm:"updateonmismatch" json:"updateonmismatch"`) 版本不一致即可升级(开发用,默认关) bool updateonmismatch = 40; //@go_tags(`gorm:"updateonmismatch" json:"updateonmismatch"`) 版本不一致即可升级(开发用,默认关)
// 批次未配置赠送时的回退(与 production_batch 同语义)。bindrewardvpitime 复用为 VIP 天数,
// bindrewardtranslate/bindrewardmeeting 不再读写。
int32 bindviplevel = 60; //@go_tags(`gorm:"bindviplevel" json:"bindviplevel"`) 绑定赠送VIP等级回退值(默认1)
int64 bindcompute = 61; //@go_tags(`gorm:"bindcompute" json:"bindcompute"`) 绑定赠送算力回退值(0=不送)
} }
@ -404,6 +431,15 @@ message DBAuthCode {
string devicemac = 12; //@go_tags(`gorm:"devicemac;index:idx_product_mac" json:"devicemac"`) 地址 string devicemac = 12; //@go_tags(`gorm:"devicemac;index:idx_product_mac" json:"devicemac"`) 地址
int32 disabled = 13; //@go_tags(`gorm:"disabled;index" json:"disabled"`) 封禁状态 0:正常 -1:禁用 int32 disabled = 13; //@go_tags(`gorm:"disabled;index" json:"disabled"`) 封禁状态 0:正常 -1:禁用
string channelid = 14; //@go_tags(`gorm:"channelid;size:16;index" json:"channelid"`) 渠道商id(短码,生产时随批次落库;空=未分配渠道) string channelid = 14; //@go_tags(`gorm:"channelid;size:16;index" json:"channelid"`) 渠道商id(短码,生产时随批次落库;空=未分配渠道)
// ── 设备权益快照(首次绑定=激活时从生产批次固化,此后改批次不影响已激活设备)──
// 设备 VIP 期 = [usedtime, usedtime + vipdays*86400),按日历天连续走、解绑不暂停;
// 判定时按「当前绑定着且未到期」算,**不写用户表**,解绑无需回收。
int32 vipdays = 15; //@go_tags(`gorm:"vipdays" json:"vipdays"`) 设备赠送VIP天数(激活时快照,0=不送)
int32 viplevel = 16; //@go_tags(`gorm:"viplevel" json:"viplevel"`) 设备赠送VIP等级(1=VIP 2=VIP-Pro,激活时快照)
// 设备赠送算力:总量快照 + 已消耗。剩余 = computegrant - computeused。
// computeused 解绑**不重置**:设备算力是共用一份、随设备流转,重置等于换个账号就能无限刷。
int64 computegrant = 17; //@go_tags(`gorm:"computegrant" json:"computegrant"`) 设备赠送算力总量(激活时快照,0=不送)
int64 computeused = 18; //@go_tags(`gorm:"computeused" json:"computeused"`) 设备算力已消耗(解绑不重置)
} }
@ -444,6 +480,7 @@ message DBGoods{
int64 meetnum = 9; //@go_tags(`gorm:"meetnum" json:"meetnum"`) 会议使用量资源点 int64 meetnum = 9; //@go_tags(`gorm:"meetnum" json:"meetnum"`) 会议使用量资源点
int64 tradenum = 10; //@go_tags(`gorm:"tradenum" json:"tradenum"`) 翻译资源点 int64 tradenum = 10; //@go_tags(`gorm:"tradenum" json:"tradenum"`) 翻译资源点
int32 viptime = 11; //@go_tags(`gorm:"viptime" json:"viptime"`) 会员时间 int32 viptime = 11; //@go_tags(`gorm:"viptime" json:"viptime"`) 会员时间
int64 compute = 12; //@go_tags(`gorm:"compute" json:"compute"`) 发放算力(2026-09 起 ainum/meetnum/tradenum 不再发放)
} }
//语音包管理 //语音包管理
@ -467,6 +504,12 @@ message DBUserUseLog {
int64 addtradesecond = 7; //@go_tags(`gorm:"addtradesecond" json:"addtradesecond"`) 翻译资源点使用时常 int64 addtradesecond = 7; //@go_tags(`gorm:"addtradesecond" json:"addtradesecond"`) 翻译资源点使用时常
int64 addmeetsecond = 8; //@go_tags(`gorm:"addmeetsecond" json:"addmeetsecond"`) 会议使用时常 int64 addmeetsecond = 8; //@go_tags(`gorm:"addmeetsecond" json:"addmeetsecond"`) 会议使用时常
string extra = 9; //@go_tags(`gorm:"extra" json:"extra"`) 附加数据 string extra = 9; //@go_tags(`gorm:"extra" json:"extra"`) 附加数据
// ── 算力记账(2026-09 起)。消耗为负、发放/调整为正;来源拆分三列之和 = compute 的绝对值 ──
int64 compute = 10; //@go_tags(`gorm:"compute" json:"compute"`) 本次算力变动(消耗为负)
int64 computedevice = 11; //@go_tags(`gorm:"computedevice" json:"computedevice"`) 从设备赠送额度扣的部分
int64 computeuser = 12; //@go_tags(`gorm:"computeuser" json:"computeuser"`) 从用户余额扣的部分
int64 computeover = 13; //@go_tags(`gorm:"computeover" json:"computeover"`) 超额部分(闸门关时余额不足仍放行的量)
string devicemac = 14; //@go_tags(`gorm:"devicemac;size:50" json:"devicemac"`) 扣了哪台设备(为空=未动设备额度)
} }
//用户统计数据 //用户统计数据
@ -487,6 +530,7 @@ message DBUserStatistics{
int64 trademodel4time = 14; //@go_tags(`gorm:"trademodel4time" json:"trademodel4time"`) 翻译模式4时常 int64 trademodel4time = 14; //@go_tags(`gorm:"trademodel4time" json:"trademodel4time"`) 翻译模式4时常
int32 trademodel5num = 15; //@go_tags(`gorm:"trademodel5num" json:"trademodel5num"`) 翻译模式5次数 int32 trademodel5num = 15; //@go_tags(`gorm:"trademodel5num" json:"trademodel5num"`) 翻译模式5次数
int32 tradewordcount = 16; //@go_tags(`gorm:"tradewordcount" json:"tradewordcount"`) 翻译文本长度 int32 tradewordcount = 16; //@go_tags(`gorm:"tradewordcount" json:"tradewordcount"`) 翻译文本长度
int64 computetotal = 17; //@go_tags(`gorm:"computetotal" json:"computetotal"`) 累计消耗算力
} }
// App综合统计(日/月/年三张表共用同一结构) // App综合统计(日/月/年三张表共用同一结构)

8
apps/proto/enum.proto

@ -99,6 +99,10 @@ enum UsageType {
// @Description 购买VIP时长 // @Description 购买VIP时长
// @example // @example
VIP = 4; //购买VIP时长 VIP = 4; //购买VIP时长
// 购买算力
// @Description 购买算力包(2026-09 起;商品的 compute 字段即本次发放量,归账号、不过期)
// @example
Compute = 5; //购买算力
} }
@ -124,6 +128,10 @@ enum UserLogType {
// @Description 用户消耗 // @Description 用户消耗
// @example // @example
UserConsume = 4; //用户消耗 UserConsume = 4; //用户消耗
// 后台调整算力
// @Description 后台调整用户算力余额(加减)
// @example
AdminAdjust = 5; //后台调整算力
} }
//MCP服务类型 //MCP服务类型

5
apps/proto/errorcode.proto

@ -107,4 +107,9 @@ enum ErrorCode {
TranslateNotConfigured = 5101; //未配置机器翻译服务(后台第三方服务配置里没有启用的 MT 服务) TranslateNotConfigured = 5101; //未配置机器翻译服务(后台第三方服务配置里没有启用的 MT 服务)
TranslateParamInvalid = 5102; //翻译参数不合法(原文为空 / 目标语言为空) TranslateParamInvalid = 5102; //翻译参数不合法(原文为空 / 目标语言为空)
TranslateProviderError = 5103; //翻译服务商调用失败 TranslateProviderError = 5103; //翻译服务商调用失败
//会员与算力相关错误码 5301-5303
ComputeNotEnough = 5301; //算力不足(仅算力闸门开启时返回)
VipRequired = 5302; //需要会员(翻译/会议纪要在 VIP 过期后受限;提示里要同时给出「续费」与「绑定带会员的设备」两条路)
ComputeBalanceNegative = 5303; //后台调整算力后余额将为负,拒绝
} }

52
apps/proto/user/user_msg.proto

@ -99,7 +99,9 @@ message UserBindDeviceResp {
int64 tradetotalintegral = 8; //@go_tags(`gorm:"tradetotalintegral" json:"tradetotalintegral"`) 翻译总积分 int64 tradetotalintegral = 8; //@go_tags(`gorm:"tradetotalintegral" json:"tradetotalintegral"`) 翻译总积分
int64 meetintegral = 9; //@go_tags(`gorm:"meetintegral" json:"meetintegral"`) 会议积分 int64 meetintegral = 9; //@go_tags(`gorm:"meetintegral" json:"meetintegral"`) 会议积分
int64 meettotalintegral = 10; //@go_tags(`gorm:"meettotalintegral" json:"meettotalintegral"`) 会议总积分 int64 meettotalintegral = 10; //@go_tags(`gorm:"meettotalintegral" json:"meettotalintegral"`) 会议总积分
int64 devicevipexptime = 11; //这台设备赋予的 VIP 到期时间(0=不送)
int32 deviceviplevel = 12; //这台设备赋予的 VIP 等级
int64 devicecomputeleft = 13; //这台设备剩余算力
} }
//解绑定耳机请求 //解绑定耳机请求
@ -246,11 +248,55 @@ message UserGetDevicesResp {
//上传资源使用 //上传资源使用
message UserUsagesReq { message UserUsagesReq {
UsageType usagetype = 1; //使用类型 UsageType usagetype = 1; //使用类型
map<string,int64> usages = 2; //用量统计数据 map<string,int64> usages = 2; //用量统计数据(翻译:各模式秒数;会议:MEETING_ASR_AUDIO_SECONDS;AI:可不传,改用下面两项)
int64 upchars = 3; //AI 对话本次输入字符数(Unicode 码点,仅 usagetype=AI 时有效)
int64 downchars = 4; //AI 对话本次输出字符数(同上)
} }
message UserUsagesResp { message UserUsagesResp {
map<int32,int64> usages = 1; //资源剩余使用量 map<int32,int64> usages = 1; //【兼容】旧三桶剩余量,2026-09 起恒为 0,老客户端仍按此字段解析
int64 compute = 2; //本次扣减的算力
int64 computedeviceleft = 3; //当前绑定设备的剩余算力合计
int64 computeuserleft = 4; //用户自有算力余额
int64 over = 5; //超额(闸门关时余额不足仍放行的部分;闸门开时恒为 0)
}
//获取会员与算力状态(客户端「用量」页一次拿齐,客户端不做任何换算)
message UserGetComputeReq {
}
message UserComputeVipInfo {
int32 level = 1; //实际 VIP 等级 = max(自购, 设备赋予);0 = 无 VIP
int64 exptime = 2; //实际到期时间(自购与各设备取最晚)
int64 selfexptime = 3; //自购 VIP 到期时间
repeated UserDeviceVipInfo devices = 4; //各绑定设备赋予的 VIP
}
message UserDeviceVipInfo {
string devicemac = 1;
string devicename = 2;
int32 level = 3;
int64 exptime = 4; //usedtime + vipdays*86400
int64 computegrant = 5; //设备赠送算力总量
int64 computeused = 6; //已消耗
}
message UserComputeInfo {
int64 deviceleft = 1; //各绑定设备剩余算力合计
int64 userbalance = 2; //用户自有余额
int64 totalused = 3; //累计消耗
int64 monthused = 4; //本月消耗
}
message UserComputeRates {
int64 translate = 1; //算力 / 分钟
int64 meeting = 2; //算力 / 分钟
int64 aichat = 3; //算力 / 100 字
bool gate = 4; //算力闸门是否开启
// 运营阈值(后台可配,客户端据此提前提示;0 = 不提示)
int32 vipwarndays = 5; //VIP 到期前多少天开始提示
int64 computewarn = 6; //算力低于多少时开始提示
}
message UserGetComputeResp {
UserComputeVipInfo vip = 1;
UserComputeInfo compute = 2;
UserComputeRates rates = 3;
} }
//获取唤醒词列表 //获取唤醒词列表

1
apps/services/comm/appgoods.go

@ -24,6 +24,7 @@ type AppGoods struct {
Meetnum int64 `gorm:"column:meetnum" json:"meetnum"` // 发放的会议资源点 Meetnum int64 `gorm:"column:meetnum" json:"meetnum"` // 发放的会议资源点
Tradenum int64 `gorm:"column:tradenum" json:"tradenum"` // 发放的翻译资源点 Tradenum int64 `gorm:"column:tradenum" json:"tradenum"` // 发放的翻译资源点
Viptime int32 `gorm:"column:viptime" json:"viptime"` // 发放的会员时长 Viptime int32 `gorm:"column:viptime" json:"viptime"` // 发放的会员时长
Compute int64 `gorm:"column:compute" json:"compute"` // 发放的算力(2026-09 起;为 0 时发货按旧三桶折算,见 pay.grantOfAppGoods)
// 多语言名称/货币/价格,JSON 同 pb.DBGoods.localname;后台读写经 console 的 api_goods.go 结构化收发。 // 多语言名称/货币/价格,JSON 同 pb.DBGoods.localname;后台读写经 console 的 api_goods.go 结构化收发。
Localname string `gorm:"column:localname;type:text" json:"localname"` Localname string `gorm:"column:localname;type:text" json:"localname"`

664
apps/services/comm/compute.go

@ -0,0 +1,664 @@
package comm
import (
"errors"
"sort"
"strconv"
"strings"
"sync"
"time"
"yunyan/lego/sys/mysql"
"yunyan/lego/sys/postgres"
"yunyan/pb"
"yunyan/utils/devcode"
"gorm.io/gorm"
)
// 会员与算力:两层权益的唯一判定与记账入口。
//
// - VIP 管**功能准入**:没有 VIP,翻译与会议纪要不可用;
// - 算力管**用量计量**:翻译 / 会议 / AI 对话的消耗统一折算成算力,先扣设备赠送、再扣用户余额。
//
// 两层的归属规则一致:设备赠送的归设备(解绑即失去、随设备流转),用户自购的归账号。
// 设计文档:docs/会员权益与用量统计-设计与开发文档.md
//
// ⚠️ 需要判 VIP / 扣算力的入口一律调本文件的函数,**别各写一份**——散落的判定过去就出过
// 「设备送的天数累加进用户表、解绑拆不开」的事故(见 ResolveVip 的说明)。
// ─────────────────────────────────────────────────────────────────────────────
// 换算系数:存业务库 config 表(KV),随 user_getappconfig 的 env 下发。
// 客户端拿到系数**只用于本地预估展示**,上报的永远是原始量,换算在服务端做——
// 否则改一次系数就要发一次版,且新老版本算出的数不一致。
// ─────────────────────────────────────────────────────────────────────────────
const (
ConfigKeyComputeRateTranslate = "COMPUTE_RATE_TRANSLATE" // 算力 / 分钟
ConfigKeyComputeRateMeeting = "COMPUTE_RATE_MEETING" // 算力 / 分钟
ConfigKeyComputeRateAIChat = "COMPUTE_RATE_AICHAT" // 算力 / 100 字
ConfigKeyComputeGate = "COMPUTE_GATE" // 1 = 余额不足即拒绝;0(默认)= 只记账不拦
// 运营参数(后台「会员与算力」页可配,改了不用发版)
ConfigKeyNewUserGiftVipDays = "NEWUSER_GIFT_VIPDAYS" // 开户礼 VIP 天数
ConfigKeyNewUserGiftCompute = "NEWUSER_GIFT_COMPUTE" // 开户礼算力
ConfigKeyVipWarnDays = "VIP_WARN_DAYS" // VIP 到期前多少天开始提示(0=不提示)
ConfigKeyComputeWarn = "COMPUTE_WARN" // 算力低于多少开始提示(0=不提示)
)
// 运营参数默认值。写成常量而不是 0:配置表还没建这几行时(新部署、或运营没填过),
// 取 0 会变成「开户礼什么都不送、永不提示」,那是静默的功能缺失而不是合理的默认。
const (
DefaultNewUserGiftVipDays = 30
DefaultNewUserGiftCompute = 100
DefaultVipWarnDays = 7
DefaultComputeWarn = 100
)
// OpsParams 一份生效中的运营参数。
type OpsParams struct {
NewUserGiftVipDays int32
NewUserGiftCompute int64
VipWarnDays int32
ComputeWarn int64
}
// DefaultOpsParams 未配置时的取值。
func DefaultOpsParams() OpsParams {
return OpsParams{
NewUserGiftVipDays: DefaultNewUserGiftVipDays,
NewUserGiftCompute: DefaultNewUserGiftCompute,
VipWarnDays: DefaultVipWarnDays,
ComputeWarn: DefaultComputeWarn,
}
}
// ParseOpsParams 从 KV 解析;缺省回默认值。
//
// ⚠️ 与换算系数不同,这四项**允许配 0**:0 是「不送 / 不提示」的合法表达。
// 所以只有「键不存在」或「解析不出数字」才回默认,解析出 0 就用 0。
func ParseOpsParams(kv map[string]string) OpsParams {
p := DefaultOpsParams()
pick := func(key string, set func(int64)) {
if v, ok := kv[key]; ok {
if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && n >= 0 {
set(n)
}
}
}
pick(ConfigKeyNewUserGiftVipDays, func(n int64) { p.NewUserGiftVipDays = int32(n) })
pick(ConfigKeyNewUserGiftCompute, func(n int64) { p.NewUserGiftCompute = n })
pick(ConfigKeyVipWarnDays, func(n int64) { p.VipWarnDays = int32(n) })
pick(ConfigKeyComputeWarn, func(n int64) { p.ComputeWarn = n })
return p
}
// ToKV 反向:给后台读写用。
func (p OpsParams) ToKV() map[string]string {
return map[string]string{
ConfigKeyNewUserGiftVipDays: strconv.FormatInt(int64(p.NewUserGiftVipDays), 10),
ConfigKeyNewUserGiftCompute: strconv.FormatInt(p.NewUserGiftCompute, 10),
ConfigKeyVipWarnDays: strconv.FormatInt(int64(p.VipWarnDays), 10),
ConfigKeyComputeWarn: strconv.FormatInt(p.ComputeWarn, 10),
}
}
var opsParamsCache struct {
sync.Mutex
at time.Time
params OpsParams
}
// LoadOpsParams 读取生效中的运营参数(业务库 config 表),缓存同换算系数。
func LoadOpsParams() OpsParams {
opsParamsCache.Lock()
defer opsParamsCache.Unlock()
if !opsParamsCache.at.IsZero() && time.Since(opsParamsCache.at) < computeRatesTTL {
return opsParamsCache.params
}
items := make([]*pb.DBAppConfigItem, 0, 4)
keys := []string{ConfigKeyNewUserGiftVipDays, ConfigKeyNewUserGiftCompute, ConfigKeyVipWarnDays, ConfigKeyComputeWarn}
kv := map[string]string{}
if err := mysql.Find(TableAppConfig, &items, "`key` IN ?", keys); err == nil {
for _, it := range items {
kv[it.Key] = it.Value
}
}
opsParamsCache.params = ParseOpsParams(kv)
opsParamsCache.at = time.Now()
return opsParamsCache.params
}
// InvalidateOpsParams 让下一次 LoadOpsParams 重新读库。
func InvalidateOpsParams() {
opsParamsCache.Lock()
opsParamsCache.at = time.Time{}
opsParamsCache.Unlock()
}
// ComputeRates 一份生效中的换算系数。
type ComputeRates struct {
Translate int64
Meeting int64
AIChat int64
Gate bool
}
// DefaultComputeRates 三个系数默认全 1:1 分钟翻译 = 1 分钟会议 = 100 字对话 = 1 算力;闸门默认关。
func DefaultComputeRates() ComputeRates {
return ComputeRates{Translate: 1, Meeting: 1, AIChat: 1, Gate: false}
}
// ParseComputeRates 从 env 形式的 KV 解析系数;缺省或非法一律回默认值,不让一个写坏的配置把计费打到 0。
func ParseComputeRates(kv map[string]string) ComputeRates {
r := DefaultComputeRates()
pick := func(key string, dst *int64) {
if v, ok := kv[key]; ok {
if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && n > 0 {
*dst = n
}
}
}
pick(ConfigKeyComputeRateTranslate, &r.Translate)
pick(ConfigKeyComputeRateMeeting, &r.Meeting)
pick(ConfigKeyComputeRateAIChat, &r.AIChat)
if v, ok := kv[ConfigKeyComputeGate]; ok {
v = strings.TrimSpace(strings.ToLower(v))
r.Gate = v == "1" || v == "true" || v == "on"
}
return r
}
// ToKV 反向:给后台读写与下发用。
func (r ComputeRates) ToKV() map[string]string {
gate := "0"
if r.Gate {
gate = "1"
}
return map[string]string{
ConfigKeyComputeRateTranslate: strconv.FormatInt(r.Translate, 10),
ConfigKeyComputeRateMeeting: strconv.FormatInt(r.Meeting, 10),
ConfigKeyComputeRateAIChat: strconv.FormatInt(r.AIChat, 10),
ConfigKeyComputeGate: gate,
}
}
var computeRatesCache struct {
sync.Mutex
at time.Time
rates ComputeRates
}
// computeRatesTTL 读库缓存时长。改系数后最多这么久生效;后台保存时会调 InvalidateComputeRates 立即刷新本进程。
const computeRatesTTL = 30 * time.Second
// LoadComputeRates 读取生效中的换算系数(业务库 config 表)。读失败回默认值——计费不能因为配置表抖动而中断。
func LoadComputeRates() ComputeRates {
computeRatesCache.Lock()
defer computeRatesCache.Unlock()
if !computeRatesCache.at.IsZero() && time.Since(computeRatesCache.at) < computeRatesTTL {
return computeRatesCache.rates
}
items := make([]*pb.DBAppConfigItem, 0, 4)
keys := []string{ConfigKeyComputeRateTranslate, ConfigKeyComputeRateMeeting, ConfigKeyComputeRateAIChat, ConfigKeyComputeGate}
kv := map[string]string{}
// `key` 是 MySQL 保留字,必须反引号。
if err := mysql.Find(TableAppConfig, &items, "`key` IN ?", keys); err == nil {
for _, it := range items {
kv[it.Key] = it.Value
}
}
computeRatesCache.rates = ParseComputeRates(kv)
computeRatesCache.at = time.Now()
return computeRatesCache.rates
}
// InvalidateComputeRates 让下一次 LoadComputeRates 重新读库。
func InvalidateComputeRates() {
computeRatesCache.Lock()
computeRatesCache.at = time.Time{}
computeRatesCache.Unlock()
}
// SaveComputeRates 把系数写回 config 表(有则改、无则建),并使本进程缓存失效。
// 其它进程靠 computeRatesTTL 自然过期,或由调用方广播 Rpc_ModifyAppConifg。
func SaveComputeRates(r ComputeRates) error {
if r.Translate <= 0 || r.Meeting <= 0 || r.AIChat <= 0 {
return errors.New("换算系数必须大于 0")
}
for k, v := range r.ToKV() {
item := &pb.DBAppConfigItem{}
err := mysql.FindOne(TableAppConfig, item, "`key`=?", k)
if err != nil && err != mysql.ErrNoDocuments {
return err
}
if err == mysql.ErrNoDocuments {
item = &pb.DBAppConfigItem{Group: "compute", Key: k, Value: v, Description: computeKeyDesc(k)}
if err = mysql.Insert(TableAppConfig, item); err != nil {
return err
}
continue
}
if item.Value != v {
if err = mysql.Table(TableAppConfig).Where("`key`=?", k).Update("value", v).Error; err != nil {
return err
}
}
}
InvalidateComputeRates()
return nil
}
func computeKeyDesc(k string) string {
switch k {
case ConfigKeyComputeRateTranslate:
return "算力换算:翻译,算力/分钟"
case ConfigKeyComputeRateMeeting:
return "算力换算:会议纪要,算力/分钟"
case ConfigKeyComputeRateAIChat:
return "算力换算:AI对话,算力/100字"
case ConfigKeyComputeGate:
return "算力闸门:1=余额不足即拒绝,0=只记账不拦(开启前须先上线预扣)"
case ConfigKeyNewUserGiftVipDays:
return "开户礼:赠送VIP天数(0=不送)"
case ConfigKeyNewUserGiftCompute:
return "开户礼:赠送算力(0=不送)"
case ConfigKeyVipWarnDays:
return "提示阈值:VIP到期前多少天开始提示(0=不提示)"
case ConfigKeyComputeWarn:
return "提示阈值:算力低于多少开始提示(0=不提示)"
}
return ""
}
func ceilDiv(a, b int64) int64 {
if a <= 0 || b <= 0 {
return 0
}
return (a + b - 1) / b
}
// ForTranslate 翻译时长(秒)→ 算力。按会话累计后向上取整到分钟,单次最低 1。
func (r ComputeRates) ForTranslate(sec int64) int64 {
if sec <= 0 {
return 0
}
if c := ceilDiv(sec, 60) * r.Translate; c > 0 {
return c
}
return 1
}
// ForMeeting 会议时长(秒)→ 算力。
func (r ComputeRates) ForMeeting(sec int64) int64 {
if sec <= 0 {
return 0
}
if c := ceilDiv(sec, 60) * r.Meeting; c > 0 {
return c
}
return 1
}
// ForAIChat 对话字符数(输入 + 输出,Unicode 码点)→ 算力。每 100 字向上取整,单次最低 1。
func (r ComputeRates) ForAIChat(chars int64) int64 {
if chars <= 0 {
return 0
}
if c := ceilDiv(chars, 100) * r.AIChat; c > 0 {
return c
}
return 1
}
// ─────────────────────────────────────────────────────────────────────────────
// 绑定设备:VIP 与设备算力都挂在 device_mac 行上,按「当前绑定关系」取。
// 绑定关系在 userdevice(业务库 MySQL),设备行在 device_mac(console 主库 Postgres),
// 两库之间只能靠 MAC 对;device_mac.uid 只是台账、解绑时尽力清理,不拿它当绑定判据。
// ─────────────────────────────────────────────────────────────────────────────
// BoundFactoryDevices 取用户当前绑定的全部设备行(已被后台禁用的不算)。
// 找不到对应设备行的绑定记录静默跳过——那是台账不一致,不该让整次判定失败。
func BoundFactoryDevices(uid string) ([]*pb.DBAuthCode, error) {
uds := make([]*pb.DBUserDivice, 0, 2)
if err := mysql.Find(TableUserdevice, &uds, "uid=?", uid); err != nil && err != mysql.ErrNoDocuments {
return nil, err
}
out := make([]*pb.DBAuthCode, 0, len(uds))
seen := map[string]bool{}
for _, ud := range uds {
mac := strings.TrimSpace(ud.Devicemac)
if mac == "" {
continue
}
if n, ok := devcode.NormalizeMac(mac); ok {
mac = n
}
if seen[mac] {
continue
}
seen[mac] = true
d := &pb.DBAuthCode{}
if err := postgres.FindOne(TableDeviceMac, d, "devicemac=?", mac); err != nil {
continue
}
if d.Disabled != 0 {
continue
}
out = append(out, d)
}
return out, nil
}
// DeviceVipExpire 这台设备赋予的 VIP 到期时间:激活日 + vipdays 天,按日历天连续走、解绑不暂停。
// 未激活(usedtime=0)或不送 VIP(vipdays=0)返回 0。
func DeviceVipExpire(d *pb.DBAuthCode) int64 {
if d == nil || d.Usedtime <= 0 || d.Vipdays <= 0 {
return 0
}
return d.Usedtime + int64(d.Vipdays)*86400
}
// DeviceVipLevel 设备赋予的等级;配了天数但没配等级视为 1。
func DeviceVipLevel(d *pb.DBAuthCode) int32 {
if d == nil || d.Vipdays <= 0 {
return 0
}
if d.Viplevel <= 0 {
return 1
}
return d.Viplevel
}
// DeviceComputeLeft 设备赠送算力剩余。
func DeviceComputeLeft(d *pb.DBAuthCode) int64 {
if d == nil {
return 0
}
if left := d.Computegrant - d.Computeused; left > 0 {
return left
}
return 0
}
// ─────────────────────────────────────────────────────────────────────────────
// VIP 判定
// ─────────────────────────────────────────────────────────────────────────────
// VipStatus 合并后的会员状态。
type VipStatus struct {
Level int32 // 生效等级 = max(自购, 各绑定设备);0 = 当前无 VIP
ExpTime int64 // 生效到期时间(各来源取最晚);无 VIP 时为各来源中最晚的历史到期,便于显示「已于 x 到期」
SelfExpTime int64 // 自购 VIP 到期时间(user.vipexptime,只记自购)
FromDevice bool // 当前生效的 VIP 是否来自设备(用于展示来源)
}
// Active 此刻是否有 VIP。
func (v VipStatus) Active(now int64) bool { return v.Level > 0 && v.ExpTime > now }
// ResolveVip 合并自购与设备赋予的 VIP。
//
// 判定 = 自购未过期 **或** (当前绑定着某设备 且 该设备 VIP 期未结束)。任一成立即有 VIP。
//
// ⚠️ 设备侧**不写用户表**:user.vipexptime 只记自购。以前是把赠送天数直接累加进去,
// 一旦累加就与自购混成同一个数字,解绑时无法拆分该扣回多少天。现在设备 VIP 由
// device_mac 行的 usedtime + vipdays 算出,解绑时什么都不用做——不绑了自然就不享有了。
func ResolveVip(user *pb.DBUser, devices []*pb.DBAuthCode, now int64) VipStatus {
v := VipStatus{}
if user != nil {
v.SelfExpTime = user.Vipexptime
}
// 候选:每个来源一条 (level, exp, fromDevice)。先挑生效中的,按 level 高、exp 晚取;
// 全部过期时只记最晚的历史到期,Level 保持 0。
type cand struct {
level int32
exp int64
fromDevice bool
}
cands := make([]cand, 0, 1+len(devices))
if user != nil && user.Vipexptime > 0 {
lv := user.Viplv
if lv <= 0 {
lv = 1
}
cands = append(cands, cand{level: lv, exp: user.Vipexptime})
}
for _, d := range devices {
if exp := DeviceVipExpire(d); exp > 0 {
cands = append(cands, cand{level: DeviceVipLevel(d), exp: exp, fromDevice: true})
}
}
var latestExpired int64
for _, c := range cands {
if c.exp <= now {
if c.exp > latestExpired {
latestExpired = c.exp
}
continue
}
better := v.Level == 0 || c.level > v.Level || (c.level == v.Level && c.exp > v.ExpTime)
if better {
v.Level, v.ExpTime, v.FromDevice = c.level, c.exp, c.fromDevice
}
}
if v.Level == 0 {
v.ExpTime = latestExpired
}
return v
}
// ResolveUserVip 按 uid 一步到位:读用户与其绑定设备后合并。user 可传 nil 由本函数读取。
func ResolveUserVip(uid string, user *pb.DBUser) (VipStatus, []*pb.DBAuthCode, error) {
if user == nil {
user = &pb.DBUser{}
if err := mysql.FindOne(TableUser, user, "uid=?", uid); err != nil {
return VipStatus{}, nil, err
}
}
devices, err := BoundFactoryDevices(uid)
if err != nil {
return VipStatus{}, nil, err
}
return ResolveVip(user, devices, time.Now().Unix()), devices, nil
}
// ─────────────────────────────────────────────────────────────────────────────
// 算力扣减
// ─────────────────────────────────────────────────────────────────────────────
// ComputeUsageKind 消耗类型,决定用哪个换算系数。
type ComputeUsageKind int
const (
ComputeUsageTranslate ComputeUsageKind = iota + 1
ComputeUsageMeeting
ComputeUsageAIChat
)
// ErrComputeNotEnough 闸门开启且余额不足。
var ErrComputeNotEnough = errors.New("compute not enough")
// ErrComputeBalanceNegative 调整后余额将为负。
var ErrComputeBalanceNegative = errors.New("compute balance would be negative")
// ComputeResult 一次扣减的结果。
type ComputeResult struct {
Compute int64 // 本次折算出的算力
Device int64 // 从设备赠送额度扣的部分
User int64 // 从用户余额扣的部分
Over int64 // 超额(闸门关时余额不足仍放行的量;闸门开时恒为 0)
DeviceMac string // 扣了哪台设备(多台以逗号分隔)
DeviceLeft int64 // 扣后各绑定设备剩余合计
UserLeft int64 // 扣后用户余额
Rejected bool // 闸门开启且不足,已拒绝、未写库
}
type deviceCut struct {
d *pb.DBAuthCode
n int64
}
// planComputeDeduction 决定从哪里扣多少:先扣设备(剩余最少的先扣,用完一台再动下一台),再扣用户余额,剩下算超额。
func planComputeDeduction(need int64, devices []*pb.DBAuthCode, userBalance int64) (cuts []deviceCut, fromUser, over int64) {
rem := need
sorted := make([]*pb.DBAuthCode, 0, len(devices))
for _, d := range devices {
if DeviceComputeLeft(d) > 0 {
sorted = append(sorted, d)
}
}
sort.SliceStable(sorted, func(i, j int) bool { return DeviceComputeLeft(sorted[i]) < DeviceComputeLeft(sorted[j]) })
for _, d := range sorted {
if rem <= 0 {
break
}
n := DeviceComputeLeft(d)
if n > rem {
n = rem
}
cuts = append(cuts, deviceCut{d: d, n: n})
rem -= n
}
if rem > 0 && userBalance > 0 {
fromUser = userBalance
if fromUser > rem {
fromUser = rem
}
rem -= fromUser
}
over = rem
return
}
// ApplyComputeUsage 一次用量的算力记账:换算 → 先设备后用户扣减 → 闸门判定 → 原子写库 → 回填流水与统计。
//
// - raw:原始量(翻译/会议为秒,AI 为输入+输出字符数);
// - userlog:调用方已填好原始字段的流水行,本函数只补算力五列,**由调用方负责 Insert**;
// - stats:调用方持有的统计行,本函数累加 Computetotal,**由调用方负责保存**。
//
// 闸门关(默认):余额不够也放行,差额记进 Over。闸门开:不够则 Rejected=true 且不写任何东西。
//
// 设备扣减用 `computeused = computeused + n` 原子更新、用户余额用 `computebalance - n`,
// 不走读-改-写,避免并发上报把余额算飞。
func ApplyComputeUsage(uid string, kind ComputeUsageKind, raw int64, userlog *pb.DBUserUseLog, stats *pb.DBUserStatistics) (res ComputeResult, err error) {
rates := LoadComputeRates()
switch kind {
case ComputeUsageTranslate:
res.Compute = rates.ForTranslate(raw)
case ComputeUsageMeeting:
res.Compute = rates.ForMeeting(raw)
case ComputeUsageAIChat:
res.Compute = rates.ForAIChat(raw)
}
user := &pb.DBUser{}
if err = mysql.FindOne(TableUser, user, "uid=?", uid); err != nil {
return
}
devices, derr := BoundFactoryDevices(uid)
if derr != nil {
err = derr
return
}
if res.Compute == 0 {
res.UserLeft = user.Computebalance
for _, d := range devices {
res.DeviceLeft += DeviceComputeLeft(d)
}
return
}
cuts, fromUser, over := planComputeDeduction(res.Compute, devices, user.Computebalance)
if rates.Gate && over > 0 {
res.Rejected = true
res.Over = over
res.UserLeft = user.Computebalance
for _, d := range devices {
res.DeviceLeft += DeviceComputeLeft(d)
}
return
}
macs := make([]string, 0, len(cuts))
for _, c := range cuts {
if err = postgres.Table(TableDeviceMac).Where("code=?", c.d.Code).
Update("computeused", gorm.Expr("computeused + ?", c.n)).Error; err != nil {
return
}
c.d.Computeused += c.n
res.Device += c.n
macs = append(macs, c.d.Devicemac)
}
if fromUser > 0 {
if err = mysql.Table(TableUser).Where("uid=?", uid).
Update("computebalance", gorm.Expr("computebalance - ?", fromUser)).Error; err != nil {
return
}
user.Computebalance -= fromUser
res.User = fromUser
}
res.Over = over
res.DeviceMac = strings.Join(macs, ",")
res.UserLeft = user.Computebalance
for _, d := range devices {
res.DeviceLeft += DeviceComputeLeft(d)
}
if userlog != nil {
userlog.Compute = -res.Compute
userlog.Computedevice = res.Device
userlog.Computeuser = res.User
userlog.Computeover = res.Over
userlog.Devicemac = res.DeviceMac
}
if stats != nil {
stats.Computetotal += res.Compute
}
return
}
// AdjustUserCompute 后台调整用户算力余额(delta 可负),余额不得低于 0;同时写一条 AdminAdjust 流水。
func AdjustUserCompute(uid string, delta int64, remark string) (newBalance int64, err error) {
user := &pb.DBUser{}
if err = mysql.FindOne(TableUser, user, "uid=?", uid); err != nil {
return
}
if user.Computebalance+delta < 0 {
err = ErrComputeBalanceNegative
return
}
if err = mysql.Table(TableUser).Where("uid=?", uid).
Update("computebalance", gorm.Expr("computebalance + ?", delta)).Error; err != nil {
return
}
newBalance = user.Computebalance + delta
_ = mysql.Insert(TableUserUseLog, &pb.DBUserUseLog{
Uid: uid,
Ts: time.Now().Unix(),
Logtype: pb.UserLogType_AdminAdjust,
Compute: delta,
Extra: remark,
})
return
}
// GrantUserCompute 发放用户算力(支付发货 / 开户礼等),delta 必须 > 0。流水由调用方写(它们各有自己的 logtype 与 extra)。
func GrantUserCompute(uid string, delta int64) error {
if delta <= 0 {
return nil
}
return mysql.Table(TableUser).Where("uid=?", uid).
Update("computebalance", gorm.Expr("computebalance + ?", delta)).Error
}
// LegacyBucketsToCompute 把旧三桶的量折算成算力:翻译秒 / 会议秒按分钟向上取整 × 系数,AI 次数 1:1。
// 用于:存量余额迁移、老商品(只配了 ainum/meetnum/tradenum 没配 compute)发货、公码与后台赠送的旧字段。
func LegacyBucketsToCompute(r ComputeRates, tradeSec, meetSec, aiCount int64) int64 {
var c int64
if tradeSec > 0 {
c += ceilDiv(tradeSec, 60) * r.Translate
}
if meetSec > 0 {
c += ceilDiv(meetSec, 60) * r.Meeting
}
if aiCount > 0 {
c += aiCount
}
return c
}

172
apps/services/comm/compute_test.go

@ -0,0 +1,172 @@
package comm
import (
"testing"
"yunyan/pb"
)
func TestComputeRates_Conversion(t *testing.T) {
r := DefaultComputeRates()
cases := []struct {
name string
got int64
want int64
}{
{"翻译 0 秒不计", r.ForTranslate(0), 0},
{"翻译 1 秒向上取整到 1 分钟", r.ForTranslate(1), 1},
{"翻译 60 秒 = 1", r.ForTranslate(60), 1},
{"翻译 61 秒 = 2", r.ForTranslate(61), 2},
{"会议 3600 秒 = 60", r.ForMeeting(3600), 60},
{"对话 1 字最低 1", r.ForAIChat(1), 1},
{"对话 100 字 = 1", r.ForAIChat(100), 1},
{"对话 101 字 = 2", r.ForAIChat(101), 2},
}
for _, c := range cases {
if c.got != c.want {
t.Errorf("%s: got %d want %d", c.name, c.got, c.want)
}
}
r2 := ComputeRates{Translate: 3, Meeting: 2, AIChat: 5}
if got := r2.ForTranslate(90); got != 6 {
t.Errorf("系数 3 × 2 分钟应为 6,got %d", got)
}
if got := r2.ForAIChat(250); got != 15 {
t.Errorf("系数 5 × 3 段应为 15,got %d", got)
}
}
func TestParseComputeRates_BadValuesFallBack(t *testing.T) {
r := ParseComputeRates(map[string]string{
ConfigKeyComputeRateTranslate: "0", // 非法 → 默认 1
ConfigKeyComputeRateMeeting: "abc", // 非法 → 默认 1
ConfigKeyComputeRateAIChat: "4",
ConfigKeyComputeGate: "1",
})
if r.Translate != 1 || r.Meeting != 1 || r.AIChat != 4 || !r.Gate {
t.Fatalf("unexpected %+v", r)
}
if r := ParseComputeRates(nil); r != DefaultComputeRates() {
t.Fatalf("空配置应回默认,got %+v", r)
}
}
func TestResolveVip(t *testing.T) {
now := int64(2_000_000_000) // 2033 年,减几十天也不会跌成负数(Usedtime<=0 视为未激活)
day := int64(86400)
dev := func(used int64, days, level int32) *pb.DBAuthCode {
return &pb.DBAuthCode{Code: "AA", Devicemac: "AA", Usedtime: used, Vipdays: days, Viplevel: level}
}
t.Run("无任何来源", func(t *testing.T) {
v := ResolveVip(&pb.DBUser{}, nil, now)
if v.Active(now) || v.Level != 0 {
t.Fatalf("%+v", v)
}
})
t.Run("只有自购且有效", func(t *testing.T) {
v := ResolveVip(&pb.DBUser{Viplv: 1, Vipexptime: now + day}, nil, now)
if !v.Active(now) || v.FromDevice || v.ExpTime != now+day {
t.Fatalf("%+v", v)
}
})
t.Run("自购过期、设备在期 → 来自设备", func(t *testing.T) {
v := ResolveVip(&pb.DBUser{Vipexptime: now - day}, []*pb.DBAuthCode{dev(now-10*day, 30, 1)}, now)
if !v.Active(now) || !v.FromDevice || v.ExpTime != now-10*day+30*day {
t.Fatalf("%+v", v)
}
})
t.Run("设备未激活不算", func(t *testing.T) {
v := ResolveVip(&pb.DBUser{}, []*pb.DBAuthCode{dev(0, 30, 1)}, now)
if v.Active(now) {
t.Fatalf("%+v", v)
}
})
t.Run("设备 VIP 期按日历天连续走,解绑不暂停:激活 40 天前送 30 天 → 已过期", func(t *testing.T) {
v := ResolveVip(&pb.DBUser{}, []*pb.DBAuthCode{dev(now-40*day, 30, 1)}, now)
if v.Active(now) {
t.Fatalf("%+v", v)
}
if v.ExpTime != now-10*day {
t.Fatalf("过期时仍应给出历史到期时间, %+v", v)
}
})
t.Run("两者都有效:等级高的赢;同级取最晚", func(t *testing.T) {
v := ResolveVip(&pb.DBUser{Viplv: 1, Vipexptime: now + 100*day}, []*pb.DBAuthCode{dev(now-day, 10, 2)}, now)
if v.Level != 2 || !v.FromDevice {
t.Fatalf("设备是 VIP-Pro 应优先, %+v", v)
}
v = ResolveVip(&pb.DBUser{Viplv: 1, Vipexptime: now + 100*day}, []*pb.DBAuthCode{dev(now-day, 10, 1)}, now)
if v.Level != 1 || v.FromDevice || v.ExpTime != now+100*day {
t.Fatalf("同级取到期最晚的自购, %+v", v)
}
})
t.Run("设备配了天数没配等级视为 1", func(t *testing.T) {
v := ResolveVip(&pb.DBUser{}, []*pb.DBAuthCode{dev(now-day, 10, 0)}, now)
if v.Level != 1 {
t.Fatalf("%+v", v)
}
})
}
func TestPlanComputeDeduction(t *testing.T) {
d := func(mac string, grant, used int64) *pb.DBAuthCode {
return &pb.DBAuthCode{Code: mac, Devicemac: mac, Computegrant: grant, Computeused: used}
}
t.Run("先扣设备,剩余最少的先扣", func(t *testing.T) {
cuts, user, over := planComputeDeduction(7, []*pb.DBAuthCode{d("big", 100, 0), d("small", 10, 7)}, 50)
if len(cuts) != 2 || cuts[0].d.Code != "small" || cuts[0].n != 3 || cuts[1].n != 4 || user != 0 || over != 0 {
t.Fatalf("cuts=%+v user=%d over=%d", cuts, user, over)
}
})
t.Run("设备不够扣用户", func(t *testing.T) {
cuts, user, over := planComputeDeduction(10, []*pb.DBAuthCode{d("a", 4, 0)}, 50)
if len(cuts) != 1 || cuts[0].n != 4 || user != 6 || over != 0 {
t.Fatalf("cuts=%+v user=%d over=%d", cuts, user, over)
}
})
t.Run("都不够记超额", func(t *testing.T) {
cuts, user, over := planComputeDeduction(10, []*pb.DBAuthCode{d("a", 4, 0)}, 2)
if len(cuts) != 1 || user != 2 || over != 4 {
t.Fatalf("cuts=%+v user=%d over=%d", cuts, user, over)
}
})
t.Run("用完的设备跳过", func(t *testing.T) {
cuts, user, _ := planComputeDeduction(1, []*pb.DBAuthCode{d("a", 4, 4)}, 5)
if len(cuts) != 0 || user != 1 {
t.Fatalf("cuts=%+v user=%d", cuts, user)
}
})
}
func TestParseOpsParams(t *testing.T) {
t.Run("空配置回默认", func(t *testing.T) {
if got := ParseOpsParams(nil); got != DefaultOpsParams() {
t.Fatalf("got %+v", got)
}
})
t.Run("0 是合法值,不回落默认", func(t *testing.T) {
got := ParseOpsParams(map[string]string{
ConfigKeyNewUserGiftVipDays: "0",
ConfigKeyNewUserGiftCompute: "0",
ConfigKeyVipWarnDays: "0",
ConfigKeyComputeWarn: "0",
})
if got.NewUserGiftVipDays != 0 || got.NewUserGiftCompute != 0 || got.VipWarnDays != 0 || got.ComputeWarn != 0 {
t.Fatalf("配 0 应保持 0(= 不送 / 不提示),got %+v", got)
}
})
t.Run("非法值回默认", func(t *testing.T) {
got := ParseOpsParams(map[string]string{
ConfigKeyNewUserGiftVipDays: "abc",
ConfigKeyNewUserGiftCompute: "-5",
})
if got.NewUserGiftVipDays != DefaultNewUserGiftVipDays || got.NewUserGiftCompute != DefaultNewUserGiftCompute {
t.Fatalf("got %+v", got)
}
})
t.Run("往返一致", func(t *testing.T) {
in := OpsParams{NewUserGiftVipDays: 15, NewUserGiftCompute: 250, VipWarnDays: 3, ComputeWarn: 50}
if got := ParseOpsParams(in.ToKV()); got != in {
t.Fatalf("got %+v want %+v", got, in)
}
})
}

5
apps/services/comm/svcpool.go

@ -162,6 +162,11 @@ func ResolveSvcPlainFields(svc *ThirdSvcConfig, ovr *SvcRegionOverride, encKey s
// 只是客户端现在不直接消费第三方服务下发,改由服务端代调,凭据因此不出网关。 // 只是客户端现在不直接消费第三方服务下发,改由服务端代调,凭据因此不出网关。
const SvcCatMT int32 = 3 const SvcCatMT int32 = 3
// SvcCatAST 端到端实时翻译(通话翻译走的阿里 livetranslate 等,与 admin 前端 SCAT 的 4 一致)。
// 客户端拿着它的凭据**直连服务商**,翻译过程不经过我们的服务器——VIP 过期后唯一的服务端手段
// 就是不下发这一类凭据(见 modules/user 的 filterSvcsByVip)。
const SvcCatAST int32 = 4
// SvcCatIdVerify 身份证实名核验(阿里云 Id2MetaVerify / 腾讯云 IdCardVerification)。 // SvcCatIdVerify 身份证实名核验(阿里云 Id2MetaVerify / 腾讯云 IdCardVerification)。
// 服务端专用——凭据是云账号主 AK/SK,绝不下发客户端。 // 服务端专用——凭据是云账号主 AK/SK,绝不下发客户端。
const SvcCatIdVerify int32 = 11 const SvcCatIdVerify int32 = 11

BIN
apps/services/lego/.DS_Store

Binary file not shown.

BIN
apps/services/modules/.DS_Store

Binary file not shown.

19
apps/services/modules/api/api_dispatchresource.go

@ -72,20 +72,11 @@ func (this *apiComp) DispatchResource(session comm.IUserSession, req *pb.ApiDisp
} }
userlog.Addvipday = addVipDay userlog.Addvipday = addVipDay
} }
if addAichat > 0 { // 2026-09 起三个额度桶停用:派发表单上的 AI 次 / 翻译分 / 会议分按当前换算系数折成算力发到用户余额。
user.Aichatintegral += addAichat // 管理员资源池仍按旧单位扣(池是后台侧的账,与用户侧计量无关)。
user.Aichattotalintegral += addAichat if addCompute := comm.LegacyBucketsToCompute(comm.LoadComputeRates(), addTradeSec, addMeetSec, addAichat); addCompute > 0 {
userlog.Addagentintegral = addAichat user.Computebalance += addCompute
} userlog.Compute = addCompute
if addTradeSec > 0 {
user.Tradeintegral += addTradeSec
user.Tradetotalintegral += addTradeSec
userlog.Addtradesecond = addTradeSec
}
if addMeetSec > 0 {
user.Meetintegral += addMeetSec
user.Meettotalintegral += addMeetSec
userlog.Addmeetsecond = addMeetSec
} }
if err = this.module.model.updateuser(user); err != nil { if err = this.module.model.updateuser(user); err != nil {
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()} errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}

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

@ -106,6 +106,15 @@ func (this *modelComp) Init(service core.IService, module core.IModule, comp cor
// ⚠️ 不叫 factoryid:DBProduct 的 factoryid 是老客户端兼容字段(gorm:"-",值同 brandid),同名会打架。 // ⚠️ 不叫 factoryid:DBProduct 的 factoryid 是老客户端兼容字段(gorm:"-",值同 brandid),同名会打架。
"ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS oemfactoryid bigint DEFAULT 0", "ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS oemfactoryid bigint DEFAULT 0",
"CREATE INDEX IF NOT EXISTS idx_product_providerid ON " + comm.TableProduct + " (providerid)", "CREATE INDEX IF NOT EXISTS idx_product_providerid ON " + comm.TableProduct + " (providerid)",
// devicetype 已于 2026-09-08 废弃并删列(同 console 的 ensureDeviceTables):
// 服务端无逻辑读它,后台表单已去掉。pb 里的 Devicetype 改成了 gorm:"-" 的老客户端兼容
// 字段(恒为 0,只为老 App 包能解析 user_getappconfig),新库不会建这一列。
// ⚠️ 不可逆:执行后存量列里的历史取值即消失。该值不参与任何判断,故不做备份。
"ALTER TABLE " + comm.TableProduct + " DROP COLUMN IF EXISTS devicetype",
// scanuuid 同上(2026-09-08 废弃):设备校验与绑定奖励都已改成按 MAC 判定,
// pb 里的 Scanuuid 是 gorm:"-" 的老客户端兼容字段,新库不会建这一列。
// ⚠️ 不可逆:执行后存量列里的历史取值即消失。
"ALTER TABLE " + comm.TableProduct + " DROP COLUMN IF EXISTS scanuuid",
} { } {
if res := postgres.Exec(ddl); res.Error != nil { if res := postgres.Exec(ddl); res.Error != nil {
this.module.Errorln(res.Error) this.module.Errorln(res.Error)

427
apps/services/modules/console/api_compute.go

@ -0,0 +1,427 @@
package console
import (
"errors"
"fmt"
"strings"
"time"
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/lego/sys/mysql"
"yunyan/pb"
"yunyan/utils/devcode"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// 会员与算力 · 后台接口(设计文档:docs/会员权益与用量统计-设计与开发文档.md §6/§7)。
//
// 所有接口都按选中应用(X-App-Id)直连其业务库:换算系数存业务库 config 表(随 user_getappconfig 的 env
// 下发给客户端,客户端仅用于本地预估展示);用户算力余额与流水在业务库 user / useruselog。
// 设备赠送的 VIP / 算力挂在 console 主库的 device_mac 行上,用户详情里跨库拼出来。
//
// comm.ApplyComputeUsage / AdjustUserCompute 用的是业务进程自己的全局 mysql 连接,console 这边
// 拿到的是按应用取出的连接对象,所以余额调整逻辑在这里按 conn 重写了一份(语义完全一致)。
var computeConfigKeys = []string{
comm.ConfigKeyComputeRateTranslate,
comm.ConfigKeyComputeRateMeeting,
comm.ConfigKeyComputeRateAIChat,
comm.ConfigKeyComputeGate,
comm.ConfigKeyNewUserGiftVipDays,
comm.ConfigKeyNewUserGiftCompute,
comm.ConfigKeyVipWarnDays,
comm.ConfigKeyComputeWarn,
}
// computeRatesView 与前端约定的形状:换算系数 + 闸门 + 运营参数,一次读写。
type computeRatesView struct {
Translate int64 `json:"translate"` // 算力 / 分钟
Meeting int64 `json:"meeting"` // 算力 / 分钟
Aichat int64 `json:"aichat"` // 算力 / 100 字
Gate bool `json:"gate"` // 算力闸门
// 运营参数:允许配 0(= 不送 / 不提示),所以校验用 >= 0 而不是 > 0。
NewUserGiftVipDays int32 `json:"newuser_gift_vipdays"` // 开户礼 VIP 天数
NewUserGiftCompute int64 `json:"newuser_gift_compute"` // 开户礼算力
VipWarnDays int32 `json:"vip_warn_days"` // VIP 到期前多少天提示
ComputeWarn int64 `json:"compute_warn"` // 算力低于多少提示
}
func (this *serverComp) appConnFromCtx(c *gin.Context) (mysql.ISys, bool) {
appId := parseAppId(c.GetHeader("X-App-Id"))
if !this.requireAppScope(c, appId) {
return nil, false
}
conn, err := this.module.registry.getServiceDB(appId)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return nil, false
}
return conn, true
}
// getComputeConfig 读选中应用的算力换算系数与闸门。
func (this *serverComp) getComputeConfig(c *gin.Context) {
conn, ok := this.appConnFromCtx(c)
if !ok {
return
}
items := make([]*pb.DBAppConfigItem, 0, 4)
if err := conn.Find(comm.TableAppConfig, &items, "`key` IN ?", computeConfigKeys); err != nil && !errors.Is(err, mysql.ErrNoDocuments) {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
kv := map[string]string{}
for _, it := range items {
kv[it.Key] = it.Value
}
r := comm.ParseComputeRates(kv)
o := comm.ParseOpsParams(kv)
writeOK(c, &computeRatesView{
Translate: r.Translate, Meeting: r.Meeting, Aichat: r.AIChat, Gate: r.Gate,
NewUserGiftVipDays: o.NewUserGiftVipDays, NewUserGiftCompute: o.NewUserGiftCompute,
VipWarnDays: o.VipWarnDays, ComputeWarn: o.ComputeWarn,
})
}
// saveComputeConfig 保存系数与闸门(仅超管/管理员)。
//
// ⚠️ 闸门开启前必须先上线预扣:现在 user_usages 是用完才上报,余额只剩 1 的用户仍可先用满再报——
// 开关关着时无所谓(反正放行),开了而没预扣等于形同虚设。前端在开关旁有提示,这里只做值校验。
func (this *serverComp) saveComputeConfig(c *gin.Context) {
if idt := currentIdentity(c); idt != pb.Identity_Admin && idt != pb.Identity_Manager {
writeErr(c, pb.ErrorCode_InsufficientPermissions, "仅超管/管理员可修改算力配置")
return
}
conn, ok := this.appConnFromCtx(c)
if !ok {
return
}
var req computeRatesView
if err := c.ShouldBindJSON(&req); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
return
}
const rateMax = 1_000_000
for name, v := range map[string]int64{"translate": req.Translate, "meeting": req.Meeting, "aichat": req.Aichat} {
if v <= 0 || v > rateMax {
writeErr(c, pb.ErrorCode_ReqParameterError, fmt.Sprintf("换算系数 %s 需在 1~%d 之间", name, rateMax))
return
}
}
// 运营参数允许为 0(不送 / 不提示),只挡负数与离谱的大值
if req.NewUserGiftVipDays < 0 || req.NewUserGiftVipDays > 3650 {
writeErr(c, pb.ErrorCode_ReqParameterError, "开户礼 VIP 天数需在 0~3650 之间")
return
}
if req.NewUserGiftCompute < 0 || req.NewUserGiftCompute > 10_000_000 {
writeErr(c, pb.ErrorCode_ReqParameterError, "开户礼算力需在 0~10000000 之间")
return
}
if req.VipWarnDays < 0 || req.VipWarnDays > 365 {
writeErr(c, pb.ErrorCode_ReqParameterError, "VIP 到期提示天数需在 0~365 之间")
return
}
if req.ComputeWarn < 0 || req.ComputeWarn > 10_000_000 {
writeErr(c, pb.ErrorCode_ReqParameterError, "算力提示阈值需在 0~10000000 之间")
return
}
r := comm.ComputeRates{Translate: req.Translate, Meeting: req.Meeting, AIChat: req.Aichat, Gate: req.Gate}
o := comm.OpsParams{
NewUserGiftVipDays: req.NewUserGiftVipDays, NewUserGiftCompute: req.NewUserGiftCompute,
VipWarnDays: req.VipWarnDays, ComputeWarn: req.ComputeWarn,
}
kvAll := r.ToKV()
for k, v := range o.ToKV() {
kvAll[k] = v
}
for k, v := range kvAll {
item := &pb.DBAppConfigItem{}
err := conn.FindOne(comm.TableAppConfig, item, "`key`=?", k)
switch {
case errors.Is(err, mysql.ErrNoDocuments):
item = &pb.DBAppConfigItem{Group: "compute", Key: k, Value: v, Description: computeConfigDesc(k)}
if err = conn.Insert(comm.TableAppConfig, item); err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
case err != nil:
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
case item.Value != v:
if err = conn.Table(comm.TableAppConfig).Where("`key`=?", k).Update("value", v).Error; err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
}
}
// 让业务进程立即重载应用配置(env 里的系数是从它的缓存下发的);服务端换算本身走 30s TTL 缓存。
broadcastConfigChanged(comm.ConfigKindGlobalConfig, "update", 0)
writeOK(c, &req)
}
func computeConfigDesc(k string) string {
switch k {
case comm.ConfigKeyComputeRateTranslate:
return "算力换算:翻译,算力/分钟"
case comm.ConfigKeyComputeRateMeeting:
return "算力换算:会议纪要,算力/分钟"
case comm.ConfigKeyComputeRateAIChat:
return "算力换算:AI对话,算力/100字"
case comm.ConfigKeyComputeGate:
return "算力闸门:1=余额不足即拒绝,0=只记账不拦(开启前须先上线预扣)"
case comm.ConfigKeyNewUserGiftVipDays:
return "开户礼:赠送VIP天数(0=不送)"
case comm.ConfigKeyNewUserGiftCompute:
return "开户礼:赠送算力(0=不送)"
case comm.ConfigKeyVipWarnDays:
return "提示阈值:VIP到期前多少天开始提示(0=不提示)"
case comm.ConfigKeyComputeWarn:
return "提示阈值:算力低于多少开始提示(0=不提示)"
}
return ""
}
// adjustUserCompute 后台调整用户算力余额(加减),余额不得低于 0,必填备注,写 AdminAdjust 流水。
func (this *serverComp) adjustUserCompute(c *gin.Context) {
if idt := currentIdentity(c); idt != pb.Identity_Admin && idt != pb.Identity_Manager {
writeErr(c, pb.ErrorCode_InsufficientPermissions, "仅超管/管理员可调整算力余额")
return
}
conn, ok := this.appConnFromCtx(c)
if !ok {
return
}
var req struct {
Uid string `json:"uid"`
Delta int64 `json:"delta"`
Remark string `json:"remark"`
}
_ = c.ShouldBindJSON(&req)
uid := strings.TrimSpace(req.Uid)
remark := strings.TrimSpace(req.Remark)
if uid == "" || req.Delta == 0 || remark == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "uid、非零的 delta、备注 都必填")
return
}
user := &pb.DBUser{}
if err := conn.FindOne(comm.TableUser, user, "uid=?", uid); err != nil {
if errors.Is(err, mysql.ErrNoDocuments) {
writeErr(c, pb.ErrorCode_UserSessionNobeing, "未找到该用户")
return
}
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
if user.Computebalance+req.Delta < 0 {
writeErr(c, pb.ErrorCode_ComputeBalanceNegative, fmt.Sprintf("当前余额 %d,调整后将为负", user.Computebalance))
return
}
if err := conn.Table(comm.TableUser).Where("uid=?", uid).
Update("computebalance", gorm.Expr("computebalance + ?", req.Delta)).Error; err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
_ = conn.Insert(comm.TableUserUseLog, &pb.DBUserUseLog{
Uid: uid,
Ts: time.Now().Unix(),
Logtype: pb.UserLogType_AdminAdjust,
Compute: req.Delta,
Extra: fmt.Sprintf("console adjust by account=%d: %s", currentAccountId(c), remark),
})
writeOK(c, gin.H{"uid": uid, "computebalance": user.Computebalance + req.Delta})
}
// getUserComputeLogs 用户流水(按时间倒序分页),含发放/消耗/后台调整全部类型,前端按 logtype 与算力列展示。
func (this *serverComp) getUserComputeLogs(c *gin.Context) {
conn, ok := this.appConnFromCtx(c)
if !ok {
return
}
var req struct {
Uid string `json:"uid"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
_ = c.ShouldBindJSON(&req)
uid := strings.TrimSpace(req.Uid)
if uid == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "uid 必填")
return
}
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 || req.PageSize > 200 {
req.PageSize = 50
}
var total int64
if err := conn.Table(comm.TableUserUseLog).Where("uid=?", uid).Count(&total).Error; err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
rows := make([]*pb.DBUserUseLog, 0, req.PageSize)
if err := conn.Table(comm.TableUserUseLog).Where("uid=?", uid).
Order("ts DESC, id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).
Find(&rows).Error; err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
writeOK(c, gin.H{"logs": rows, "total": total, "page": req.Page, "page_size": req.PageSize})
}
// computeStatsDay 按日消耗。
type computeStatsDay struct {
Day string `json:"day"`
Compute int64 `json:"compute"`
Users int64 `json:"users"`
}
// computeStatsKind 按功能消耗(raw 为原始量:翻译/会议为秒,AI 为次)。
type computeStatsKind struct {
Compute int64 `json:"compute"`
Raw int64 `json:"raw"`
Times int64 `json:"times"`
}
type computeStatsTop struct {
Uid string `json:"uid"`
Compute int64 `json:"compute"`
}
// getComputeStats 算力看板:按日 / 按功能 / 用户 Top N(选中应用业务库流水),以及设备算力发放与消耗总量(console 主库)。
// 用户 Top N 是**异常用量的发现入口**——额度闸门取消后,单用户成本上不封顶,这里是唯一能看出来的地方。
func (this *serverComp) getComputeStats(c *gin.Context) {
conn, ok := this.appConnFromCtx(c)
if !ok {
return
}
var req struct {
From int64 `json:"from"` // unix 秒,含
To int64 `json:"to"` // unix 秒,含
Top int `json:"top"`
}
_ = c.ShouldBindJSON(&req)
now := time.Now()
if req.To <= 0 {
req.To = now.Unix()
}
if req.From <= 0 || req.From > req.To {
req.From = now.AddDate(0, 0, -30).Unix()
}
if req.Top <= 0 || req.Top > 100 {
req.Top = 20
}
base := func() *gorm.DB {
return conn.Table(comm.TableUserUseLog).
Where("logtype=? AND compute<0 AND ts BETWEEN ? AND ?", int32(pb.UserLogType_UserConsume), req.From, req.To)
}
days := make([]*computeStatsDay, 0, 31)
if err := base().
Select("FROM_UNIXTIME(ts,'%Y-%m-%d') AS day, COALESCE(SUM(-compute),0) AS compute, COUNT(DISTINCT uid) AS users").
Group("day").Order("day").Scan(&days).Error; err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
var kinds struct {
TranslateCompute, TranslateRaw, TranslateTimes int64
MeetingCompute, MeetingRaw, MeetingTimes int64
AichatCompute, AichatRaw, AichatTimes int64
}
if err := base().Select(
"COALESCE(SUM(CASE WHEN addtradesecond<0 THEN -compute ELSE 0 END),0) AS translate_compute," +
"COALESCE(SUM(CASE WHEN addtradesecond<0 THEN -addtradesecond ELSE 0 END),0) AS translate_raw," +
"COALESCE(SUM(CASE WHEN addtradesecond<0 THEN 1 ELSE 0 END),0) AS translate_times," +
"COALESCE(SUM(CASE WHEN addmeetsecond<0 THEN -compute ELSE 0 END),0) AS meeting_compute," +
"COALESCE(SUM(CASE WHEN addmeetsecond<0 THEN -addmeetsecond ELSE 0 END),0) AS meeting_raw," +
"COALESCE(SUM(CASE WHEN addmeetsecond<0 THEN 1 ELSE 0 END),0) AS meeting_times," +
"COALESCE(SUM(CASE WHEN addagentintegral<0 THEN -compute ELSE 0 END),0) AS aichat_compute," +
"COALESCE(SUM(CASE WHEN addagentintegral<0 THEN -addagentintegral ELSE 0 END),0) AS aichat_raw," +
"COALESCE(SUM(CASE WHEN addagentintegral<0 THEN 1 ELSE 0 END),0) AS aichat_times").
Scan(&kinds).Error; err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
top := make([]*computeStatsTop, 0, req.Top)
if err := base().Select("uid, COALESCE(SUM(-compute),0) AS compute").
Group("uid").Order("compute DESC").Limit(req.Top).Scan(&top).Error; err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
// 设备算力总量(console 主库,跨应用;product 才按应用归属,这里不细分)
var dev struct{ Grant, Used, Devices int64 }
if adb := consoleDeviceConn().AdminDB(); adb != nil {
if err := adb.Table(comm.TableDeviceMac).
Select("COALESCE(SUM(computegrant),0) AS grant, COALESCE(SUM(computeused),0) AS used, COUNT(*) AS devices").
Where("usedtime>0 AND computegrant>0").Scan(&dev).Error; err != nil {
log.Warn("console: 设备算力汇总失败", log.Field{Key: "err", Value: err.Error()})
}
}
writeOK(c, gin.H{
"from": req.From, "to": req.To,
"days": days,
"kinds": gin.H{
"translate": &computeStatsKind{Compute: kinds.TranslateCompute, Raw: kinds.TranslateRaw, Times: kinds.TranslateTimes},
"meeting": &computeStatsKind{Compute: kinds.MeetingCompute, Raw: kinds.MeetingRaw, Times: kinds.MeetingTimes},
"aichat": &computeStatsKind{Compute: kinds.AichatCompute, Raw: kinds.AichatRaw, Times: kinds.AichatTimes},
},
"top": top,
"device": gin.H{"grant": dev.Grant, "used": dev.Used, "left": dev.Grant - dev.Used, "devices": dev.Devices},
})
}
// userComputeSummary 用户详情里的会员与算力附加信息:设备赋予的 VIP / 算力按当前绑定关系跨库取。
func userComputeSummary(conn mysql.ISys, user *pb.DBUser) (out gin.H) {
uds := make([]*pb.DBUserDivice, 0, 2)
_ = conn.Find(comm.TableUserdevice, &uds, "uid=?", user.Uid)
devs := make([]*pb.DBAuthCode, 0, len(uds))
views := make([]gin.H, 0, len(uds))
var deviceLeft int64
if adb := consoleDeviceConn().AdminDB(); adb != nil {
seen := map[string]bool{}
for _, ud := range uds {
mac := strings.TrimSpace(ud.Devicemac)
if mac == "" {
continue
}
if n, ok := devcode.NormalizeMac(mac); ok {
mac = n
}
if seen[mac] {
continue
}
seen[mac] = true
d := &pb.DBAuthCode{}
if err := adb.FindOne(comm.TableDeviceMac, d, "devicemac=?", mac); err != nil || d.Disabled != 0 {
continue
}
devs = append(devs, d)
deviceLeft += comm.DeviceComputeLeft(d)
views = append(views, gin.H{
"devicemac": d.Devicemac,
"devicename": ud.Devicename,
"usedtime": d.Usedtime,
"vipdays": d.Vipdays,
"viplevel": comm.DeviceVipLevel(d),
"vipexptime": comm.DeviceVipExpire(d),
"computegrant": d.Computegrant,
"computeused": d.Computeused,
"computeleft": comm.DeviceComputeLeft(d),
})
}
}
vip := comm.ResolveVip(user, devs, time.Now().Unix())
return gin.H{
"vip_effective": gin.H{
"level": vip.Level,
"exptime": vip.ExpTime,
"selfexptime": vip.SelfExpTime,
"fromdevice": vip.FromDevice,
},
"compute_devices": views,
"compute_deviceleft": deviceLeft,
}
}

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

@ -120,6 +120,10 @@ func ensureConfigTables() error {
if err := pg.CreateTable(comm.TableAppGoods, &AppGoods{}); err != nil { if err := pg.CreateTable(comm.TableAppGoods, &AppGoods{}); err != nil {
return err return err
} }
// 2026-09 会员与算力:商品改发「VIP 天数 + 算力」。CreateTable 对已存在的表跳过 AutoMigrate,故显式补列。
if res := pg.Exec("ALTER TABLE " + comm.TableAppGoods + " ADD COLUMN IF NOT EXISTS compute bigint DEFAULT 0"); res.Error != nil {
return res.Error
}
// 一次性迁移:老表数据 → 新表 app_name='' 全局作用域(新表为空且老表存在时)。 // 一次性迁移:老表数据 → 新表 app_name='' 全局作用域(新表为空且老表存在时)。
migrateLegacyConfigToScoped() migrateLegacyConfigToScoped()
// 一次性迁移:独立 mcp 表 → svc_config 的 MCP 服务类型(含区域覆盖)。 // 一次性迁移:独立 mcp 表 → svc_config 的 MCP 服务类型(含区域覆盖)。

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

@ -18,7 +18,9 @@ import (
// 批次绑定赠送的上限(防手滑,不是业务硬约束)。 // 批次绑定赠送的上限(防手滑,不是业务硬约束)。
const ( const (
batchRewardVipDayMax uint32 = 3650 // 10 年 batchRewardVipDayMax uint32 = 3650 // 10 年
batchRewardMinuteMax uint32 = 525600 // 1 年的分钟数 batchRewardMinuteMax uint32 = 525600 // 【已停用】翻译/会议分钟数不再作为赠送项
batchRewardVipLevelMax int32 = 2 // 1=VIP 2=VIP-Pro
batchRewardComputeMax int64 = 10_000_000
) )
// defaultBrandContactMail 品牌商联系邮箱缺省必含项(与 modules/api 一致)。 // defaultBrandContactMail 品牌商联系邮箱缺省必含项(与 modules/api 一致)。
@ -78,12 +80,16 @@ func (this *serverComp) updateProduct(c *gin.Context, sys *appConn) {
// 品牌商 / 渠道方 / 绑定奖励 / 功能开关已从产品表单移除,前端不再回传这些字段。 // 品牌商 / 渠道方 / 绑定奖励 / 功能开关已从产品表单移除,前端不再回传这些字段。
// dvUpdateProduct 是全字段 Save,不显式保留就会被抹成 0/false——那会让存量产品的 // dvUpdateProduct 是全字段 Save,不显式保留就会被抹成 0/false——那会让存量产品的
// 绑定奖励失效、客户端功能开关集体关掉(这些值仍在下发给 App,见 user_getappconfig)。 // 绑定奖励失效、客户端功能开关集体关掉(这些值仍在下发给 App,见 user_getappconfig)。
// ⚠️ Scanuuid 不在此列:它已于 2026-09-08 整体废弃,pb 里改成 gorm:"-" 的兼容字段,
// 不建列也不参与读写,Save 碰不到它。
req.Product.Brandid = old.Brandid req.Product.Brandid = old.Brandid
req.Product.Channel = old.Channel req.Product.Channel = old.Channel
req.Product.Channelextra = old.Channelextra req.Product.Channelextra = old.Channelextra
req.Product.Bindrewardvpitime = old.Bindrewardvpitime req.Product.Bindrewardvpitime = old.Bindrewardvpitime
req.Product.Bindrewardtranslate = old.Bindrewardtranslate req.Product.Bindrewardtranslate = old.Bindrewardtranslate
req.Product.Bindrewardmeeting = old.Bindrewardmeeting req.Product.Bindrewardmeeting = old.Bindrewardmeeting
req.Product.Bindviplevel = old.Bindviplevel // 2026-09 会员与算力:批次未配置时的回退值,产品表单同样不回传
req.Product.Bindcompute = old.Bindcompute
req.Product.Iscanbindagain = old.Iscanbindagain req.Product.Iscanbindagain = old.Iscanbindagain
req.Product.Iscontinuouschat = old.Iscontinuouschat req.Product.Iscontinuouschat = old.Iscontinuouschat
req.Product.Eqenable = old.Eqenable req.Product.Eqenable = old.Eqenable
@ -720,7 +726,7 @@ func (this *serverComp) createAuthCodes(c *gin.Context, sys *appConn) {
writeErr(c, pb.ErrorCode_ReqParameterError, "分成比例需在 0~10000(万分比) 之间") writeErr(c, pb.ErrorCode_ReqParameterError, "分成比例需在 0~10000(万分比) 之间")
return return
} }
if msg := checkBatchRewards(req.Rewardvip, req.Rewardtranslate, req.Rewardmeeting); msg != "" { if msg := checkBatchRewards(req.Rewardvip, req.Viplevel, req.Computegrant); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg) writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return return
} }
@ -802,10 +808,11 @@ func (this *serverComp) createAuthCodes(c *gin.Context, sys *appConn) {
Channelid: channel.Id, Channelid: channel.Id,
Factoryid: factoryId, Factoryid: factoryId,
Sharerate: req.Sharerate, Sharerate: req.Sharerate,
// 绑定赠送冻结进批次:事后改这里不影响已发出的货,绑定时按设备所属批次发放。 // 绑定赠送冻结进批次:事后改这里不影响已发出的货,设备激活时按所属批次把三项快照到设备行。
// 2026-09 起只有「VIP 天数 + VIP 等级 + 算力」,翻译/会议分钟数两列不再写入。
Rewardvip: req.Rewardvip, Rewardvip: req.Rewardvip,
Rewardtranslate: req.Rewardtranslate, Viplevel: normalizeVipLevel(req.Viplevel),
Rewardmeeting: req.Rewardmeeting, Computegrant: req.Computegrant,
} }
if err = dvAddProductionBatch(sys, batch); err != nil { // Insert 回填 batch.Id if err = dvAddProductionBatch(sys, batch); err != nil { // Insert 回填 batch.Id
writeErr(c, pb.ErrorCode_DBError, err.Error()) writeErr(c, pb.ErrorCode_DBError, err.Error())
@ -1007,23 +1014,28 @@ func (this *serverComp) restFactoryDevics(c *gin.Context, sys *appConn) {
writeOK(c, &pb.ApiRestUserDevicsResp{}) writeOK(c, &pb.ApiRestUserDevicsResp{})
} }
// checkBatchRewards 校验批次的绑定赠送三项。返回空串表示通过。 // checkBatchRewards 校验批次的绑定赠送三项(VIP 天数 / VIP 等级 / 算力)。返回空串表示通过。
// func checkBatchRewards(vipDays uint32, vipLevel int32, compute int64) string {
// 上限不是技术限制,是防手滑:VIP 天数多打一个 0 就是 3650 天永久会员,
// 而授权码一旦生产出去就发给工厂了,撤不回来。
func checkBatchRewards(vipDays, translateMin, meetingMin uint32) string {
if vipDays > batchRewardVipDayMax { if vipDays > batchRewardVipDayMax {
return fmt.Sprintf("赠送VIP天数不能超过 %d 天", batchRewardVipDayMax) return fmt.Sprintf("赠送VIP天数不能超过 %d 天", batchRewardVipDayMax)
} }
if translateMin > batchRewardMinuteMax { if vipLevel < 0 || vipLevel > batchRewardVipLevelMax {
return fmt.Sprintf("赠送翻译时长不能超过 %d 分钟", batchRewardMinuteMax) return fmt.Sprintf("VIP等级只能是 1(VIP) 或 2(VIP-Pro),得到 %d", vipLevel)
} }
if meetingMin > batchRewardMinuteMax { if compute < 0 || compute > batchRewardComputeMax {
return fmt.Sprintf("赠送会议时长不能超过 %d 分钟", batchRewardMinuteMax) return fmt.Sprintf("赠送算力需在 0~%d 之间", batchRewardComputeMax)
} }
return "" return ""
} }
// normalizeVipLevel 没填等级时按 1(VIP) 处理,避免批次表里出现「有天数没等级」的行。
func normalizeVipLevel(lv int32) int32 {
if lv <= 0 {
return 1
}
return lv
}
// normalizeImportMacs 规范化并校验导入模式的 MAC 列表。返回 (规范化后的列表, 错误提示)。 // normalizeImportMacs 规范化并校验导入模式的 MAC 列表。返回 (规范化后的列表, 错误提示)。
// //
// MAC 现在就是授权码分表的主键,所以三件事必须在写库之前挡住,否则代价很高: // MAC 现在就是授权码分表的主键,所以三件事必须在写库之前挡住,否则代价很高:

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

@ -45,6 +45,7 @@ type appGoodsReq struct {
Meetnum int64 `json:"meetnum"` Meetnum int64 `json:"meetnum"`
Tradenum int64 `json:"tradenum"` Tradenum int64 `json:"tradenum"`
Viptime int32 `json:"viptime"` Viptime int32 `json:"viptime"`
Compute int64 `json:"compute"`
Locales map[string]*GoodsLocale `json:"locales"` Locales map[string]*GoodsLocale `json:"locales"`
Sort int32 `json:"sort"` Sort int32 `json:"sort"`
Remark string `json:"remark"` Remark string `json:"remark"`
@ -109,6 +110,7 @@ func applyGoodsReq(m *AppGoods, req *appGoodsReq, localname string) {
m.Meetnum = req.Meetnum m.Meetnum = req.Meetnum
m.Tradenum = req.Tradenum m.Tradenum = req.Tradenum
m.Viptime = req.Viptime m.Viptime = req.Viptime
m.Compute = req.Compute
m.Localname = localname m.Localname = localname
m.Sort = req.Sort m.Sort = req.Sort
m.Remark = strings.TrimSpace(req.Remark) m.Remark = strings.TrimSpace(req.Remark)

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

@ -89,6 +89,15 @@ var serviceColumns = []serviceColumn{
{comm.TablePayOrder, &pb.DBPayOrder{}, "src_productid"}, {comm.TablePayOrder, &pb.DBPayOrder{}, "src_productid"},
{comm.TablePayOrder, &pb.DBPayOrder{}, "src_brandid"}, {comm.TablePayOrder, &pb.DBPayOrder{}, "src_brandid"},
{comm.TablePayOrder, &pb.DBPayOrder{}, "src_channelid"}, {comm.TablePayOrder, &pb.DBPayOrder{}, "src_channelid"},
// 2026-09 会员与算力(业务服务启动会 AutoMigrate,这里只为 console 先升级的窗口兜底)
{comm.TableUser, &pb.DBUser{}, "computebalance"},
{comm.TableUserUseLog, &pb.DBUserUseLog{}, "compute"},
{comm.TableUserUseLog, &pb.DBUserUseLog{}, "computedevice"},
{comm.TableUserUseLog, &pb.DBUserUseLog{}, "computeuser"},
{comm.TableUserUseLog, &pb.DBUserUseLog{}, "computeover"},
{comm.TableUserUseLog, &pb.DBUserUseLog{}, "devicemac"},
{comm.TableUserStatistics, &pb.DBUserStatistics{}, "computetotal"},
{comm.TableGoods, &pb.DBGoods{}, "compute"},
} }
// ensureServiceColumns 幂等补齐业务库缺失的列。全程 best-effort: // ensureServiceColumns 幂等补齐业务库缺失的列。全程 best-effort:
@ -226,6 +235,9 @@ func ensureDeviceTables() error {
"ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS rewardvip bigint DEFAULT 0", "ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS rewardvip bigint DEFAULT 0",
"ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS rewardtranslate bigint DEFAULT 0", "ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS rewardtranslate bigint DEFAULT 0",
"ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS rewardmeeting bigint DEFAULT 0", "ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS rewardmeeting bigint DEFAULT 0",
// 2026-09 会员与算力:批次只配「VIP 天数(rewardvip 复用) + VIP 等级 + 赠送算力」,翻译/会议两列不再读写。
"ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS viplevel integer DEFAULT 1",
"ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS computegrant bigint DEFAULT 0",
} { } {
if res := adb.Exec(ddl); res.Error != nil { if res := adb.Exec(ddl); res.Error != nil {
return res.Error return res.Error
@ -253,6 +265,19 @@ func ensureDeviceTables() error {
"ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS description text DEFAULT ''", "ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS description text DEFAULT ''",
// pb 里 providerid 带 gorm index;建表走 AutoMigrate 会自动建,补列这条路要自己补上 // pb 里 providerid 带 gorm index;建表走 AutoMigrate 会自动建,补列这条路要自己补上
"CREATE INDEX IF NOT EXISTS idx_product_providerid ON " + comm.TableProduct + " (providerid)", "CREATE INDEX IF NOT EXISTS idx_product_providerid ON " + comm.TableProduct + " (providerid)",
// devicetype 已于 2026-09-08 废弃并删列:服务端没有任何逻辑读它,后台表单也去掉了这一项。
// pb 里那个 Devicetype 已改成 gorm:"-" 的老客户端兼容字段(恒为 0,只为老 App 包能解析),
// 新库根本不会建这一列,这条只负责把存量库里的旧列清掉。幂等。
// ⚠️ 不可逆:列里的历史取值执行后即消失。它不参与任何判断,故不做备份。
"ALTER TABLE " + comm.TableProduct + " DROP COLUMN IF EXISTS devicetype",
// 2026-09 会员与算力:批次未配置时的回退(bindrewardvpitime 复用为 VIP 天数)。
"ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS bindviplevel integer DEFAULT 1",
"ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS bindcompute bigint DEFAULT 0",
// scanuuid 已于 2026-09-08 废弃并删列:设备校验早就改成按 MAC 走(device_mac 全局表),
// 绑定奖励也是按 MAC 判「这台设备有没有被绑过」,跟这个 UUID 没有任何关系。
// pb 里的 Scanuuid 同样改成了 gorm:"-" 的老客户端兼容字段(恒为空串,只为老 App 包能解析)。
// ⚠️ 不可逆:列里的历史取值执行后即消失。它不参与任何判断,故不做备份。
"ALTER TABLE " + comm.TableProduct + " DROP COLUMN IF EXISTS scanuuid",
} { } {
if res := adb.Exec(ddl); res.Error != nil { if res := adb.Exec(ddl); res.Error != nil {
return res.Error return res.Error
@ -271,6 +296,24 @@ func ensureDeviceTables() error {
if err := adb.CreateTable(comm.TableDeviceMac, &pb.DBAuthCode{}); err != nil { if err := adb.CreateTable(comm.TableDeviceMac, &pb.DBAuthCode{}); err != nil {
return err return err
} }
// 2026-09 会员与算力:设备行上的权益快照(激活时从批次固化)。CreateTable 对已存在的表跳过
// AutoMigrate,故显式补列。随后把**已激活**的存量设备按其批次回填一次——设备 VIP 期是
// [usedtime, usedtime+vipdays),与首绑用户当年拿到的那段 VIP 基本重合,不算二次赠送;
// 但转手后的下一个绑定者从此能接着享有剩余天数与剩余算力。WHERE 里的全 0 条件保证幂等。
for _, ddl := range []string{
"ALTER TABLE " + comm.TableDeviceMac + " ADD COLUMN IF NOT EXISTS vipdays integer DEFAULT 0",
"ALTER TABLE " + comm.TableDeviceMac + " ADD COLUMN IF NOT EXISTS viplevel integer DEFAULT 0",
"ALTER TABLE " + comm.TableDeviceMac + " ADD COLUMN IF NOT EXISTS computegrant bigint DEFAULT 0",
"ALTER TABLE " + comm.TableDeviceMac + " ADD COLUMN IF NOT EXISTS computeused bigint DEFAULT 0",
"UPDATE " + comm.TableDeviceMac + " d SET vipdays = b.rewardvip, viplevel = GREATEST(COALESCE(b.viplevel,1),1), computegrant = COALESCE(b.computegrant,0)" +
" FROM " + comm.TableProductionBatch + " b" +
" WHERE d.batchno = b.batchno AND d.usedtime > 0 AND d.vipdays = 0 AND d.computegrant = 0" +
" AND (b.rewardvip > 0 OR COALESCE(b.computegrant,0) > 0)",
} {
if res := adb.Exec(ddl); res.Error != nil {
return res.Error
}
}
var products []*pb.DBProduct var products []*pb.DBProduct
if err := adb.Find(comm.TableProduct, &products, ""); err != nil && err != postgres.ErrNoDocuments { if err := adb.Find(comm.TableProduct, &products, ""); err != nil && err != postgres.ErrNoDocuments {
return err return err

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

@ -493,6 +493,22 @@ func (this *serverComp) handleWeb(c *gin.Context) {
case "api_giftuserresource": case "api_giftuserresource":
this.giftUserResource(c) this.giftUserResource(c)
return return
// 会员与算力(2026-09):换算系数/闸门存选中应用业务库 config 表;余额调整与流水在业务库 user/useruselog。
case "api_getcomputeconfig":
this.getComputeConfig(c)
return
case "api_savecomputeconfig":
this.saveComputeConfig(c)
return
case "api_adjustusercompute":
this.adjustUserCompute(c)
return
case "api_getusercomputelogs":
this.getUserComputeLogs(c)
return
case "api_getcomputestats":
this.getComputeStats(c)
return
} }
// 设备管理域:厂家/产品/设备码/公码/出货单都在 console 主库(supabase),无需选应用。 // 设备管理域:厂家/产品/设备码/公码/出货单都在 console 主库(supabase),无需选应用。
@ -867,10 +883,11 @@ func (this *serverComp) delBrand(c *gin.Context, sys *appConn) {
func (this *serverComp) getAuthCodes(c *gin.Context, sys *appConn) { func (this *serverComp) getAuthCodes(c *gin.Context, sys *appConn) {
var req pb.ApiGetAuthCodesReq var req pb.ApiGetAuthCodesReq
_ = c.ShouldBindJSON(&req) _ = c.ShouldBindJSON(&req)
if req.Productid == 0 { // productid == 0 = 「全部产品」视图(后台 MAC查询 的默认状态)。
writeErr(c, pb.ErrorCode_ReqParameterError, "productid 不能为空") // device_mac 合表(2026-09-04)之前必须带 pid 才知道查哪张分表,现在是全局一张表,
return // dvAuthCodesPaged → comm.ScopeToProduct(0,…) 会原样放行不拼 productid 条件。
} // ⚠️ 这里不再拦,意味着调用方能一次拉到全部产品的设备;按产品收敛的责任落在
// 前端下拉(产品受限的运营账号不给「全部产品」这一项)与页面权限上。
var ( var (
conds = make([]string, 0, 7) conds = make([]string, 0, 7)
args []interface{} args []interface{}
@ -1174,6 +1191,11 @@ func (this *serverComp) getUserInfo(c *gin.Context) {
// 明细查不到不影响主查询,记日志即可。 // 明细查不到不影响主查询,记日志即可。
this.module.Errorln("getUserInfo: 查实名明细失败:", err) this.module.Errorln("getUserInfo: 查实名明细失败:", err)
} }
// 会员与算力(2026-09):user.vipexptime 只记自购;设备赋予的 VIP 与算力挂在 device_mac 行上,
// 这里按当前绑定关系跨库拼出「有效 VIP」与各设备的权益快照。
for k, v := range userComputeSummary(conn, user) {
out[k] = v
}
writeOK(c, out) writeOK(c, out)
} }
@ -1253,14 +1275,9 @@ func (this *serverComp) giftUserResource(c *gin.Context) {
user.Vipexptime += addVipSec user.Vipexptime += addVipSec
} }
} }
if addTradeSec > 0 { // 2026-09 起三个额度桶停用:翻译分 / 会议分按当前换算系数折成算力发到用户余额。
user.Tradeintegral += addTradeSec addCompute := comm.LegacyBucketsToCompute(comm.LoadComputeRates(), addTradeSec, addMeetSec, 0)
user.Tradetotalintegral += addTradeSec user.Computebalance += addCompute
}
if addMeetSec > 0 {
user.Meetintegral += addMeetSec
user.Meettotalintegral += addMeetSec
}
if err := conn.Save(comm.TableUser, user); err != nil { if err := conn.Save(comm.TableUser, user); err != nil {
// 代理:写库失败回滚已扣余额,避免用户没到账却扣了代理。 // 代理:写库失败回滚已扣余额,避免用户没到账却扣了代理。
if identity == pb.Identity_Agent { if identity == pb.Identity_Agent {
@ -1275,18 +1292,15 @@ func (this *serverComp) giftUserResource(c *gin.Context) {
Ts: now, Ts: now,
Logtype: pb.UserLogType_AdminGive, Logtype: pb.UserLogType_AdminGive,
Addvipday: req.Vipday, Addvipday: req.Vipday,
Addtradesecond: addTradeSec, Compute: addCompute,
Addmeetsecond: addMeetSec,
Extra: fmt.Sprintf("console gift by identity=%d account=%d", int32(identity), accId), Extra: fmt.Sprintf("console gift by identity=%d account=%d", int32(identity), accId),
}) })
resp := gin.H{ resp := gin.H{
"uid": uid, "uid": uid,
"vipexptime": user.Vipexptime, "vipexptime": user.Vipexptime,
"tradeintegral": user.Tradeintegral, "computebalance": user.Computebalance,
"meetintegral": user.Meetintegral, "addcompute": addCompute,
"tradetotalintegral": user.Tradetotalintegral,
"meettotalintegral": user.Meettotalintegral,
} }
// 代理:回传扣减后的最新余额,前端据此刷新侧边栏。 // 代理:回传扣减后的最新余额,前端据此刷新侧边栏。
if identity == pb.Identity_Agent { if identity == pb.Identity_Agent {

52
apps/services/modules/echomeet/api_starttask.go

@ -2,6 +2,7 @@ package echomeet
import ( import (
"yunyan/comm" "yunyan/comm"
"yunyan/lego/sys/mysql"
"yunyan/pb" "yunyan/pb"
"encoding/json" "encoding/json"
"time" "time"
@ -46,20 +47,38 @@ func (this *apiComp) StartTask(session comm.IUserSession, req *pb.EchomeetStartT
} }
return return
} }
if user.Meetintegral < int64(model.Seconds) { // ── 准入:会议纪要需要 VIP(自购 或 当前绑定设备赋予,见 comm.ResolveVip)──
if vip, _, verr := comm.ResolveUserVip(user.Uid, user); verr == nil && !vip.Active(time.Now().Unix()) {
errdata = &pb.ErrorData{ errdata = &pb.ErrorData{
Code: pb.ErrorCode_MeetIntegralNotEnough, Code: pb.ErrorCode_VipRequired,
Message: "积分不足", Message: "会议纪要需要会员,请续费或绑定带会员的设备",
} }
return return
} }
// ── 计量:会议时长服务端自己知道(model.Seconds),按换算系数折成算力记账。
user.Meetintegral -= int64(model.Seconds) // 先设备赠送、再用户余额;闸门默认关,不够也放行、差额记超额。──
err = this.module.model.updateuser(user) statistics, sErr := this.module.model.getStatistics(session.GetUserId())
if err != nil { if sErr != nil && sErr != mysql.ErrNoDocuments {
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: sErr.Error()}
return
}
statistics.Meetnum += 1
statistics.Meettime += int64(model.Seconds)
userlog := &pb.DBUserUseLog{
Uid: session.GetUserId(),
Logtype: pb.UserLogType_UserConsume,
Ts: time.Now().Unix(),
Addmeetsecond: -1 * int64(model.Seconds),
}
res, cerr := comm.ApplyComputeUsage(user.Uid, comm.ComputeUsageMeeting, int64(model.Seconds), userlog, statistics)
if cerr != nil {
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: cerr.Error()}
return
}
if res.Rejected {
errdata = &pb.ErrorData{ errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError, Code: pb.ErrorCode_ComputeNotEnough,
Message: err.Error(), Message: "算力不足",
} }
return return
} }
@ -73,22 +92,11 @@ func (this *apiComp) StartTask(session comm.IUserSession, req *pb.EchomeetStartT
Second: int64(model.Seconds), Second: int64(model.Seconds),
}) })
} }
// 同步更新累计统计(排行榜依赖此表)+ 消费日志(供历史重建扫描)
// 同步更新累计统计(排行榜依赖此表)
if statistics, sErr := this.module.model.getStatistics(session.GetUserId()); sErr == nil {
statistics.Meetnum += 1
statistics.Meettime += int64(model.Seconds)
if uErr := this.module.model.updateStatistics(statistics); uErr != nil { if uErr := this.module.model.updateStatistics(statistics); uErr != nil {
this.module.Warnf("StartTask id:%d update userstatistics failed: %v", model.Id, uErr) this.module.Warnf("StartTask id:%d update userstatistics failed: %v", model.Id, uErr)
} }
} _ = this.module.model.addUseLog(userlog)
// 写消费日志(供历史重建扫描)
_ = this.module.model.addUseLog(&pb.DBUserUseLog{
Uid: session.GetUserId(),
Logtype: pb.UserLogType_UserConsume,
Ts: time.Now().Unix(),
Addmeetsecond: -1 * int64(model.Seconds),
})
tmpl, err := this.module.cache.ResolveTemplate(req.Tid, req.Templateid, req.Tolanguage) tmpl, err := this.module.cache.ResolveTemplate(req.Tid, req.Templateid, req.Tolanguage)
if err != nil { if err != nil {

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

@ -41,14 +41,18 @@ func (this *apiComp) Start() (err error) {
// 但发放、记账、埋点是同一套,抽出来共用。 // 但发放、记账、埋点是同一套,抽出来共用。
type goodsGrant struct { type goodsGrant struct {
Viptime int32 Viptime int32
Ainum int64 Compute int64
Meetnum int64
Tradenum int64
} }
// grantOfAppGoods 把后台商品配置换算成发放内容(v2 口径)。 // grantOfAppGoods 把后台商品配置换算成发放内容。
// 2026-09 起商品只发「VIP 天数 + 算力」;老商品没配 compute(=0)而配了旧三桶时,按当前换算系数折成算力,
// 保证历史商品照常可卖、买到的东西不缩水。
func grantOfAppGoods(g *comm.AppGoods) *goodsGrant { func grantOfAppGoods(g *comm.AppGoods) *goodsGrant {
return &goodsGrant{Viptime: g.Viptime, Ainum: g.Ainum, Meetnum: g.Meetnum, Tradenum: g.Tradenum} c := g.Compute
if c == 0 {
c = comm.LegacyBucketsToCompute(comm.LoadComputeRates(), g.Tradenum, g.Meetnum, g.Ainum)
}
return &goodsGrant{Viptime: g.Viptime, Compute: c}
} }
// deliverResourceByOrder 按订单给用户发货:商品取自**业务库 goods 表**(历史口径, // deliverResourceByOrder 按订单给用户发货:商品取自**业务库 goods 表**(历史口径,
@ -69,7 +73,11 @@ func (this *apiComp) deliverResourceByOrder(user *pb.DBUser, order *pb.DBPayOrde
var grant *goodsGrant var grant *goodsGrant
for _, v := range goodss { for _, v := range goodss {
if v.Id == order.ProductId { if v.Id == order.ProductId {
grant = &goodsGrant{Viptime: v.Viptime, Ainum: v.Ainum, Meetnum: v.Meetnum, Tradenum: v.Tradenum} c := v.Compute
if c == 0 {
c = comm.LegacyBucketsToCompute(comm.LoadComputeRates(), v.Tradenum, v.Meetnum, v.Ainum)
}
grant = &goodsGrant{Viptime: v.Viptime, Compute: c}
} }
} }
return this.deliverGrant(user, order, grant) return this.deliverGrant(user, order, grant)
@ -109,27 +117,14 @@ func (this *apiComp) deliverGrant(user *pb.DBUser, order *pb.DBPayOrder, grant *
log.Field{Key: "mode", Value: mode}, log.Field{Key: "mode", Value: mode},
log.Field{Key: "Vipexptime", Value: user.Vipexptime}) log.Field{Key: "Vipexptime", Value: user.Vipexptime})
} }
if v.Ainum > 0 { if v.Compute > 0 {
user.Aichatintegral += v.Ainum user.Computebalance += v.Compute
user.Aichattotalintegral = user.Aichatintegral userlog.Compute = v.Compute
userlog.Addagentintegral = v.Ainum this.module.Debug("支付发货", log.Field{Key: "Computebalance", Value: user.Computebalance})
this.module.Debug("支付发货", log.Field{Key: "Aichatintegral", Value: user.Aichatintegral}) }
} userlog.Extra = fmt.Sprintf(`{"order_id":"%s","product_id":"%s","product_name":"%s","amount":%d,"payment_method":%d,"add_vip_days":%d,"add_compute":%d}`,
if v.Meetnum > 0 {
user.Meetintegral += v.Meetnum
user.Meettotalintegral = user.Meetintegral
userlog.Addmeetsecond = int64(v.Meetnum)
this.module.Debug("支付发货", log.Field{Key: "Meetintegral", Value: user.Meetintegral})
}
if v.Tradenum > 0 {
user.Tradeintegral += v.Tradenum
user.Tradetotalintegral = user.Tradeintegral
userlog.Addtradesecond = int64(v.Tradenum)
this.module.Debug("支付发货", log.Field{Key: "Tradeintegral", Value: user.Tradeintegral})
}
userlog.Extra = fmt.Sprintf(`{"order_id":"%s","product_id":"%s","product_name":"%s","amount":%d,"payment_method":%d,"add_vip_days":%d,"add_ai_integral":%d,"add_trade_second":%d,"add_meet_second":%d}`,
order.OrderId, order.ProductId, order.ProductName, order.Amount, order.PaymentMethod, order.OrderId, order.ProductId, order.ProductName, order.Amount, order.PaymentMethod,
userlog.Addvipday, userlog.Addagentintegral, userlog.Addtradesecond, userlog.Addmeetsecond) userlog.Addvipday, userlog.Compute)
} }
// 2. 更新用户信息 // 2. 更新用户信息
if err = this.module.model.updateUser(user); err != nil { if err = this.module.model.updateUser(user); err != nil {

18
apps/services/modules/user/api_bindauthcode.go

@ -121,10 +121,12 @@ func (this *apiComp) BindAuthCode(session comm.IUserSession, req *pb.UserBindAut
return return
} }
// 5) 计算奖励(单位:vip=天,translate/meeting=分钟;存储统一按秒) // 5) 计算奖励:VIP 按天累加到自购到期时间(公码是发给**账号**的,归属用户,与设备无关);
// 公码上配的翻译/会议分钟数(旧字段)按当前换算系数折成算力发到用户余额。
addVipDay := int64(code.Bindrewardvpitime) addVipDay := int64(code.Bindrewardvpitime)
addTradeSec := int64(code.Bindrewardtranslate) * 60 addTradeSec := int64(code.Bindrewardtranslate) * 60
addMeetSec := int64(code.Bindrewardmeeting) * 60 addMeetSec := int64(code.Bindrewardmeeting) * 60
addCompute := comm.LegacyBucketsToCompute(comm.LoadComputeRates(), addTradeSec, addMeetSec, 0)
if addVipDay > 0 { if addVipDay > 0 {
addVipSec := addVipDay * 24 * 60 * 60 addVipSec := addVipDay * 24 * 60 * 60
if user.Vipexptime == 0 || user.Vipexptime < now { if user.Vipexptime == 0 || user.Vipexptime < now {
@ -133,14 +135,7 @@ func (this *apiComp) BindAuthCode(session comm.IUserSession, req *pb.UserBindAut
user.Vipexptime += addVipSec user.Vipexptime += addVipSec
} }
} }
if addTradeSec > 0 { user.Computebalance += addCompute
user.Tradeintegral += addTradeSec
user.Tradetotalintegral += addTradeSec
}
if addMeetSec > 0 {
user.Meetintegral += addMeetSec
user.Meettotalintegral += addMeetSec
}
// 6) 保存用户余额(isactivatecode 已经在步骤 3 提前落库,这里 Save 会覆盖一并写回,是幂等的) // 6) 保存用户余额(isactivatecode 已经在步骤 3 提前落库,这里 Save 会覆盖一并写回,是幂等的)
if err = this.module.model.save(user); err != nil { if err = this.module.model.save(user); err != nil {
@ -151,14 +146,13 @@ func (this *apiComp) BindAuthCode(session comm.IUserSession, req *pb.UserBindAut
return return
} }
if addVipDay > 0 || addTradeSec > 0 || addMeetSec > 0 { if addVipDay > 0 || addCompute > 0 {
_ = this.module.model.addIntegralLog(&pb.DBUserUseLog{ _ = this.module.model.addIntegralLog(&pb.DBUserUseLog{
Uid: user.Uid, Uid: user.Uid,
Logtype: pb.UserLogType_ActivityReward, Logtype: pb.UserLogType_ActivityReward,
Ts: now, Ts: now,
Addvipday: addVipDay, Addvipday: addVipDay,
Addtradesecond: addTradeSec, Compute: addCompute,
Addmeetsecond: addMeetSec,
Extra: code.Code, Extra: code.Code,
}) })
} }

149
apps/services/modules/user/api_binddevice.go

@ -184,7 +184,10 @@ func (this *apiComp) BindDevice(session comm.IUserSession, req *pb.UserBindDevic
// 渠道商归属来自授权码(生产时定下),绑定即固化到用户设备上, // 渠道商归属来自授权码(生产时定下),绑定即固化到用户设备上,
// 后续该用户的充值/消耗埋点都按它归到对应渠道商名下(统计与月结算的依据)。 // 后续该用户的充值/消耗埋点都按它归到对应渠道商名下(统计与月结算的依据)。
Channelid: model.Channelid, Channelid: model.Channelid,
Devicetype: product.Devicetype, // devicetype 不再从产品上抄:产品的「设备类型」已于 2026-09-08 废弃
// (pb 里降级成 gorm:"-" 的老客户端兼容字段,恒为 0,见 db.proto)。
// userdevice 上这一列暂时保留,存量行的值原样不动,新行为 0(客户端「我的设备」
// 页会显示成「未知设备」)。等客户端那边一并改掉分类展示后再删列。
Devicename: req.Devicename, Devicename: req.Devicename,
Devicemac: req.Devicemac, Devicemac: req.Devicemac,
// 存授权码行的主键(model.Code),不是客户端传上来的 req.Code: // 存授权码行的主键(model.Code),不是客户端传上来的 req.Code:
@ -204,6 +207,11 @@ func (this *apiComp) BindDevice(session comm.IUserSession, req *pb.UserBindDevic
// 历史设备漏记 usedtime 是失误,从此补上;仅在 0→1 首激时写,换绑/接管不覆盖原始激活日。 // 历史设备漏记 usedtime 是失误,从此补上;仅在 0→1 首激时写,换绑/接管不覆盖原始激活日。
if codestate == 0 { if codestate == 0 {
model.Usedtime = time.Now().Unix() model.Usedtime = time.Now().Unix()
// 设备权益快照:VIP 天数 / 等级 / 赠送算力,激活这一刻从批次(回退产品)固化到设备行。
// 此后改批次配置只影响尚未激活的库存设备。**不写用户表**——设备 VIP 由 usedtime+vipdays 算出,
// 设备算力记在 computegrant/computeused 上,谁绑定谁享有剩余部分,解绑无需回收。
model.Vipdays, model.Viplevel, model.Computegrant = this.resolveBindRewards(model, product)
model.Computeused = 0
} }
if err = this.module.model.saveFactoryDevice(model); err != nil { if err = this.module.model.saveFactoryDevice(model); err != nil {
errdata = &pb.ErrorData{ errdata = &pb.ErrorData{
@ -222,7 +230,6 @@ func (this *apiComp) BindDevice(session comm.IUserSession, req *pb.UserBindDevic
// 记录最后绑定的产品ID与渠道商:后续充值/消耗埋点按它们归属统计与结算 // 记录最后绑定的产品ID与渠道商:后续充值/消耗埋点按它们归属统计与结算
user.Lastbindproductid = product.Id user.Lastbindproductid = product.Id
user.Lastbindchannelid = model.Channelid user.Lastbindchannelid = model.Channelid
userSaved := false
// 设备激活 + 绑定埋点。 // 设备激活 + 绑定埋点。
// 激活是「设备一生一次」的事件:只在 codestate==0 首激时记,换绑/接管不再计—— // 激活是「设备一生一次」的事件:只在 codestate==0 首激时记,换绑/接管不再计——
// 与上面 model.Usedtime 只在 0→1 时写保持同一口径,否则激活数被换绑次数放大。 // 与上面 model.Usedtime 只在 0→1 时写保持同一口径,否则激活数被换绑次数放大。
@ -236,139 +243,103 @@ func (this *apiComp) BindDevice(session comm.IUserSession, req *pb.UserBindDevic
this.module.Debug("BindDevice", this.module.Debug("BindDevice",
log.Field{Key: "Isgiveaway", Value: user.Isgiveaway}, log.Field{Key: "Isgiveaway", Value: user.Isgiveaway},
log.Field{Key: "Iscanbindagain", Value: product.Iscanbindagain}, log.Field{Key: "Iscanbindagain", Value: product.Iscanbindagain},
log.Field{Key: "codestate", Value: codestate}) log.Field{Key: "codestate", Value: codestate},
// 设备权益只发给**第一个绑定这台设备的人**,判据是 codestate==0(这台设备 log.Field{Key: "vipdays", Value: model.Vipdays},
// 此前从未被绑过)。 log.Field{Key: "computegrant", Value: model.Computegrant})
// 2026-09 起绑定**不再往用户表累加**任何权益:设备赠送的 VIP 与算力都挂在设备行上(见上面快照),
// 判定时按「当前绑定着且未到期」算。原来把天数加进 user.vipexptime 的做法一旦累加就与自购
// 混成同一个数字,解绑时拆不开;三个额度桶也已停用。
// //
// ⚠️ 原条件是 `!user.Isgiveaway || codestate == 0`,多出来的 // `user.Isgiveaway = true` 仍然要写:厂家公码(api_bindauthcode.go)拿它当防重领标记。
// `!user.Isgiveaway` 那一支是个口子:只要这个**用户**没领过绑定礼,哪怕设备
// 是二手的(codestate!=0,已经被别人绑过、权益也已经发出去过一次),照样再发
// 一整份。等于一台设备转手几次就能送出去几份 VIP/翻译/会议时长。
//
// codestate 是可靠判据:解绑处 `model.Status = 0` 是**注释掉的**
// (api_unbinddevice.go,只清 Uid),status 一旦 0→1 就回不去,
// 所以「绑了解、解了绑」刷不出第二份。
//
// `user.Isgiveaway = true` 仍然要写:厂家公码(api_bindauthcode.go)拿它当
// 防重领标记,见 model_user.go 的说明。把它一起删掉会让绑过设备的用户
// 还能再领一次公码。
if codestate == 0 { if codestate == 0 {
Isgiveaway = true Isgiveaway = true
user.Isgiveaway = true user.Isgiveaway = true
// 绑定赠送:优先按【设备所属生产批次】发(后台「MAC生成管理 → 生成MAC」里一批一配), }
// 批次没配(三项全 0 / 取不到批次 / 存量码没有批次号)才回退到产品档案上的旧字段。 if err = this.module.model.save(user); err != nil {
vipDays, transMin, meetMin := this.resolveBindRewards(model, product)
var addVipDay, addTrade, addMeet int64
if vipDays > 0 {
// ⚠️ 这里以前写死 90 天,配多少都发 90——product.Bindrewardvpitime 实际只当了开关用。
// 现在按配置的天数发,批次里填几天就是几天。
addVipDay = int64(vipDays)
addSec := addVipDay * 24 * 60 * 60
if user.Vipexptime == 0 || user.Vipexptime < time.Now().Unix() { //重置
user.Vipexptime = time.Now().Unix() + addSec
} else { //累加
user.Vipexptime = user.Vipexptime + addSec
}
}
// translate/meeting 配置为分钟,积分按秒存储
if transMin > 0 {
addTrade = int64(transMin) * 60
user.Tradeintegral += addTrade
user.Tradetotalintegral += addTrade
}
if meetMin > 0 {
addMeet = int64(meetMin) * 60
user.Meetintegral += addMeet
user.Meettotalintegral += addMeet
}
err = this.module.model.save(user)
if err != nil {
errdata = &pb.ErrorData{ errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError, Code: pb.ErrorCode_DBError,
Message: err.Error(), Message: err.Error(),
} }
return return
} }
userSaved = true if codestate == 0 && (model.Vipdays > 0 || model.Computegrant > 0) {
// 记录绑定设备奖励流水 // 首激流水:记录这台设备带了什么(不是发给用户的,是设备自己的权益)
if addVipDay > 0 || addTrade > 0 || addMeet > 0 {
_ = this.module.model.addIntegralLog(&pb.DBUserUseLog{ _ = this.module.model.addIntegralLog(&pb.DBUserUseLog{
Uid: session.GetUserId(), Uid: session.GetUserId(),
Logtype: pb.UserLogType_ActivityReward, Logtype: pb.UserLogType_ActivityReward,
Ts: time.Now().Unix(), Ts: time.Now().Unix(),
Addvipday: addVipDay, Addvipday: int64(model.Vipdays),
Addtradesecond: addTrade, Compute: model.Computegrant,
Addmeetsecond: addMeet, Devicemac: model.Devicemac,
Extra: req.Code, // 绑定时使用的设备/授权码 Extra: "device activate grant",
}) })
// 绑定赠送资源埋点
if this.module.analyze != nil { if this.module.analyze != nil {
this.module.analyze.Report(&comm.StatEvent{ this.module.analyze.Report(&comm.StatEvent{
Type: comm.StatEventResourceGrant, Type: comm.StatEventResourceGrant,
ProductId: product.Id, ProductId: product.Id,
ChannelId: model.Channelid, ChannelId: model.Channelid,
Uid: session.GetUserId(), Uid: session.GetUserId(),
GrantVipDay: addVipDay, GrantVipDay: int64(model.Vipdays),
GrantTradeSecond: addTrade,
GrantMeetSecond: addMeet,
}) })
} }
} }
}
// 首绑送资源分支未触发时,仍需持久化 lastbindproductid / lastbindchannelid
if !userSaved {
if err = this.module.model.save(user); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
return
}
}
// 老客户端仍按 viplv/vipexptime 判会员,这里回**合并后的有效值**(自购 或 设备赋予),
// 让它们不改代码也能看到设备带来的 VIP;三个积分字段回 0(已停用)。
vip, _, verr := comm.ResolveUserVip(session.GetUserId(), user)
if verr != nil {
this.module.Warn("BindDevice: VIP 合并判定失败", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: verr.Error()})
vip = comm.ResolveVip(user, []*pb.DBAuthCode{model}, time.Now().Unix())
}
resp = &pb.UserBindDeviceResp{ resp = &pb.UserBindDeviceResp{
Device: divice, Device: divice,
Isgiveaway: Isgiveaway, Isgiveaway: Isgiveaway,
Viplv: user.Viplv, Viplv: vip.Level,
Vipexptime: user.Vipexptime, Vipexptime: vip.ExpTime,
Tradeintegral: user.Tradeintegral, Devicevipexptime: comm.DeviceVipExpire(model),
Tradetotalintegral: user.Tradetotalintegral, Deviceviplevel: comm.DeviceVipLevel(model),
Meetintegral: user.Meetintegral, Devicecomputeleft: comm.DeviceComputeLeft(model),
Meettotalintegral: user.Meettotalintegral,
} }
return return
} }
// resolveBindRewards 决定这台设备绑定时送什么:VIP天数 / 翻译分钟 / 会议分钟。 // resolveBindRewards 决定这台设备激活时带什么:VIP 天数 / VIP 等级 / 赠送算力。
// //
// 取值顺序: // 取值顺序:
// 1. 设备所属生产批次的 rewardvip/rewardtranslate/rewardmeeting——同一款产品不同批次 // 1. 设备所属生产批次(后台「MAC生成管理 → 生成MAC」里一批一配);
// (不同渠道、不同活动)送的东西经常不一样,配置点在后台「生成MAC」表单上; // 2. 批次 VIP 天数与算力**都为 0**,或批次取不到(存量码没有批次号、批次被撤销)→ 回退产品档案上的字段。
// 2. 批次三项全为 0,或批次取不到(存量授权码没有 batchno、批次被撤销)→ 回退产品档案上的旧字段。
// //
// ⚠️ 判「批次是否配过」用的是三项全 0,不是「批次存在」:新批次默认三项都是 0, // ⚠️ 判「批次是否配过」用的是两项全 0,不是「批次存在」:新批次默认全 0,若按存在与否判定,
// 若按「批次存在就用批次的值」,所有新批次都会变成不送任何东西,而产品上原本配好的赠送 // 所有新批次都会静默变成不送任何东西。
// 会在不改任何配置的情况下静默失效。
// //
// ⚠️ 取批次失败只回退、不报错:赠送是绑定的附带动作,为了它把绑定整个失败掉不划算。 // ⚠️ 取批次失败只回退、不报错:赠送是绑定的附带动作,为了它把绑定整个失败掉不划算。
func (this *apiComp) resolveBindRewards(code *pb.DBAuthCode, product *pb.DBProduct) (vipDays, translateMin, meetingMin uint32) { //
// 产品档案上的 bindrewardvpitime 历史上是**开关**(后台勾选写 1,绑定时发 90 天)。为了不让存量
// 产品在升级后从 90 天变成 1 天,这里保留「1 → 90」的映射;后台改成按天数填之后,填 90 就是 90。
func (this *apiComp) resolveBindRewards(code *pb.DBAuthCode, product *pb.DBProduct) (vipDays, vipLevel int32, compute int64) {
if code != nil { if code != nil {
if batch, err := this.module.model.getProductionBatch(code.Batchno); err == nil && batch != nil { if batch, err := this.module.model.getProductionBatch(code.Batchno); err == nil && batch != nil {
if batch.Rewardvip > 0 || batch.Rewardtranslate > 0 || batch.Rewardmeeting > 0 { if batch.Rewardvip > 0 || batch.Computegrant > 0 {
return batch.Rewardvip, batch.Rewardtranslate, batch.Rewardmeeting lv := batch.Viplevel
if lv <= 0 {
lv = 1
}
return int32(batch.Rewardvip), lv, batch.Computegrant
} }
} }
} }
if product == nil { if product == nil {
return 0, 0, 0 return 0, 0, 0
} }
// 产品档案上的旧字段:Bindrewardvpitime 历史上只当开关用(非 0 就发 90 天), vipDays = int32(product.Bindrewardvpitime)
// 这里保持那个语义,免得存量产品的赠送天数在升级后变样。 if vipDays == 1 {
vipDays = product.Bindrewardvpitime vipDays = 90 // 存量「开关」语义
if vipDays > 0 { }
vipDays = 90 vipLevel = product.Bindviplevel
if vipDays > 0 && vipLevel <= 0 {
vipLevel = 1
} }
return vipDays, product.Bindrewardtranslate, product.Bindrewardmeeting return vipDays, vipLevel, product.Bindcompute
} }
// alreadyBound 判断用户已有的这条绑定是不是本次要绑的同一台设备。 // alreadyBound 判断用户已有的这条绑定是不是本次要绑的同一台设备。

93
apps/services/modules/user/api_getcompute.go

@ -0,0 +1,93 @@
package user
import (
"time"
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/lego/sys/mysql"
"yunyan/pb"
)
// @Summary 获取会员与算力状态
// @Description 客户端「用量」页一次拿齐:VIP(自购 / 设备赋予合并后)、算力(设备剩余 + 账户余额 + 累计/本月消耗)、换算系数。客户端不做任何换算。
// @Tags User
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param user body pb.UserGetComputeReq true "无参数"
// @Success 200 {object} comm.HttpResult{data=pb.UserGetComputeResp} "成功返回"
// @Router /api/home/user_getcompute [post]
func (this *apiComp) GetCompute(session comm.IUserSession, req *pb.UserGetComputeReq) (resp *pb.UserGetComputeResp, errdata *pb.ErrorData) {
uid := session.GetUserId()
user, err := this.module.model.findforid(uid)
if err != nil {
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
return
}
vip, devices, err := comm.ResolveUserVip(uid, user)
if err != nil {
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
return
}
// 设备名从绑定记录上取(device_mac 行上没有名字)
names := map[string]string{}
if uds, e := this.module.model.getDevices(uid); e == nil {
for _, ud := range uds {
names[ud.Devicemac] = ud.Devicename
}
}
vipInfo := &pb.UserComputeVipInfo{
Level: vip.Level,
Exptime: vip.ExpTime,
Selfexptime: vip.SelfExpTime,
Devices: make([]*pb.UserDeviceVipInfo, 0, len(devices)),
}
var deviceLeft int64
for _, d := range devices {
deviceLeft += comm.DeviceComputeLeft(d)
vipInfo.Devices = append(vipInfo.Devices, &pb.UserDeviceVipInfo{
Devicemac: d.Devicemac,
Devicename: names[d.Devicemac],
Level: comm.DeviceVipLevel(d),
Exptime: comm.DeviceVipExpire(d),
Computegrant: d.Computegrant,
Computeused: d.Computeused,
})
}
stats, err := this.module.model.getStatistics(uid)
if err != nil && err != mysql.ErrNoDocuments {
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
return
}
monthUsed, merr := this.module.model.sumComputeUsedSince(uid, monthStart(time.Now()))
if merr != nil {
// 本月消耗只是展示项,算不出来不该让整个接口失败
this.module.Warn("GetCompute: 本月消耗汇总失败", log.Field{Key: "uid", Value: uid}, log.Field{Key: "err", Value: merr.Error()})
}
rates := comm.LoadComputeRates()
ops := comm.LoadOpsParams()
resp = &pb.UserGetComputeResp{
Vip: vipInfo,
Compute: &pb.UserComputeInfo{
Deviceleft: deviceLeft,
Userbalance: user.Computebalance,
Totalused: stats.Computetotal,
Monthused: monthUsed,
},
Rates: &pb.UserComputeRates{
Translate: rates.Translate,
Meeting: rates.Meeting,
Aichat: rates.AIChat,
Gate: rates.Gate,
Vipwarndays: ops.VipWarnDays,
Computewarn: ops.ComputeWarn,
},
}
return
}
// monthStart 本地时区当月 1 日 0 点。
func monthStart(t time.Time) int64 {
y, m, _ := t.Date()
return time.Date(y, m, 1, 0, 0, 0, 0, t.Location()).Unix()
}

7
apps/services/modules/user/api_getinfo.go

@ -73,6 +73,13 @@ func (this *apiComp) GetInfo(session comm.IUserSession, req *pb.UserGetInfoReq)
// if user.Avatar != "" { // if user.Avatar != "" {
// user.Avatar = aes.AesDecryptCBC(user.Avatar, this.options.CBCKey) // user.Avatar = aes.AesDecryptCBC(user.Avatar, this.options.CBCKey)
// } // }
// 老客户端只认 user.viplv / user.vipexptime,这里把**合并后的有效值**(自购 或 设备赋予)
// 覆盖到响应对象上——上面的 save 已经落库完,这次改动只进响应、不再写库。
// 库里 vipexptime 始终只记自购部分(见 comm.ResolveVip)。
if vip, _, verr := comm.ResolveUserVip(user.Uid, user); verr == nil {
user.Viplv = vip.Level
user.Vipexptime = vip.ExpTime
}
resp = &pb.UserGetInfoResp{ resp = &pb.UserGetInfoResp{
User: user, User: user,
Devices: divices, Devices: divices,

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

@ -241,10 +241,12 @@ func (this *apiComp) Sgin(session comm.IUserSession, req *pb.UserSginReq) (resp
// if user.Avatar != "" { // if user.Avatar != "" {
// user.Avatar = aes.AesEncryptCBC(user.Avatar, this.options.CBCKey) // user.Avatar = aes.AesEncryptCBC(user.Avatar, this.options.CBCKey)
// } // }
// 新用户开户礼:30 天 VIP + 翻译/会议各 100 分钟 + 智能体 100 次。 // 新用户开户礼:VIP 天数 + 算力,两个数额由后台「会员与算力」页配置。
// 在 add 之前赋值,随用户一起落库,不需要额外一次写。见 newuser_gift.go。 // 在 add 之前赋值,随用户一起落库,不需要额外一次写。见 newuser_gift.go。
// 参数取一次传给发放与流水两处,保证两者用同一份快照。
giftNow := time.Now().Unix() giftNow := time.Now().Unix()
applyNewUserGift(user, giftNow) giftParams := comm.LoadOpsParams()
applyNewUserGift(user, giftNow, giftParams)
if err = this.module.model.add(user); err != nil { if err = this.module.model.add(user); err != nil {
errdata = &pb.ErrorData{ errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError, Code: pb.ErrorCode_DBError,
@ -252,7 +254,7 @@ func (this *apiComp) Sgin(session comm.IUserSession, req *pb.UserSginReq) (resp
} }
return return
} }
logNewUserGift(user.Uid, giftNow) logNewUserGift(user.Uid, giftNow, giftParams)
// 新增用户埋点 // 新增用户埋点
if this.module.analyze != nil { if this.module.analyze != nil {
this.module.analyze.Report(&comm.StatEvent{Type: comm.StatEventRegister, Uid: user.Uid}) this.module.analyze.Report(&comm.StatEvent{Type: comm.StatEventRegister, Uid: user.Uid})

7
apps/services/modules/user/api_translate.go

@ -36,6 +36,13 @@ func (this *apiComp) Translate(session comm.IUserSession, req *pb.UserTranslateR
resp = &pb.UserTranslateResp{Text: req.Text} resp = &pb.UserTranslateResp{Text: req.Text}
return return
} }
// VIP 是翻译的准入闸门(自购 或 当前绑定设备赋予,见 comm.ResolveVip)。
// 文本/图片翻译与三段式(同传/面对面)的机器翻译那一跳都经过这里,拦住即整体不可用;
// 通话翻译走端到端直连服务商,拦不到——它的服务端手段是不下发凭据(svcresolve.go)。
if vip, _, verr := comm.ResolveUserVip(session.GetUserId(), nil); verr == nil && !vip.Active(time.Now().Unix()) {
errdata = &pb.ErrorData{Code: pb.ErrorCode_VipRequired, Message: "翻译功能需要会员,请续费或绑定带会员的设备"}
return
}
svcId, provider, tr, err := this.module.translatemodel.resolveTranslator() svcId, provider, tr, err := this.module.translatemodel.resolveTranslator()
if err != nil { if err != nil {

148
apps/services/modules/user/api_usages.go

@ -1,14 +1,15 @@
package user package user
import ( import (
"time"
"yunyan/comm" "yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/lego/sys/mysql" "yunyan/lego/sys/mysql"
"yunyan/pb" "yunyan/pb"
"time"
) )
// @Summary 使用资源请求 // @Summary 使用资源请求
// @Description 使用资源请求 // @Description 上报一次用量(翻译秒数 / 会议秒数 / AI 对话字数)。服务端记原始统计,并按后台换算系数折算成算力做记账。
// @Tags User // @Tags User
// @Accept json // @Accept json
// @Produce json // @Produce json
@ -16,28 +17,30 @@ import (
// @Param user body pb.UserUsagesReq true "使用资源请求" // @Param user body pb.UserUsagesReq true "使用资源请求"
// @Success 200 {object} comm.HttpResult{data=pb.UserUsagesResp} "成功返回" // @Success 200 {object} comm.HttpResult{data=pb.UserUsagesResp} "成功返回"
// @Router /api/home/user_usages [post] // @Router /api/home/user_usages [post]
//
// 2026-09 起本接口**不再扣三个额度桶、不再因余额不足拒绝**——功能准入只看 VIP(见 comm.ResolveVip),
// 用量统一折算成算力(见 comm.ApplyComputeUsage):先扣设备赠送、再扣用户余额;
// 算力闸门(COMPUTE_GATE)默认关,余额不够也放行、差额记为超额。
//
// ⚠️ 客户端只上报**原始量**(秒 / 字符数),不上报算力:换算系数在后台可改,客户端自己算的话
// 改一次系数就要发一次版。客户端拿到的算力是本接口**返回**的。
func (this *apiComp) Usages(session comm.IUserSession, req *pb.UserUsagesReq) (resp *pb.UserUsagesResp, errdata *pb.ErrorData) { func (this *apiComp) Usages(session comm.IUserSession, req *pb.UserUsagesReq) (resp *pb.UserUsagesResp, errdata *pb.ErrorData) {
var ( var (
user *pb.DBUser user *pb.DBUser
statistics *pb.DBUserStatistics statistics *pb.DBUserStatistics
usages map[int32]int64
userlog *pb.DBUserUseLog userlog *pb.DBUserUseLog
kind comm.ComputeUsageKind
raw int64
err error err error
) )
user, err = this.module.model.findforid(session.GetUserId()) user, err = this.module.model.findforid(session.GetUserId())
if err != nil { if err != nil {
errdata = &pb.ErrorData{ errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
return return
} }
statistics, err = this.module.model.getStatistics(session.GetUserId()) statistics, err = this.module.model.getStatistics(session.GetUserId())
if err != nil && err != mysql.ErrNoDocuments { if err != nil && err != mysql.ErrNoDocuments {
errdata = &pb.ErrorData{ errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
return return
} }
userlog = &pb.DBUserUseLog{ userlog = &pb.DBUserUseLog{
@ -45,118 +48,97 @@ func (this *apiComp) Usages(session comm.IUserSession, req *pb.UserUsagesReq) (r
Logtype: pb.UserLogType_UserConsume, Logtype: pb.UserLogType_UserConsume,
Ts: time.Now().Unix(), Ts: time.Now().Unix(),
} }
usages = make(map[int32]int64) // ── 原始统计照记(现有字段不动,看板与结算还在用) ──
switch req.Usagetype { switch req.Usagetype {
case pb.UsageType_Trade: case pb.UsageType_Trade:
var totalCost int64 kind = comm.ComputeUsageTranslate
for _, v := range req.Usages { if v, ok := req.Usages["TRANSLATION_MODE_simultaneous"]; ok && v > 0 {
totalCost += v
}
if user.Tradeintegral < totalCost {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_TradeIntegralNotEnough,
Message: "翻译积分不足",
}
return
}
if v, ok := req.Usages["TRANSLATION_MODE_simultaneous"]; ok {
user.Tradeintegral -= v
statistics.Trademodel1Num += 1 statistics.Trademodel1Num += 1
statistics.Trademodel1Time += v statistics.Trademodel1Time += v
userlog.Addagentintegral += -1 * v raw += v
} }
if v, ok := req.Usages["TRANSLATION_MODE_faceToFace"]; ok { if v, ok := req.Usages["TRANSLATION_MODE_faceToFace"]; ok && v > 0 {
user.Tradeintegral -= v
statistics.Trademodel2Num += 1 statistics.Trademodel2Num += 1
statistics.Trademodel2Time += v statistics.Trademodel2Time += v
userlog.Addagentintegral += -1 * v raw += v
} }
if v, ok := req.Usages["TRANSLATION_MODE_call"]; ok { if v, ok := req.Usages["TRANSLATION_MODE_call"]; ok && v > 0 {
user.Tradeintegral -= v
statistics.Trademodel3Num += 1 statistics.Trademodel3Num += 1
statistics.Trademodel3Time += v statistics.Trademodel3Time += v
userlog.Addagentintegral += -1 * v raw += v
} }
if v, ok := req.Usages["TRANSLATION_MODE_audioVideo"]; ok { if v, ok := req.Usages["TRANSLATION_MODE_audioVideo"]; ok && v > 0 {
user.Tradeintegral -= v
statistics.Trademodel4Num += 1 statistics.Trademodel4Num += 1
statistics.Trademodel4Time += v statistics.Trademodel4Time += v
userlog.Addagentintegral += -1 * v raw += v
} }
usages[int32(req.Usagetype)] = user.Tradeintegral // 原实现把翻译秒数写进了 Addagentintegral(AI 那一列),这里改回正确的列。
userlog.Addtradesecond = -raw
case pb.UsageType_Meet: case pb.UsageType_Meet:
if v, ok := req.Usages["MEETING_ASR_AUDIO_SECONDS"]; ok { kind = comm.ComputeUsageMeeting
if user.Meetintegral < v { if v, ok := req.Usages["MEETING_ASR_AUDIO_SECONDS"]; ok && v > 0 {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_MeetIntegralNotEnough,
Message: "会议积分不足",
}
return
}
user.Meetintegral -= v
statistics.Meetnum += 1 statistics.Meetnum += 1
statistics.Meettime += v statistics.Meettime += v
userlog.Addmeetsecond += -1 * v raw = v
userlog.Addmeetsecond = -v
} }
usages[int32(req.Usagetype)] = user.Meetintegral
case pb.UsageType_AI: case pb.UsageType_AI:
if user.Aichatintegral < 1 { kind = comm.ComputeUsageAIChat
errdata = &pb.ErrorData{
Code: pb.ErrorCode_AIIntegralNotEnough,
Message: "AI积分不足",
}
return
}
user.Aichatintegral -= 1
usages[int32(req.Usagetype)] = user.Aichatintegral
statistics.Aichatnum += 1 statistics.Aichatnum += 1
usages[int32(req.Usagetype)] = user.Aichatintegral // 这两列名字里带 token,但存的是**字符数**(Unicode 码点),与大模型自己的 tokenizer 无关。
userlog.Addagentintegral += -1 // 字段早就有、一直没人写;老客户端不传字数时 raw=0 → 本次不扣算力、只记一次。
statistics.Aichatuptoken += req.Upchars
statistics.Aichatdowntoken += req.Downchars
raw = req.Upchars + req.Downchars
userlog.Addagentintegral = -1
default:
errdata = &pb.ErrorData{Code: pb.ErrorCode_ReqParameterError, Message: "未知的用量类型"}
return
} }
err = this.module.model.save(user) // ── 算力记账 ──
res, err := comm.ApplyComputeUsage(session.GetUserId(), kind, raw, userlog, statistics)
if err != nil { if err != nil {
errdata = &pb.ErrorData{ this.module.Error("Usages: 算力记账失败",
Code: pb.ErrorCode_DBError, log.Field{Key: "uid", Value: session.GetUserId()},
Message: err.Error(), log.Field{Key: "kind", Value: int(kind)},
} log.Field{Key: "raw", Value: raw},
log.Field{Key: "err", Value: err.Error()})
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
return return
} }
err = this.module.model.updateStatistics(statistics) if res.Rejected {
if err != nil { errdata = &pb.ErrorData{Code: pb.ErrorCode_ComputeNotEnough, Message: "算力不足"}
errdata = &pb.ErrorData{ return
Code: pb.ErrorCode_DBError,
Message: err.Error(),
} }
if err = this.module.model.updateStatistics(statistics); err != nil {
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
return return
} }
if err = this.module.model.addIntegralLog(userlog); err != nil { if err = this.module.model.addIntegralLog(userlog); err != nil {
errdata = &pb.ErrorData{ errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
Code: pb.ErrorCode_DBError,
Message: err.Error(),
}
return return
} }
// 资源消耗埋点 // 资源消耗埋点(口径不变)
if this.module.analyze != nil { if this.module.analyze != nil {
switch req.Usagetype { switch req.Usagetype {
case pb.UsageType_Trade: case pb.UsageType_Trade:
var sec int64 this.module.analyze.Report(&comm.StatEvent{Type: comm.StatEventTranslate, ProductId: user.Lastbindproductid, ChannelId: user.Lastbindchannelid, Uid: session.GetUserId(), Second: raw})
for _, v := range req.Usages {
sec += v
}
this.module.analyze.Report(&comm.StatEvent{Type: comm.StatEventTranslate, ProductId: user.Lastbindproductid, ChannelId: user.Lastbindchannelid, Uid: session.GetUserId(), Second: sec})
case pb.UsageType_Meet: case pb.UsageType_Meet:
if v, ok := req.Usages["MEETING_ASR_AUDIO_SECONDS"]; ok { this.module.analyze.Report(&comm.StatEvent{Type: comm.StatEventMeeting, ProductId: user.Lastbindproductid, ChannelId: user.Lastbindchannelid, Uid: session.GetUserId(), Second: raw})
this.module.analyze.Report(&comm.StatEvent{Type: comm.StatEventMeeting, ProductId: user.Lastbindproductid, ChannelId: user.Lastbindchannelid, Uid: session.GetUserId(), Second: v})
}
case pb.UsageType_AI: case pb.UsageType_AI:
this.module.analyze.Report(&comm.StatEvent{Type: comm.StatEventAiChat, ProductId: user.Lastbindproductid, ChannelId: user.Lastbindchannelid, Uid: session.GetUserId()}) this.module.analyze.Report(&comm.StatEvent{Type: comm.StatEventAiChat, ProductId: user.Lastbindproductid, ChannelId: user.Lastbindchannelid, Uid: session.GetUserId()})
} }
} }
// 兼容:老客户端按 usages[usagetype] 读「剩余量」。三桶已停用,这里回算力剩余合计而不是 0——
// 老包若在本地拿剩余量做闸门,回 0 会把它锁死。单位对不上是过渡期的已知代价。
left := res.DeviceLeft + res.UserLeft
resp = &pb.UserUsagesResp{ resp = &pb.UserUsagesResp{
Usages: usages, Usages: map[int32]int64{int32(req.Usagetype): left},
Compute: res.Compute,
Computedeviceleft: res.DeviceLeft,
Computeuserleft: res.UserLeft,
Over: res.Over,
} }
return return
} }

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

@ -29,6 +29,6 @@ func (this *apiV2Comp) GetThirdSvcs(session comm.IUserSession, req *pb.UserGetTh
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()} errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
return return
} }
resp = &pb.UserGetThirdSvcsResp{Svcs: items} resp = &pb.UserGetThirdSvcsResp{Svcs: filterSvcsByVip(session.GetUserId(), items)}
return return
} }

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

@ -68,7 +68,7 @@ func (this *apiV3Comp) GetAppConfig(session comm.IUserSession, req *pb.UserGetAp
Env: env, Env: env,
Agents: agents, Agents: agents,
Products: products, Products: products,
Thirdsvcs: thirdsvcs, Thirdsvcs: filterSvcsByVip(session.GetUserId(), thirdsvcs),
Meet: this.meetCapabilities(), Meet: this.meetCapabilities(),
// 通话翻译:按上面解析出的区域筛好的选路规则 + 规则引用的翻译 agent(见 api_v3_calltranslate.go)。 // 通话翻译:按上面解析出的区域筛好的选路规则 + 规则引用的翻译 agent(见 api_v3_calltranslate.go)。
CallTranslate: this.callTranslateV3(region), CallTranslate: this.callTranslateV3(region),

5
apps/services/modules/user/model_config.go

@ -121,6 +121,11 @@ func (this *modelConfigComp) loaddb() (err error) {
this.agents = agents this.agents = agents
this.mcps = mcps this.mcps = mcps
this.lock.Unlock() this.lock.Unlock()
// 算力换算系数与运营参数也存 config 表,但走 comm 的独立缓存(业务侧各处直接调 comm.LoadXxx,
// 不经过这里的 this.config)。后台改完会广播到这里,顺手让那两份缓存失效,
// 否则要等它们自己的 30s TTL 过期才生效。
comm.InvalidateComputeRates()
comm.InvalidateOpsParams()
return return
} }

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

@ -1,6 +1,7 @@
package user package user
import ( import (
"yunyan/lego/sys/log"
"context" "context"
"fmt" "fmt"
"strings" "strings"
@ -40,10 +41,49 @@ func (this *modelUserComp) Init(service core.IService, module core.IModule, comp
if err = mysql.CreateTable(comm.TableUserUseLog, &pb.DBUserUseLog{}); err != nil { if err = mysql.CreateTable(comm.TableUserUseLog, &pb.DBUserUseLog{}); err != nil {
this.module.Errorln(err) this.module.Errorln(err)
} }
this.migrateLegacyBucketsToCompute()
return return
} }
// legacyComputeMigratedKey config 表里的一次性迁移标记;存在即不再跑。
const legacyComputeMigratedKey = "COMPUTE_LEGACY_MIGRATED"
// migrateLegacyBucketsToCompute 把存量用户的三个旧额度桶(翻译秒 / 会议秒 / AI 次数)按换算系数
// 折成算力并入 computebalance。只跑一次(config 表打标记),旧列**保留不清**——
// 它们已不参与任何判定,清掉是不可逆操作,没必要冒险;老客户端还会把它们当「剩余分钟」显示一阵子。
//
// 一条 SQL 完成,避免逐行读改写在大表上拖慢启动。系数取此刻生效值(首次多半是默认 1)。
func (this *modelUserComp) migrateLegacyBucketsToCompute() {
marker := &pb.DBAppConfigItem{}
if err := mysql.FindOne(comm.TableAppConfig, marker, "`key`=?", legacyComputeMigratedKey); err == nil {
return
} else if err != mysql.ErrNoDocuments {
this.module.Warn("算力迁移:读标记失败,本次跳过", log.Field{Key: "err", Value: err.Error()})
return
}
r := comm.LoadComputeRates()
res := mysql.Exec(
"UPDATE "+comm.TableUser+" SET computebalance = computebalance"+
" + CEIL(GREATEST(tradeintegral,0)/60)*?"+
" + CEIL(GREATEST(meetintegral,0)/60)*?"+
" + GREATEST(aichatintegral,0)"+
" WHERE tradeintegral > 0 OR meetintegral > 0 OR aichatintegral > 0",
r.Translate, r.Meeting)
if res.Error != nil {
this.module.Error("算力迁移:折算存量额度失败", log.Field{Key: "err", Value: res.Error.Error()})
return
}
if err := mysql.Insert(comm.TableAppConfig, &pb.DBAppConfigItem{
Group: "compute", Key: legacyComputeMigratedKey, Value: "1",
Description: "存量三桶已折算进算力(自动迁移标记,勿删)",
}); err != nil {
this.module.Warn("算力迁移:写标记失败,下次启动会重复折算!", log.Field{Key: "err", Value: err.Error()})
return
}
this.module.Infof("算力迁移:已把 %d 个用户的存量额度折算进 computebalance", res.RowsAffected)
}
func (this *modelUserComp) findforid(uid string) (user *pb.DBUser, err error) { func (this *modelUserComp) findforid(uid string) (user *pb.DBUser, err error) {
user = &pb.DBUser{} user = &pb.DBUser{}
err = mysql.FindOne(comm.TableUser, user, "uid=?", uid) err = mysql.FindOne(comm.TableUser, user, "uid=?", uid)
@ -488,3 +528,14 @@ func (this *modelUserComp) rollbackActivateCode(uid string) (affected int64, err
err = tx.Error err = tx.Error
return return
} }
// sumComputeUsedSince 从 since 起的算力消耗合计(流水里消耗记为负数,这里取绝对值)。
func (this *modelUserComp) sumComputeUsedSince(uid string, since int64) (used int64, err error) {
var total struct{ Total int64 }
err = mysql.Table(comm.TableUserUseLog).
Select("COALESCE(SUM(-compute),0) AS total").
Where("uid=? AND ts>=? AND logtype=? AND compute<0", uid, since, int32(pb.UserLogType_UserConsume)).
Scan(&total).Error
used = total.Total
return
}

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

@ -450,6 +450,71 @@ func TestFactoryidNotPersisted(t *testing.T) {
} }
} }
// devicetype 同样是纯下发字段(2026-09-08 废弃):产品上的「设备类型」已删除,
// pb 里只留一个恒为 0 的兼容字段给老 App 包解析用。gorm 必须完全忽略它,
// 否则 AutoMigrate 会把刚 DROP 掉的列又建回来。
func TestDevicetypeNotPersisted(t *testing.T) {
s, err := schema.Parse(&pb.DBProduct{}, &sync.Map{}, schema.NamingStrategy{})
if err != nil {
t.Fatal(err)
}
if f := s.LookUpField("devicetype"); f != nil {
t.Fatalf("devicetype 被 gorm 当成了库字段(列 %s),应由 gorm:\"-\" 忽略", f.DBName)
}
}
// 老客户端按「非空 int」解析 devicetype,字段不下发即 TypeError、整个响应解析失败。
// 所以哪怕它已废弃,JSON 里也必须始终有这个键(值恒为 0)。
func TestDevicetypeStillEmittedInJSON(t *testing.T) {
b, err := json.Marshal(&pb.DBProduct{Id: 1})
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(b, &m); err != nil {
t.Fatal(err)
}
v, ok := m["devicetype"]
if !ok {
t.Fatal("下发的产品 JSON 里缺少 devicetype 键——已发布的 App 包会整个解析失败")
}
if v != float64(0) {
t.Fatalf("devicetype 应恒为 0,实际 %v", v)
}
}
// scanuuid 与 devicetype 同一处理(2026-09-08 废弃):设备校验与绑定奖励都改成按 MAC 判定,
// 这个 UUID 不再有任何用途,库列已删,pb 里只留一个 gorm:"-" 的下发兼容字段。
func TestScanuuidNotPersisted(t *testing.T) {
s, err := schema.Parse(&pb.DBProduct{}, &sync.Map{}, schema.NamingStrategy{})
if err != nil {
t.Fatal(err)
}
if f := s.LookUpField("scanuuid"); f != nil {
t.Fatalf("scanuuid 被 gorm 当成了库字段(列 %s),应由 gorm:\"-\" 忽略", f.DBName)
}
}
// 老客户端按「非空 String」解析 scanuuid(`json['scanuuid'] as String`),
// 字段不下发即 TypeError、整个 user_getappconfig 解析失败。所以键必须始终在,值恒为空串。
func TestScanuuidStillEmittedInJSON(t *testing.T) {
b, err := json.Marshal(&pb.DBProduct{Id: 1})
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(b, &m); err != nil {
t.Fatal(err)
}
v, ok := m["scanuuid"]
if !ok {
t.Fatal("下发的产品 JSON 里缺少 scanuuid 键——已发布的 App 包会整个解析失败")
}
if v != "" {
t.Fatalf("scanuuid 应恒为空串,实际 %v", v)
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ⑥ 杂项(token / 加解密工具) // ⑥ 杂项(token / 加解密工具)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

49
apps/services/modules/user/newuser_gift.go

@ -11,54 +11,45 @@ import (
// ⚠️ 这是**临时**的拉新政策(2026-08-28 起)。要停掉就把 newUserGiftEnabled 置 false, // ⚠️ 这是**临时**的拉新政策(2026-08-28 起)。要停掉就把 newUserGiftEnabled 置 false,
// 或直接删掉 applyNewUserGift 的调用点(api_sgin.go 里创建用户那一处)。 // 或直接删掉 applyNewUserGift 的调用点(api_sgin.go 里创建用户那一处)。
// //
// 单位坑(三个额度桶并不同构,照抄会送错量级): // 2026-09 起三个额度桶停用,开户礼改成「VIP 天数 + 算力」(算力归账号、不过期,见 comm/compute.go)。
// - Tradeintegral / Meetintegral 是**秒**,所以「100 分钟」= 100*60; // 两个数额由**后台「会员与算力」页配置**(comm.LoadOpsParams),改了不用发版;
// - Aichatintegral 是**次数**,每次 AI 对话扣 1(见 api_usages.go 的 UsageType_AI 分支), // 配置表里没有这两行时回落到 comm 的默认值(30 天 + 100 算力)。
// 「分钟」在这个桶里没有意义,按 100 次给。 //
const ( // ⚠️ 两项都配 0 = 注册不送任何东西,这是合法配置而不是「没配」——所以 ParseOpsParams
newUserGiftEnabled = true // 区分「键不存在」与「值为 0」,别改成 `if v > 0` 那种写法。
const newUserGiftEnabled = true
newUserGiftVipDays = 30 // 赠送 VIP 天数
newUserGiftTradeMin = 100 // 翻译额度(分钟)——同传/面对面/通话/影音共用这一个桶
newUserGiftMeetMin = 100 // 会议额度(分钟)
newUserGiftAiTimes = 100 // 智能体额度(次数,不是分钟)
)
// applyNewUserGift 把开户礼写进尚未落库的新用户对象。 // applyNewUserGift 把开户礼写进尚未落库的新用户对象。
// 只在「用户不存在 → 新建」的分支调用,老用户不受影响。 // 只在「用户不存在 → 新建」的分支调用,老用户不受影响。
// //
// 直接赋值而非累加:这是刚 new 出来的对象,几个额度字段都还是零值。 // 直接赋值而非累加:这是刚 new 出来的对象,几个额度字段都还是零值。
func applyNewUserGift(user *pb.DBUser, now int64) { // p 由调用方传入(comm.LoadOpsParams())而不是在函数里各读一次:发放与流水必须用**同一份快照**,
// 否则两次读之间后台改了配置,就会出现「送的是 A、流水记的是 B」的对不上账。
func applyNewUserGift(user *pb.DBUser, now int64, p comm.OpsParams) {
if !newUserGiftEnabled || user == nil { if !newUserGiftEnabled || user == nil {
return return
} }
user.Vipexptime = now + int64(newUserGiftVipDays)*24*60*60 if p.NewUserGiftVipDays > 0 {
user.Vipexptime = now + int64(p.NewUserGiftVipDays)*24*60*60
tradeSec := int64(newUserGiftTradeMin) * 60 }
meetSec := int64(newUserGiftMeetMin) * 60 user.Computebalance = p.NewUserGiftCompute
user.Tradeintegral = tradeSec
user.Tradetotalintegral = tradeSec
user.Meetintegral = meetSec
user.Meettotalintegral = meetSec
user.Aichatintegral = int64(newUserGiftAiTimes)
user.Aichattotalintegral = int64(newUserGiftAiTimes)
} }
// logNewUserGift 记一条活动奖励流水,便于后台对账「送出去多少」。 // logNewUserGift 记一条活动奖励流水,便于后台对账「送出去多少」。
// 礼包已经随用户一起落库了,这条流水写失败不回滚、只忽略。 // 礼包已经随用户一起落库了,这条流水写失败不回滚、只忽略。
func logNewUserGift(uid string, now int64) { func logNewUserGift(uid string, now int64, p comm.OpsParams) {
if !newUserGiftEnabled { if !newUserGiftEnabled {
return return
} }
if p.NewUserGiftVipDays == 0 && p.NewUserGiftCompute == 0 {
return // 后台配成不送,就别记一条全 0 的流水
}
_ = mysql.Insert(comm.TableUserUseLog, &pb.DBUserUseLog{ _ = mysql.Insert(comm.TableUserUseLog, &pb.DBUserUseLog{
Uid: uid, Uid: uid,
Ts: now, Ts: now,
Logtype: pb.UserLogType_ActivityReward, Logtype: pb.UserLogType_ActivityReward,
Addvipday: int64(newUserGiftVipDays), Addvipday: int64(p.NewUserGiftVipDays),
Addtradesecond: int64(newUserGiftTradeMin) * 60, Compute: p.NewUserGiftCompute,
Addmeetsecond: int64(newUserGiftMeetMin) * 60,
Addagentintegral: int64(newUserGiftAiTimes),
Extra: "new user register gift", Extra: "new user register gift",
}) })
} }

59
apps/services/modules/user/newuser_gift_test.go

@ -3,33 +3,49 @@ package user
import ( import (
"testing" "testing"
"yunyan/comm"
"yunyan/pb" "yunyan/pb"
) )
// 守住单位口径:翻译/会议是**秒**,智能体是**次**。 // 开户礼 = VIP 天数 + 算力,两个数额由后台配置(comm.OpsParams)。
// 三个桶不同构,照抄隔壁桶的写法就会送错量级(比如给 AI 送 6000 次)。
func TestApplyNewUserGift(t *testing.T) { func TestApplyNewUserGift(t *testing.T) {
const now int64 = 1_700_000_000 const now int64 = 1_700_000_000
p := comm.DefaultOpsParams()
u := &pb.DBUser{} u := &pb.DBUser{}
applyNewUserGift(u, now) applyNewUserGift(u, now, p)
if got, want := u.Vipexptime, now+30*24*60*60; got != want { if got, want := u.Vipexptime, now+int64(p.NewUserGiftVipDays)*24*60*60; got != want {
t.Errorf("Vipexptime = %d, want %d(30 天)", got, want) t.Errorf("Vipexptime = %d, want %d(%d 天)", got, want, p.NewUserGiftVipDays)
} }
if got, want := u.Tradeintegral, int64(6000); got != want { if got, want := u.Computebalance, p.NewUserGiftCompute; got != want {
t.Errorf("Tradeintegral = %d, want %d(100 分钟 = 6000 秒)", got, want) t.Errorf("Computebalance = %d, want %d", got, want)
} }
if got, want := u.Meetintegral, int64(6000); got != want { // 旧三桶必须保持 0:它们已不是闸门,往里写值只会让老后台页面显示出「有余额」的假象
t.Errorf("Meetintegral = %d, want %d(100 分钟 = 6000 秒)", got, want) if u.Tradeintegral != 0 || u.Meetintegral != 0 || u.Aichatintegral != 0 {
t.Errorf("旧额度桶不应再发放: Trade=%d Meet=%d Ai=%d", u.Tradeintegral, u.Meetintegral, u.Aichatintegral)
} }
if got, want := u.Aichatintegral, int64(100); got != want {
t.Errorf("Aichatintegral = %d, want %d(100 **次**,不是秒)", got, want)
} }
// total 系列是「累计获得」,前端拿它算进度条,必须同步给上
if u.Tradetotalintegral != u.Tradeintegral || // 后台把两项都配成 0 = 注册不送任何东西。这是合法配置,不能被当成「没配」而回落到默认值。
u.Meettotalintegral != u.Meetintegral || func TestApplyNewUserGiftZeroMeansNoGift(t *testing.T) {
u.Aichattotalintegral != u.Aichatintegral { const now int64 = 1_700_000_000
t.Error("total 系列未与余额同步,前端进度条会算错") u := &pb.DBUser{}
applyNewUserGift(u, now, comm.OpsParams{NewUserGiftVipDays: 0, NewUserGiftCompute: 0})
if u.Vipexptime != 0 || u.Computebalance != 0 {
t.Errorf("配 0 应该什么都不送,得到 Vip=%d Compute=%d", u.Vipexptime, u.Computebalance)
}
}
// 只送算力、不送 VIP 也是合法配置。
func TestApplyNewUserGiftComputeOnly(t *testing.T) {
const now int64 = 1_700_000_000
u := &pb.DBUser{}
applyNewUserGift(u, now, comm.OpsParams{NewUserGiftVipDays: 0, NewUserGiftCompute: 500})
if u.Vipexptime != 0 {
t.Errorf("VIP 天数为 0 时不该设到期时间,得到 %d", u.Vipexptime)
}
if u.Computebalance != 500 {
t.Errorf("Computebalance = %d, want 500", u.Computebalance)
} }
} }
@ -38,12 +54,13 @@ func TestApplyNewUserGift(t *testing.T) {
// 万一将来有人挪去老用户路径,这个测试会提醒他先想清楚语义)。 // 万一将来有人挪去老用户路径,这个测试会提醒他先想清楚语义)。
func TestApplyNewUserGiftIsAssignNotAccumulate(t *testing.T) { func TestApplyNewUserGiftIsAssignNotAccumulate(t *testing.T) {
const now int64 = 1_700_000_000 const now int64 = 1_700_000_000
u := &pb.DBUser{Tradeintegral: 999, Aichatintegral: 7, Vipexptime: now + 12345} p := comm.DefaultOpsParams()
applyNewUserGift(u, now) u := &pb.DBUser{Computebalance: 999, Vipexptime: now + 12345}
if u.Tradeintegral != 6000 || u.Aichatintegral != 100 { applyNewUserGift(u, now, p)
t.Errorf("应为赋值语义,得到 Trade=%d Ai=%d", u.Tradeintegral, u.Aichatintegral) if u.Computebalance != p.NewUserGiftCompute {
t.Errorf("应为赋值语义,得到 Compute=%d", u.Computebalance)
} }
if u.Vipexptime != now+30*24*60*60 { if u.Vipexptime != now+int64(p.NewUserGiftVipDays)*24*60*60 {
t.Errorf("VIP 应为赋值语义,得到 %d", u.Vipexptime) t.Errorf("VIP 应为赋值语义,得到 %d", u.Vipexptime)
} }
} }

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

@ -1,6 +1,7 @@
package user package user
import ( import (
"time"
"os" "os"
"sort" "sort"
@ -96,3 +97,26 @@ func resolveThirdSvcs(app string, region pb.Region, ids []string) ([]*pb.ThirdSv
sort.Slice(items, func(i, j int) bool { return items[i].Id < items[j].Id }) sort.Slice(items, func(i, j int) bool { return items[i].Id < items[j].Id })
return items, nil return items, nil
} }
// filterSvcsByVip VIP 过期时把端到端翻译类(SvcCatAST)服务从下发列表里去掉。
//
// 通话翻译走阿里端到端,客户端拿着凭据直连服务商 WebSocket,整个过程一个请求都不经过我们的服务器,
// 事后也拦不住——这里是它**唯一**的服务端闸门。凭据是启动时拉一次的,VIP 在使用期间过期时客户端
// 手里的凭据仍然有效,直到下次重新拉配置;这个窗口(最长一个启动周期)已在设计上接受。
//
// VIP 判定失败(库抖动)按**有 VIP**放行:宁可多发一次凭据,也不能让付费用户因为一次查询失败用不了功能。
func filterSvcsByVip(uid string, items []*pb.ThirdSvcItem) []*pb.ThirdSvcItem {
vip, _, err := comm.ResolveUserVip(uid, nil)
if err != nil || vip.Active(time.Now().Unix()) {
return items
}
out := items[:0]
for _, it := range items {
if comm.CategoriesHas(it.Categories, comm.SvcCatAST) {
continue
}
out = append(out, it)
}
return out
}

24
apps/services/pb/api_msg.pb.go

@ -4471,7 +4471,9 @@ type ApiCreateAuthCodesReq struct {
Sharerate int32 `protobuf:"varint,11,opt,name=sharerate,proto3" json:"sharerate"` //@go_tags(`json:"sharerate"`) 本批次渠道分成比例(万分比 0~10000),生产时冻结进批次台账 Sharerate int32 `protobuf:"varint,11,opt,name=sharerate,proto3" json:"sharerate"` //@go_tags(`json:"sharerate"`) 本批次渠道分成比例(万分比 0~10000),生产时冻结进批次台账
Rewardvip uint32 `protobuf:"varint,12,opt,name=rewardvip,proto3" json:"rewardvip"` //@go_tags(`json:"rewardvip"`) 本批次绑定赠送VIP天数(0=不送) Rewardvip uint32 `protobuf:"varint,12,opt,name=rewardvip,proto3" json:"rewardvip"` //@go_tags(`json:"rewardvip"`) 本批次绑定赠送VIP天数(0=不送)
Rewardtranslate uint32 `protobuf:"varint,13,opt,name=rewardtranslate,proto3" json:"rewardtranslate"` //@go_tags(`json:"rewardtranslate"`) 本批次绑定赠送翻译时长(分钟,0=不送) Rewardtranslate uint32 `protobuf:"varint,13,opt,name=rewardtranslate,proto3" json:"rewardtranslate"` //@go_tags(`json:"rewardtranslate"`) 本批次绑定赠送翻译时长(分钟,0=不送)
Rewardmeeting uint32 `protobuf:"varint,14,opt,name=rewardmeeting,proto3" json:"rewardmeeting"` //@go_tags(`json:"rewardmeeting"`) 本批次绑定赠送会议时长(分钟,0=不送) Rewardmeeting uint32 `protobuf:"varint,14,opt,name=rewardmeeting,proto3" json:"rewardmeeting"` //@go_tags(`json:"rewardmeeting"`) 【已停用】本批次绑定赠送会议时长(分钟),2026-09 起不再读写
Viplevel int32 `protobuf:"varint,15,opt,name=viplevel,proto3" json:"viplevel"` //@go_tags(`json:"viplevel"`) 本批次绑定赠送VIP等级(1=VIP 2=VIP-Pro,默认1)
Computegrant int64 `protobuf:"varint,16,opt,name=computegrant,proto3" json:"computegrant"` //@go_tags(`json:"computegrant"`) 本批次绑定赠送算力(0=不送)
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -4604,6 +4606,20 @@ func (x *ApiCreateAuthCodesReq) GetRewardmeeting() uint32 {
return 0 return 0
} }
func (x *ApiCreateAuthCodesReq) GetViplevel() int32 {
if x != nil {
return x.Viplevel
}
return 0
}
func (x *ApiCreateAuthCodesReq) GetComputegrant() int64 {
if x != nil {
return x.Computegrant
}
return 0
}
type ApiCreateAuthCodesResp struct { type ApiCreateAuthCodesResp struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Brandid uint32 `protobuf:"varint,1,opt,name=brandid,proto3" json:"brandid"` //@go_tags(`json:"brandid"`) 品牌商id Brandid uint32 `protobuf:"varint,1,opt,name=brandid,proto3" json:"brandid"` //@go_tags(`json:"brandid"`) 品牌商id
@ -11062,7 +11078,7 @@ const file_api_api_msg_proto_rawDesc = "" +
"\x17ApiUploadChannelAppResp\"/\n" + "\x17ApiUploadChannelAppResp\"/\n" +
"\x13ApiDelChannelAppReq\x12\x18\n" + "\x13ApiDelChannelAppReq\x12\x18\n" +
"\achannel\x18\x01 \x01(\x05R\achannel\"\x16\n" + "\achannel\x18\x01 \x01(\x05R\achannel\"\x16\n" +
"\x14ApiDelChannelAppResp\"\xc3\x03\n" + "\x14ApiDelChannelAppResp\"\x83\x04\n" +
"\x15ApiCreateAuthCodesReq\x12\x18\n" + "\x15ApiCreateAuthCodesReq\x12\x18\n" +
"\abrandid\x18\x01 \x01(\rR\abrandid\x12\x1c\n" + "\abrandid\x18\x01 \x01(\rR\abrandid\x12\x1c\n" +
"\tproductid\x18\x02 \x01(\rR\tproductid\x12\x1e\n" + "\tproductid\x18\x02 \x01(\rR\tproductid\x12\x1e\n" +
@ -11080,7 +11096,9 @@ const file_api_api_msg_proto_rawDesc = "" +
"\tsharerate\x18\v \x01(\x05R\tsharerate\x12\x1c\n" + "\tsharerate\x18\v \x01(\x05R\tsharerate\x12\x1c\n" +
"\trewardvip\x18\f \x01(\rR\trewardvip\x12(\n" + "\trewardvip\x18\f \x01(\rR\trewardvip\x12(\n" +
"\x0frewardtranslate\x18\r \x01(\rR\x0frewardtranslate\x12$\n" + "\x0frewardtranslate\x18\r \x01(\rR\x0frewardtranslate\x12$\n" +
"\rrewardmeeting\x18\x0e \x01(\rR\rrewardmeeting\"\xbb\x02\n" + "\rrewardmeeting\x18\x0e \x01(\rR\rrewardmeeting\x12\x1a\n" +
"\bviplevel\x18\x0f \x01(\x05R\bviplevel\x12\"\n" +
"\fcomputegrant\x18\x10 \x01(\x03R\fcomputegrant\"\xbb\x02\n" +
"\x16ApiCreateAuthCodesResp\x12\x18\n" + "\x16ApiCreateAuthCodesResp\x12\x18\n" +
"\abrandid\x18\x01 \x01(\rR\abrandid\x12\x1c\n" + "\abrandid\x18\x01 \x01(\rR\abrandid\x12\x1c\n" +
"\tproductid\x18\x02 \x01(\rR\tproductid\x12\x18\n" + "\tproductid\x18\x02 \x01(\rR\tproductid\x12\x18\n" +

202
apps/services/pb/db.pb.go

@ -239,6 +239,10 @@ type DBUser struct {
Lastbindchannelid string `protobuf:"bytes,50,opt,name=lastbindchannelid,proto3" json:"lastbindchannelid" gorm:"lastbindchannelid;size:16;index"` //@go_tags(`gorm:"lastbindchannelid;size:16;index" json:"lastbindchannelid"`) 最后绑定设备的渠道商ID(充值/消耗按此渠道商归属统计与结算) Lastbindchannelid string `protobuf:"bytes,50,opt,name=lastbindchannelid,proto3" json:"lastbindchannelid" gorm:"lastbindchannelid;size:16;index"` //@go_tags(`gorm:"lastbindchannelid;size:16;index" json:"lastbindchannelid"`) 最后绑定设备的渠道商ID(充值/消耗按此渠道商归属统计与结算)
Idverified bool `protobuf:"varint,51,opt,name=idverified,proto3" json:"idverified" gorm:"idverified;index"` //@go_tags(`gorm:"idverified;index" json:"idverified"`) 是否已通过身份证实名校验(明细见 useridverify 表) Idverified bool `protobuf:"varint,51,opt,name=idverified,proto3" json:"idverified" gorm:"idverified;index"` //@go_tags(`gorm:"idverified;index" json:"idverified"`) 是否已通过身份证实名校验(明细见 useridverify 表)
Idverifiedtime int64 `protobuf:"varint,52,opt,name=idverifiedtime,proto3" json:"idverifiedtime" gorm:"idverifiedtime"` //@go_tags(`gorm:"idverifiedtime" json:"idverifiedtime"`) 实名校验通过时间(0=未通过) Idverifiedtime int64 `protobuf:"varint,52,opt,name=idverifiedtime,proto3" json:"idverifiedtime" gorm:"idverifiedtime"` //@go_tags(`gorm:"idverifiedtime" json:"idverifiedtime"`) 实名校验通过时间(0=未通过)
// 用户算力余额(充值/后台调整获得,归属账号,不过期)。
// 三个旧额度桶(aichatintegral/tradeintegral/meetintegral)自 2026-09 起停用、不再作为闸门,
// 存量值已按换算系数折进本字段(见 docs/会员权益与用量统计-设计与开发文档.md §10 存量迁移)。
Computebalance int64 `protobuf:"varint,53,opt,name=computebalance,proto3" json:"computebalance" gorm:"computebalance"` //@go_tags(`gorm:"computebalance" json:"computebalance"`) 算力余额(账号自有,不过期)
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -539,6 +543,13 @@ func (x *DBUser) GetIdverifiedtime() int64 {
return 0 return 0
} }
func (x *DBUser) GetComputebalance() int64 {
if x != nil {
return x.Computebalance
}
return 0
}
// 用户实名认证记录(表 useridverify)。一个用户一行。 // 用户实名认证记录(表 useridverify)。一个用户一行。
// //
// 合规口径:身份证号**全文不落库**——只存掩码(展示用)与 SHA-256 指纹(排查同证多号用)。 // 合规口径:身份证号**全文不落库**——只存掩码(展示用)与 SHA-256 指纹(排查同证多号用)。
@ -1874,6 +1885,10 @@ type DBProductionBatch struct {
Rewardvip uint32 `protobuf:"varint,15,opt,name=rewardvip,proto3" json:"rewardvip" gorm:"rewardvip"` //@go_tags(`gorm:"rewardvip" json:"rewardvip"`) 绑定赠送VIP天数(0=不送) Rewardvip uint32 `protobuf:"varint,15,opt,name=rewardvip,proto3" json:"rewardvip" gorm:"rewardvip"` //@go_tags(`gorm:"rewardvip" json:"rewardvip"`) 绑定赠送VIP天数(0=不送)
Rewardtranslate uint32 `protobuf:"varint,16,opt,name=rewardtranslate,proto3" json:"rewardtranslate" gorm:"rewardtranslate"` //@go_tags(`gorm:"rewardtranslate" json:"rewardtranslate"`) 绑定赠送翻译时长(分钟,0=不送) Rewardtranslate uint32 `protobuf:"varint,16,opt,name=rewardtranslate,proto3" json:"rewardtranslate" gorm:"rewardtranslate"` //@go_tags(`gorm:"rewardtranslate" json:"rewardtranslate"`) 绑定赠送翻译时长(分钟,0=不送)
Rewardmeeting uint32 `protobuf:"varint,17,opt,name=rewardmeeting,proto3" json:"rewardmeeting" gorm:"rewardmeeting"` //@go_tags(`gorm:"rewardmeeting" json:"rewardmeeting"`) 绑定赠送会议时长(分钟,0=不送) Rewardmeeting uint32 `protobuf:"varint,17,opt,name=rewardmeeting,proto3" json:"rewardmeeting" gorm:"rewardmeeting"` //@go_tags(`gorm:"rewardmeeting" json:"rewardmeeting"`) 绑定赠送会议时长(分钟,0=不送)
// 2026-09 起绑定赠送只剩「VIP 天数 + VIP 等级 + 算力」三项:rewardvip 复用为天数,
// rewardtranslate/rewardmeeting **不再读写**(列保留,避免存量批次表结构变动)。
Viplevel int32 `protobuf:"varint,18,opt,name=viplevel,proto3" json:"viplevel" gorm:"viplevel"` //@go_tags(`gorm:"viplevel" json:"viplevel"`) 绑定赠送VIP等级(1=VIP 2=VIP-Pro,默认1)
Computegrant int64 `protobuf:"varint,19,opt,name=computegrant,proto3" json:"computegrant" gorm:"computegrant"` //@go_tags(`gorm:"computegrant" json:"computegrant"`) 绑定赠送算力(0=不送)
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -2027,15 +2042,44 @@ func (x *DBProductionBatch) GetRewardmeeting() uint32 {
return 0 return 0
} }
func (x *DBProductionBatch) GetViplevel() int32 {
if x != nil {
return x.Viplevel
}
return 0
}
func (x *DBProductionBatch) GetComputegrant() int64 {
if x != nil {
return x.Computegrant
}
return 0
}
// 工厂产品 // 工厂产品
type DBProduct struct { type DBProduct struct {
state protoimpl.MessageState `protogen:"open.v1"` 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 uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id" gorm:"id;primaryKey;autoIncrement"` //@go_tags(`gorm:"id;primaryKey;autoIncrement" json:"id"`) 设备编码
Brandid uint32 `protobuf:"varint,2,opt,name=brandid,proto3" json:"brandid" gorm:"brandid;index"` //@go_tags(`gorm:"brandid;index" json:"brandid"`) 品牌商id Brandid uint32 `protobuf:"varint,2,opt,name=brandid,proto3" json:"brandid" gorm:"brandid;index"` //@go_tags(`gorm:"brandid;index" json:"brandid"`) 品牌商id
Devicetype DeviceType `protobuf:"varint,3,opt,name=devicetype,proto3,enum=DeviceType" json:"devicetype" gorm:"devicetype"` //@go_tags(`gorm:"devicetype" json:"devicetype"`) 设备类型 // 【已废弃 2026-09-08】产品上的「设备类型」已删除:服务端没有任何逻辑读它,
// 后台表单也已去掉这一项。枚举把「连接方式」(经典/BLE单双端)和「产品形态」(录音笔/眼镜/卡片)
// 混在一根轴上,客户端早已另建 DeviceCategories 做品类,这里只剩误配来源。
// ⚠️ 字段保留为**纯下发兼容字段**,理由同下面的 factoryid:已发布的 App 包按「非空 int」解析
// devicetype,字段一旦不下发,整个 user_getappconfig 响应就抛 TypeError(产品表、设备列表全废)。
// gorm:"-" 不建列、不参与读写,恒为 0 下发。老包淘汰干净后可整条删除。
// ⚠️ 字段号 3 不要复用。
Devicetype DeviceType `protobuf:"varint,3,opt,name=devicetype,proto3,enum=DeviceType" json:"devicetype" gorm:"-"` //@go_tags(`gorm:"-" json:"devicetype"`) [兼容]已废弃,恒为0
Devicename string `protobuf:"bytes,4,opt,name=devicename,proto3" json:"devicename" gorm:"devicename"` //@go_tags(`gorm:"devicename" json:"devicename"`) 设备名称 Devicename string `protobuf:"bytes,4,opt,name=devicename,proto3" json:"devicename" gorm:"devicename"` //@go_tags(`gorm:"devicename" json:"devicename"`) 设备名称
Productimage string `protobuf:"bytes,5,opt,name=productimage,proto3" json:"productimage" gorm:"productimage"` //@go_tags(`gorm:"productimage" json:"productimage"`) 产品图片 Productimage string `protobuf:"bytes,5,opt,name=productimage,proto3" json:"productimage" gorm:"productimage"` //@go_tags(`gorm:"productimage" json:"productimage"`) 产品图片
Scanuuid string `protobuf:"bytes,6,opt,name=scanuuid,proto3" json:"scanuuid" gorm:"scanuuid"` //@go_tags(`gorm:"scanuuid" json:"scanuuid"`) 设备扫描唯一id // 【已废弃 2026-09-08】设备扫描UUID 已删除:设备校验早就换成按 MAC 走(device_mac 全局表,
// 见 comm.TableDeviceMac),绑定奖励也是按 MAC 判「这台设备有没有被绑过」(codestate),
// 跟这个 UUID 没有任何关系。后台产品表单里那一栏也已去掉。
// ⚠️ 字段保留为**纯下发兼容字段**,理由同 devicetype / factoryid:已发布的 App 包按
// 「非空 String」解析 scanuuid(`json['scanuuid'] as String`),字段一旦不下发,
// 整个 user_getappconfig 响应就抛 TypeError(产品表、设备列表全废)。
// gorm:"-" 不建列、不参与读写,恒为空串下发。老包淘汰干净后可整条删除。
// ⚠️ 字段号 6 不要复用。
Scanuuid string `protobuf:"bytes,6,opt,name=scanuuid,proto3" json:"scanuuid" gorm:"-"` //@go_tags(`gorm:"-" json:"scanuuid"`) [兼容]已废弃,恒为空
Version string `protobuf:"bytes,7,opt,name=version,proto3" json:"version" gorm:"version"` //@go_tags(`gorm:"version" json:"version"`) 版本 Version string `protobuf:"bytes,7,opt,name=version,proto3" json:"version" gorm:"version"` //@go_tags(`gorm:"version" json:"version"`) 版本
Updatedescription string `protobuf:"bytes,9,opt,name=updatedescription,proto3" json:"updatedescription" gorm:"updatedescription"` //@go_tags(`gorm:"updatedescription" json:"updatedescription"`) 描述 Updatedescription string `protobuf:"bytes,9,opt,name=updatedescription,proto3" json:"updatedescription" gorm:"updatedescription"` //@go_tags(`gorm:"updatedescription" json:"updatedescription"`) 描述
Updatepackageaddress string `protobuf:"bytes,10,opt,name=updatepackageaddress,proto3" json:"updatepackageaddress" gorm:"updatepackageaddress"` //@go_tags(`gorm:"updatepackageaddress" json:"updatepackageaddress"`) 更新包地址=url Updatepackageaddress string `protobuf:"bytes,10,opt,name=updatepackageaddress,proto3" json:"updatepackageaddress" gorm:"updatepackageaddress"` //@go_tags(`gorm:"updatepackageaddress" json:"updatepackageaddress"`) 更新包地址=url
@ -2091,6 +2135,10 @@ type DBProduct struct {
// 也没法把耳机刷回旧固件反复验证 OTA 流程。 // 也没法把耳机刷回旧固件反复验证 OTA 流程。
// ⚠️ 正式产品别开:一次配置失误(比如后台挂了个旧版固件)就会把全量用户降级。 // ⚠️ 正式产品别开:一次配置失误(比如后台挂了个旧版固件)就会把全量用户降级。
Updateonmismatch bool `protobuf:"varint,40,opt,name=updateonmismatch,proto3" json:"updateonmismatch" gorm:"updateonmismatch"` //@go_tags(`gorm:"updateonmismatch" json:"updateonmismatch"`) 版本不一致即可升级(开发用,默认关) Updateonmismatch bool `protobuf:"varint,40,opt,name=updateonmismatch,proto3" json:"updateonmismatch" gorm:"updateonmismatch"` //@go_tags(`gorm:"updateonmismatch" json:"updateonmismatch"`) 版本不一致即可升级(开发用,默认关)
// 批次未配置赠送时的回退(与 production_batch 同语义)。bindrewardvpitime 复用为 VIP 天数,
// bindrewardtranslate/bindrewardmeeting 不再读写。
Bindviplevel int32 `protobuf:"varint,60,opt,name=bindviplevel,proto3" json:"bindviplevel" gorm:"bindviplevel"` //@go_tags(`gorm:"bindviplevel" json:"bindviplevel"`) 绑定赠送VIP等级回退值(默认1)
Bindcompute int64 `protobuf:"varint,61,opt,name=bindcompute,proto3" json:"bindcompute" gorm:"bindcompute"` //@go_tags(`gorm:"bindcompute" json:"bindcompute"`) 绑定赠送算力回退值(0=不送)
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -2398,6 +2446,20 @@ func (x *DBProduct) GetUpdateonmismatch() bool {
return false return false
} }
func (x *DBProduct) GetBindviplevel() int32 {
if x != nil {
return x.Bindviplevel
}
return 0
}
func (x *DBProduct) GetBindcompute() int64 {
if x != nil {
return x.Bindcompute
}
return 0
}
type DBProductVersion struct { type DBProductVersion struct {
state protoimpl.MessageState `protogen:"open.v1"` 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 Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id" gorm:"id;primaryKey;autoIncrement"` //@go_tags(`gorm:"id;primaryKey;autoIncrement" json:"id"`) 版本id
@ -2632,6 +2694,15 @@ type DBAuthCode struct {
Devicemac string `protobuf:"bytes,12,opt,name=devicemac,proto3" json:"devicemac" gorm:"devicemac;index:idx_product_mac"` //@go_tags(`gorm:"devicemac;index:idx_product_mac" json:"devicemac"`) 地址 Devicemac string `protobuf:"bytes,12,opt,name=devicemac,proto3" json:"devicemac" gorm:"devicemac;index:idx_product_mac"` //@go_tags(`gorm:"devicemac;index:idx_product_mac" json:"devicemac"`) 地址
Disabled int32 `protobuf:"varint,13,opt,name=disabled,proto3" json:"disabled" gorm:"disabled;index"` //@go_tags(`gorm:"disabled;index" json:"disabled"`) 封禁状态 0:正常 -1:禁用 Disabled int32 `protobuf:"varint,13,opt,name=disabled,proto3" json:"disabled" gorm:"disabled;index"` //@go_tags(`gorm:"disabled;index" json:"disabled"`) 封禁状态 0:正常 -1:禁用
Channelid string `protobuf:"bytes,14,opt,name=channelid,proto3" json:"channelid" gorm:"channelid;size:16;index"` //@go_tags(`gorm:"channelid;size:16;index" json:"channelid"`) 渠道商id(短码,生产时随批次落库;空=未分配渠道) Channelid string `protobuf:"bytes,14,opt,name=channelid,proto3" json:"channelid" gorm:"channelid;size:16;index"` //@go_tags(`gorm:"channelid;size:16;index" json:"channelid"`) 渠道商id(短码,生产时随批次落库;空=未分配渠道)
// ── 设备权益快照(首次绑定=激活时从生产批次固化,此后改批次不影响已激活设备)──
// 设备 VIP 期 = [usedtime, usedtime + vipdays*86400),按日历天连续走、解绑不暂停;
// 判定时按「当前绑定着且未到期」算,**不写用户表**,解绑无需回收。
Vipdays int32 `protobuf:"varint,15,opt,name=vipdays,proto3" json:"vipdays" gorm:"vipdays"` //@go_tags(`gorm:"vipdays" json:"vipdays"`) 设备赠送VIP天数(激活时快照,0=不送)
Viplevel int32 `protobuf:"varint,16,opt,name=viplevel,proto3" json:"viplevel" gorm:"viplevel"` //@go_tags(`gorm:"viplevel" json:"viplevel"`) 设备赠送VIP等级(1=VIP 2=VIP-Pro,激活时快照)
// 设备赠送算力:总量快照 + 已消耗。剩余 = computegrant - computeused。
// computeused 解绑**不重置**:设备算力是共用一份、随设备流转,重置等于换个账号就能无限刷。
Computegrant int64 `protobuf:"varint,17,opt,name=computegrant,proto3" json:"computegrant" gorm:"computegrant"` //@go_tags(`gorm:"computegrant" json:"computegrant"`) 设备赠送算力总量(激活时快照,0=不送)
Computeused int64 `protobuf:"varint,18,opt,name=computeused,proto3" json:"computeused" gorm:"computeused"` //@go_tags(`gorm:"computeused" json:"computeused"`) 设备算力已消耗(解绑不重置)
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -2757,6 +2828,34 @@ func (x *DBAuthCode) GetChannelid() string {
return "" return ""
} }
func (x *DBAuthCode) GetVipdays() int32 {
if x != nil {
return x.Vipdays
}
return 0
}
func (x *DBAuthCode) GetViplevel() int32 {
if x != nil {
return x.Viplevel
}
return 0
}
func (x *DBAuthCode) GetComputegrant() int64 {
if x != nil {
return x.Computegrant
}
return 0
}
func (x *DBAuthCode) GetComputeused() int64 {
if x != nil {
return x.Computeused
}
return 0
}
// 厂家公码(一厂家可有多码,每码独立启停且独立配置奖励;新用户扫码领奖励) // 厂家公码(一厂家可有多码,每码独立启停且独立配置奖励;新用户扫码领奖励)
type DBFactoryPublicCode struct { type DBFactoryPublicCode struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@ -2980,6 +3079,7 @@ type DBGoods struct {
Meetnum int64 `protobuf:"varint,9,opt,name=meetnum,proto3" json:"meetnum" gorm:"meetnum"` //@go_tags(`gorm:"meetnum" json:"meetnum"`) 会议使用量资源点 Meetnum int64 `protobuf:"varint,9,opt,name=meetnum,proto3" json:"meetnum" gorm:"meetnum"` //@go_tags(`gorm:"meetnum" json:"meetnum"`) 会议使用量资源点
Tradenum int64 `protobuf:"varint,10,opt,name=tradenum,proto3" json:"tradenum" gorm:"tradenum"` //@go_tags(`gorm:"tradenum" json:"tradenum"`) 翻译资源点 Tradenum int64 `protobuf:"varint,10,opt,name=tradenum,proto3" json:"tradenum" gorm:"tradenum"` //@go_tags(`gorm:"tradenum" json:"tradenum"`) 翻译资源点
Viptime int32 `protobuf:"varint,11,opt,name=viptime,proto3" json:"viptime" gorm:"viptime"` //@go_tags(`gorm:"viptime" json:"viptime"`) 会员时间 Viptime int32 `protobuf:"varint,11,opt,name=viptime,proto3" json:"viptime" gorm:"viptime"` //@go_tags(`gorm:"viptime" json:"viptime"`) 会员时间
Compute int64 `protobuf:"varint,12,opt,name=compute,proto3" json:"compute" gorm:"compute"` //@go_tags(`gorm:"compute" json:"compute"`) 发放算力(2026-09 起 ainum/meetnum/tradenum 不再发放)
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -3084,6 +3184,13 @@ func (x *DBGoods) GetViptime() int32 {
return 0 return 0
} }
func (x *DBGoods) GetCompute() int64 {
if x != nil {
return x.Compute
}
return 0
}
// 语音包管理 // 语音包管理
type DBWakeupVoice struct { type DBWakeupVoice struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@ -3181,6 +3288,12 @@ type DBUserUseLog struct {
Addtradesecond int64 `protobuf:"varint,7,opt,name=addtradesecond,proto3" json:"addtradesecond" gorm:"addtradesecond"` //@go_tags(`gorm:"addtradesecond" json:"addtradesecond"`) 翻译资源点使用时常 Addtradesecond int64 `protobuf:"varint,7,opt,name=addtradesecond,proto3" json:"addtradesecond" gorm:"addtradesecond"` //@go_tags(`gorm:"addtradesecond" json:"addtradesecond"`) 翻译资源点使用时常
Addmeetsecond int64 `protobuf:"varint,8,opt,name=addmeetsecond,proto3" json:"addmeetsecond" gorm:"addmeetsecond"` //@go_tags(`gorm:"addmeetsecond" json:"addmeetsecond"`) 会议使用时常 Addmeetsecond int64 `protobuf:"varint,8,opt,name=addmeetsecond,proto3" json:"addmeetsecond" gorm:"addmeetsecond"` //@go_tags(`gorm:"addmeetsecond" json:"addmeetsecond"`) 会议使用时常
Extra string `protobuf:"bytes,9,opt,name=extra,proto3" json:"extra" gorm:"extra"` //@go_tags(`gorm:"extra" json:"extra"`) 附加数据 Extra string `protobuf:"bytes,9,opt,name=extra,proto3" json:"extra" gorm:"extra"` //@go_tags(`gorm:"extra" json:"extra"`) 附加数据
// ── 算力记账(2026-09 起)。消耗为负、发放/调整为正;来源拆分三列之和 = compute 的绝对值 ──
Compute int64 `protobuf:"varint,10,opt,name=compute,proto3" json:"compute" gorm:"compute"` //@go_tags(`gorm:"compute" json:"compute"`) 本次算力变动(消耗为负)
Computedevice int64 `protobuf:"varint,11,opt,name=computedevice,proto3" json:"computedevice" gorm:"computedevice"` //@go_tags(`gorm:"computedevice" json:"computedevice"`) 从设备赠送额度扣的部分
Computeuser int64 `protobuf:"varint,12,opt,name=computeuser,proto3" json:"computeuser" gorm:"computeuser"` //@go_tags(`gorm:"computeuser" json:"computeuser"`) 从用户余额扣的部分
Computeover int64 `protobuf:"varint,13,opt,name=computeover,proto3" json:"computeover" gorm:"computeover"` //@go_tags(`gorm:"computeover" json:"computeover"`) 超额部分(闸门关时余额不足仍放行的量)
Devicemac string `protobuf:"bytes,14,opt,name=devicemac,proto3" json:"devicemac" gorm:"devicemac;size:50"` //@go_tags(`gorm:"devicemac;size:50" json:"devicemac"`) 扣了哪台设备(为空=未动设备额度)
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -3278,6 +3391,41 @@ func (x *DBUserUseLog) GetExtra() string {
return "" return ""
} }
func (x *DBUserUseLog) GetCompute() int64 {
if x != nil {
return x.Compute
}
return 0
}
func (x *DBUserUseLog) GetComputedevice() int64 {
if x != nil {
return x.Computedevice
}
return 0
}
func (x *DBUserUseLog) GetComputeuser() int64 {
if x != nil {
return x.Computeuser
}
return 0
}
func (x *DBUserUseLog) GetComputeover() int64 {
if x != nil {
return x.Computeover
}
return 0
}
func (x *DBUserUseLog) GetDevicemac() string {
if x != nil {
return x.Devicemac
}
return ""
}
// 用户统计数据 // 用户统计数据
type DBUserStatistics struct { type DBUserStatistics struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@ -3297,6 +3445,7 @@ type DBUserStatistics struct {
Trademodel4Time int64 `protobuf:"varint,14,opt,name=trademodel4time,proto3" json:"trademodel4time" gorm:"trademodel4time"` //@go_tags(`gorm:"trademodel4time" json:"trademodel4time"`) 翻译模式4时常 Trademodel4Time int64 `protobuf:"varint,14,opt,name=trademodel4time,proto3" json:"trademodel4time" gorm:"trademodel4time"` //@go_tags(`gorm:"trademodel4time" json:"trademodel4time"`) 翻译模式4时常
Trademodel5Num int32 `protobuf:"varint,15,opt,name=trademodel5num,proto3" json:"trademodel5num" gorm:"trademodel5num"` //@go_tags(`gorm:"trademodel5num" json:"trademodel5num"`) 翻译模式5次数 Trademodel5Num int32 `protobuf:"varint,15,opt,name=trademodel5num,proto3" json:"trademodel5num" gorm:"trademodel5num"` //@go_tags(`gorm:"trademodel5num" json:"trademodel5num"`) 翻译模式5次数
Tradewordcount int32 `protobuf:"varint,16,opt,name=tradewordcount,proto3" json:"tradewordcount" gorm:"tradewordcount"` //@go_tags(`gorm:"tradewordcount" json:"tradewordcount"`) 翻译文本长度 Tradewordcount int32 `protobuf:"varint,16,opt,name=tradewordcount,proto3" json:"tradewordcount" gorm:"tradewordcount"` //@go_tags(`gorm:"tradewordcount" json:"tradewordcount"`) 翻译文本长度
Computetotal int64 `protobuf:"varint,17,opt,name=computetotal,proto3" json:"computetotal" gorm:"computetotal"` //@go_tags(`gorm:"computetotal" json:"computetotal"`) 累计消耗算力
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -3443,6 +3592,13 @@ func (x *DBUserStatistics) GetTradewordcount() int32 {
return 0 return 0
} }
func (x *DBUserStatistics) GetComputetotal() int64 {
if x != nil {
return x.Computetotal
}
return 0
}
// App综合统计(日/月/年三张表共用同一结构) // App综合统计(日/月/年三张表共用同一结构)
type DBAppStat struct { type DBAppStat struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@ -3708,7 +3864,8 @@ var File_db_proto protoreflect.FileDescriptor
const file_db_proto_rawDesc = "" + const file_db_proto_rawDesc = "" +
"\n" + "\n" +
"\bdb.proto\x1a\n" + "\bdb.proto\x1a\n" +
"enum.proto\"\xf6\t\n" + "enum.proto\"\x9e\n" +
"\n" +
"\x06DBUser\x12\x10\n" + "\x06DBUser\x12\x10\n" +
"\x03uid\x18\x01 \x01(\tR\x03uid\x12\x12\n" + "\x03uid\x18\x01 \x01(\tR\x03uid\x12\x12\n" +
"\x04mail\x18\x02 \x01(\tR\x04mail\x12\x14\n" + "\x04mail\x18\x02 \x01(\tR\x04mail\x12\x14\n" +
@ -3763,7 +3920,8 @@ const file_db_proto_rawDesc = "" +
"\n" + "\n" +
"idverified\x183 \x01(\bR\n" + "idverified\x183 \x01(\bR\n" +
"idverified\x12&\n" + "idverified\x12&\n" +
"\x0eidverifiedtime\x184 \x01(\x03R\x0eidverifiedtime\"\xec\x02\n" + "\x0eidverifiedtime\x184 \x01(\x03R\x0eidverifiedtime\x12&\n" +
"\x0ecomputebalance\x185 \x01(\x03R\x0ecomputebalance\"\xec\x02\n" +
"\x0eDBUserIdVerify\x12\x10\n" + "\x0eDBUserIdVerify\x12\x10\n" +
"\x03uid\x18\x01 \x01(\tR\x03uid\x12\x1a\n" + "\x03uid\x18\x01 \x01(\tR\x03uid\x12\x1a\n" +
"\brealname\x18\x02 \x01(\tR\brealname\x12\x1e\n" + "\brealname\x18\x02 \x01(\tR\brealname\x12\x1e\n" +
@ -3913,7 +4071,7 @@ const file_db_proto_rawDesc = "" +
"\x0fconfirm_account\x18\x0e \x01(\tR\x0econfirmAccount\x12!\n" + "\x0fconfirm_account\x18\x0e \x01(\tR\x0econfirmAccount\x12!\n" +
"\fconfirm_time\x18\x0f \x01(\x03R\vconfirmTime\x12\x16\n" + "\fconfirm_time\x18\x0f \x01(\x03R\vconfirmTime\x12\x16\n" +
"\x06remark\x18\x10 \x01(\tR\x06remarkJ\x04\b\x02\x10\x03J\x04\b\x06\x10\aJ\x04\b\a\x10\bJ\x04\b\n" + "\x06remark\x18\x10 \x01(\tR\x06remarkJ\x04\b\x02\x10\x03J\x04\b\x06\x10\aJ\x04\b\a\x10\bJ\x04\b\n" +
"\x10\v\"\xf7\x03\n" + "\x10\v\"\xb7\x04\n" +
"\x11DBProductionBatch\x12\x0e\n" + "\x11DBProductionBatch\x12\x0e\n" +
"\x02id\x18\x01 \x01(\rR\x02id\x12\x0e\n" + "\x02id\x18\x01 \x01(\rR\x02id\x12\x0e\n" +
"\x02ts\x18\x02 \x01(\x03R\x02ts\x12\x18\n" + "\x02ts\x18\x02 \x01(\x03R\x02ts\x12\x18\n" +
@ -3932,7 +4090,9 @@ const file_db_proto_rawDesc = "" +
"\tsharerate\x18\x0e \x01(\x05R\tsharerate\x12\x1c\n" + "\tsharerate\x18\x0e \x01(\x05R\tsharerate\x12\x1c\n" +
"\trewardvip\x18\x0f \x01(\rR\trewardvip\x12(\n" + "\trewardvip\x18\x0f \x01(\rR\trewardvip\x12(\n" +
"\x0frewardtranslate\x18\x10 \x01(\rR\x0frewardtranslate\x12$\n" + "\x0frewardtranslate\x18\x10 \x01(\rR\x0frewardtranslate\x12$\n" +
"\rrewardmeeting\x18\x11 \x01(\rR\rrewardmeeting\"\xb6\v\n" + "\rrewardmeeting\x18\x11 \x01(\rR\rrewardmeeting\x12\x1a\n" +
"\bviplevel\x18\x12 \x01(\x05R\bviplevel\x12\"\n" +
"\fcomputegrant\x18\x13 \x01(\x03R\fcomputegrant\"\xfc\v\n" +
"\tDBProduct\x12\x0e\n" + "\tDBProduct\x12\x0e\n" +
"\x02id\x18\x01 \x01(\rR\x02id\x12\x18\n" + "\x02id\x18\x01 \x01(\rR\x02id\x12\x18\n" +
"\abrandid\x18\x02 \x01(\rR\abrandid\x12+\n" + "\abrandid\x18\x02 \x01(\rR\abrandid\x12+\n" +
@ -3983,7 +4143,9 @@ const file_db_proto_rawDesc = "" +
"createtime\x12 \n" + "createtime\x12 \n" +
"\vdescription\x18& \x01(\tR\vdescription\x12\"\n" + "\vdescription\x18& \x01(\tR\vdescription\x12\"\n" +
"\foemfactoryid\x18' \x01(\rR\foemfactoryid\x12*\n" + "\foemfactoryid\x18' \x01(\rR\foemfactoryid\x12*\n" +
"\x10updateonmismatch\x18( \x01(\bR\x10updateonmismatch\"\xb0\x01\n" + "\x10updateonmismatch\x18( \x01(\bR\x10updateonmismatch\x12\"\n" +
"\fbindviplevel\x18< \x01(\x05R\fbindviplevel\x12 \n" +
"\vbindcompute\x18= \x01(\x03R\vbindcompute\"\xb0\x01\n" +
"\x10DBProductVersion\x12\x0e\n" + "\x10DBProductVersion\x12\x0e\n" +
"\x02id\x18\x01 \x01(\rR\x02id\x12\x1c\n" + "\x02id\x18\x01 \x01(\rR\x02id\x12\x1c\n" +
"\tproductid\x18\x02 \x01(\rR\tproductid\x12\x18\n" + "\tproductid\x18\x02 \x01(\rR\tproductid\x12\x18\n" +
@ -4006,7 +4168,7 @@ const file_db_proto_rawDesc = "" +
" \x01(\tR\tchangelog\x12$\n" + " \x01(\tR\tchangelog\x12$\n" +
"\rreviewversion\x18\v \x01(\tR\rreviewversion\x12\x16\n" + "\rreviewversion\x18\v \x01(\tR\rreviewversion\x12\x16\n" +
"\x06status\x18\f \x01(\x05R\x06status\x12(\n" + "\x06status\x18\f \x01(\x05R\x06status\x12(\n" +
"\x0ftouristsversion\x18\r \x01(\tR\x0ftouristsversion\"\xef\x02\n" + "\x0ftouristsversion\x18\r \x01(\tR\x0ftouristsversion\"\xeb\x03\n" +
"\n" + "\n" +
"DBAuthCode\x12\x12\n" + "DBAuthCode\x12\x12\n" +
"\x04code\x18\x01 \x01(\tR\x04code\x12%\n" + "\x04code\x18\x01 \x01(\tR\x04code\x12%\n" +
@ -4024,7 +4186,11 @@ const file_db_proto_rawDesc = "" +
"\x03uid\x18\v \x01(\tR\x03uid\x12\x1c\n" + "\x03uid\x18\v \x01(\tR\x03uid\x12\x1c\n" +
"\tdevicemac\x18\f \x01(\tR\tdevicemac\x12\x1a\n" + "\tdevicemac\x18\f \x01(\tR\tdevicemac\x12\x1a\n" +
"\bdisabled\x18\r \x01(\x05R\bdisabled\x12\x1c\n" + "\bdisabled\x18\r \x01(\x05R\bdisabled\x12\x1c\n" +
"\tchannelid\x18\x0e \x01(\tR\tchannelid\"\xb5\x03\n" + "\tchannelid\x18\x0e \x01(\tR\tchannelid\x12\x18\n" +
"\avipdays\x18\x0f \x01(\x05R\avipdays\x12\x1a\n" +
"\bviplevel\x18\x10 \x01(\x05R\bviplevel\x12\"\n" +
"\fcomputegrant\x18\x11 \x01(\x03R\fcomputegrant\x12 \n" +
"\vcomputeused\x18\x12 \x01(\x03R\vcomputeused\"\xb5\x03\n" +
"\x13DBFactoryPublicCode\x12\x12\n" + "\x13DBFactoryPublicCode\x12\x12\n" +
"\x04code\x18\x01 \x01(\tR\x04code\x12\x18\n" + "\x04code\x18\x01 \x01(\tR\x04code\x12\x18\n" +
"\abrandid\x18\x02 \x01(\rR\abrandid\x12\x1c\n" + "\abrandid\x18\x02 \x01(\rR\abrandid\x12\x1c\n" +
@ -4050,7 +4216,7 @@ const file_db_proto_rawDesc = "" +
"\x04lang\x18\x01 \x01(\tR\x04lang\x12\x12\n" + "\x04lang\x18\x01 \x01(\tR\x04lang\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x1a\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x1a\n" +
"\bcurrency\x18\x03 \x01(\tR\bcurrency\x12\x14\n" + "\bcurrency\x18\x03 \x01(\tR\bcurrency\x12\x14\n" +
"\x05price\x18\x06 \x01(\x01R\x05price\"\x89\x02\n" + "\x05price\x18\x06 \x01(\x01R\x05price\"\xa3\x02\n" +
"\aDBGoods\x12\x0e\n" + "\aDBGoods\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x16\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x16\n" +
"\x06enable\x18\x02 \x01(\bR\x06enable\x12\x12\n" + "\x06enable\x18\x02 \x01(\bR\x06enable\x12\x12\n" +
@ -4063,14 +4229,15 @@ const file_db_proto_rawDesc = "" +
"\ameetnum\x18\t \x01(\x03R\ameetnum\x12\x1a\n" + "\ameetnum\x18\t \x01(\x03R\ameetnum\x12\x1a\n" +
"\btradenum\x18\n" + "\btradenum\x18\n" +
" \x01(\x03R\btradenum\x12\x18\n" + " \x01(\x03R\btradenum\x12\x18\n" +
"\aviptime\x18\v \x01(\x05R\aviptime\"\x9f\x01\n" + "\aviptime\x18\v \x01(\x05R\aviptime\x12\x18\n" +
"\acompute\x18\f \x01(\x03R\acompute\"\x9f\x01\n" +
"\rDBWakeupVoice\x12\x0e\n" + "\rDBWakeupVoice\x12\x0e\n" +
"\x02id\x18\x01 \x01(\rR\x02id\x12\x16\n" + "\x02id\x18\x01 \x01(\rR\x02id\x12\x16\n" +
"\x06enable\x18\x02 \x01(\bR\x06enable\x12\x12\n" + "\x06enable\x18\x02 \x01(\bR\x06enable\x12\x12\n" +
"\x04name\x18\x03 \x01(\tR\x04name\x12\x12\n" + "\x04name\x18\x03 \x01(\tR\x04name\x12\x12\n" +
"\x04desc\x18\x04 \x01(\tR\x04desc\x12\x18\n" + "\x04desc\x18\x04 \x01(\tR\x04desc\x12\x18\n" +
"\aversion\x18\x05 \x01(\tR\aversion\x12$\n" + "\aversion\x18\x05 \x01(\tR\aversion\x12$\n" +
"\rpackagaddress\x18\x06 \x01(\tR\rpackagaddress\"\x96\x02\n" + "\rpackagaddress\x18\x06 \x01(\tR\rpackagaddress\"\xb8\x03\n" +
"\fDBUserUseLog\x12\x0e\n" + "\fDBUserUseLog\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x04R\x02id\x12\x10\n" + "\x02id\x18\x01 \x01(\x04R\x02id\x12\x10\n" +
"\x03uid\x18\x02 \x01(\tR\x03uid\x12\x0e\n" + "\x03uid\x18\x02 \x01(\tR\x03uid\x12\x0e\n" +
@ -4080,7 +4247,13 @@ const file_db_proto_rawDesc = "" +
"\x10addagentintegral\x18\x06 \x01(\x03R\x10addagentintegral\x12&\n" + "\x10addagentintegral\x18\x06 \x01(\x03R\x10addagentintegral\x12&\n" +
"\x0eaddtradesecond\x18\a \x01(\x03R\x0eaddtradesecond\x12$\n" + "\x0eaddtradesecond\x18\a \x01(\x03R\x0eaddtradesecond\x12$\n" +
"\raddmeetsecond\x18\b \x01(\x03R\raddmeetsecond\x12\x14\n" + "\raddmeetsecond\x18\b \x01(\x03R\raddmeetsecond\x12\x14\n" +
"\x05extra\x18\t \x01(\tR\x05extra\"\xe0\x04\n" + "\x05extra\x18\t \x01(\tR\x05extra\x12\x18\n" +
"\acompute\x18\n" +
" \x01(\x03R\acompute\x12$\n" +
"\rcomputedevice\x18\v \x01(\x03R\rcomputedevice\x12 \n" +
"\vcomputeuser\x18\f \x01(\x03R\vcomputeuser\x12 \n" +
"\vcomputeover\x18\r \x01(\x03R\vcomputeover\x12\x1c\n" +
"\tdevicemac\x18\x0e \x01(\tR\tdevicemac\"\x84\x05\n" +
"\x10DBUserStatistics\x12\x10\n" + "\x10DBUserStatistics\x12\x10\n" +
"\x03uid\x18\x01 \x01(\tR\x03uid\x12\x1c\n" + "\x03uid\x18\x01 \x01(\tR\x03uid\x12\x1c\n" +
"\taichatnum\x18\x02 \x01(\x03R\taichatnum\x12$\n" + "\taichatnum\x18\x02 \x01(\x03R\taichatnum\x12$\n" +
@ -4098,7 +4271,8 @@ const file_db_proto_rawDesc = "" +
"\x0etrademodel4num\x18\r \x01(\x05R\x0etrademodel4num\x12(\n" + "\x0etrademodel4num\x18\r \x01(\x05R\x0etrademodel4num\x12(\n" +
"\x0ftrademodel4time\x18\x0e \x01(\x03R\x0ftrademodel4time\x12&\n" + "\x0ftrademodel4time\x18\x0e \x01(\x03R\x0ftrademodel4time\x12&\n" +
"\x0etrademodel5num\x18\x0f \x01(\x05R\x0etrademodel5num\x12&\n" + "\x0etrademodel5num\x18\x0f \x01(\x05R\x0etrademodel5num\x12&\n" +
"\x0etradewordcount\x18\x10 \x01(\x05R\x0etradewordcount\"\xca\a\n" + "\x0etradewordcount\x18\x10 \x01(\x05R\x0etradewordcount\x12\"\n" +
"\fcomputetotal\x18\x11 \x01(\x03R\fcomputetotal\"\xca\a\n" +
"\tDBAppStat\x12\x0e\n" + "\tDBAppStat\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x04R\x02id\x12\x1b\n" + "\x02id\x18\x01 \x01(\x04R\x02id\x12\x1b\n" +
"\tstat_date\x18\x02 \x01(\tR\bstatDate\x12\x1d\n" + "\tstat_date\x18\x02 \x01(\tR\bstatDate\x12\x1d\n" +

20
apps/services/pb/enum.pb.go

@ -266,6 +266,10 @@ const (
// @Description 购买VIP时长 // @Description 购买VIP时长
// @example // @example
UsageType_VIP UsageType = 4 //购买VIP时长 UsageType_VIP UsageType = 4 //购买VIP时长
// 购买算力
// @Description 购买算力包(2026-09 起;商品的 compute 字段即本次发放量,归账号、不过期)
// @example
UsageType_Compute UsageType = 5 //购买算力
) )
// Enum value maps for UsageType. // Enum value maps for UsageType.
@ -276,6 +280,7 @@ var (
2: "Meet", 2: "Meet",
3: "AI", 3: "AI",
4: "VIP", 4: "VIP",
5: "Compute",
} }
UsageType_value = map[string]int32{ UsageType_value = map[string]int32{
"UsageNull": 0, "UsageNull": 0,
@ -283,6 +288,7 @@ var (
"Meet": 2, "Meet": 2,
"AI": 3, "AI": 3,
"VIP": 4, "VIP": 4,
"Compute": 5,
} }
) )
@ -336,6 +342,10 @@ const (
// @Description 用户消耗 // @Description 用户消耗
// @example // @example
UserLogType_UserConsume UserLogType = 4 //用户消耗 UserLogType_UserConsume UserLogType = 4 //用户消耗
// 后台调整算力
// @Description 后台调整用户算力余额(加减)
// @example
UserLogType_AdminAdjust UserLogType = 5 //后台调整算力
) )
// Enum value maps for UserLogType. // Enum value maps for UserLogType.
@ -346,6 +356,7 @@ var (
2: "ActivityReward", 2: "ActivityReward",
3: "AdminGive", 3: "AdminGive",
4: "UserConsume", 4: "UserConsume",
5: "AdminAdjust",
} }
UserLogType_value = map[string]int32{ UserLogType_value = map[string]int32{
"UserLogTypeUnknown": 0, "UserLogTypeUnknown": 0,
@ -353,6 +364,7 @@ var (
"ActivityReward": 2, "ActivityReward": 2,
"AdminGive": 3, "AdminGive": 3,
"UserConsume": 4, "UserConsume": 4,
"AdminAdjust": 5,
} }
) )
@ -659,19 +671,21 @@ const file_enum_proto_rawDesc = "" +
"\x1cBLE_Bluetooth_Single_Headset\x10\x03\x12\x18\n" + "\x1cBLE_Bluetooth_Single_Headset\x10\x03\x12\x18\n" +
"\x14Smart_Voice_Recorder\x10\x04\x12\f\n" + "\x14Smart_Voice_Recorder\x10\x04\x12\f\n" +
"\bSmartEye\x10\x05\x12\x12\n" + "\bSmartEye\x10\x05\x12\x12\n" +
"\x0eActivationCard\x10\x06*@\n" + "\x0eActivationCard\x10\x06*M\n" +
"\tUsageType\x12\r\n" + "\tUsageType\x12\r\n" +
"\tUsageNull\x10\x00\x12\t\n" + "\tUsageNull\x10\x00\x12\t\n" +
"\x05Trade\x10\x01\x12\b\n" + "\x05Trade\x10\x01\x12\b\n" +
"\x04Meet\x10\x02\x12\x06\n" + "\x04Meet\x10\x02\x12\x06\n" +
"\x02AI\x10\x03\x12\a\n" + "\x02AI\x10\x03\x12\a\n" +
"\x03VIP\x10\x04*b\n" + "\x03VIP\x10\x04\x12\v\n" +
"\aCompute\x10\x05*s\n" +
"\vUserLogType\x12\x16\n" + "\vUserLogType\x12\x16\n" +
"\x12UserLogTypeUnknown\x10\x00\x12\a\n" + "\x12UserLogTypeUnknown\x10\x00\x12\a\n" +
"\x03Pay\x10\x01\x12\x12\n" + "\x03Pay\x10\x01\x12\x12\n" +
"\x0eActivityReward\x10\x02\x12\r\n" + "\x0eActivityReward\x10\x02\x12\r\n" +
"\tAdminGive\x10\x03\x12\x0f\n" + "\tAdminGive\x10\x03\x12\x0f\n" +
"\vUserConsume\x10\x04*\"\n" + "\vUserConsume\x10\x04\x12\x0f\n" +
"\vAdminAdjust\x10\x05*\"\n" +
"\rMCPServerType\x12\b\n" + "\rMCPServerType\x12\b\n" +
"\x04Http\x10\x00\x12\a\n" + "\x04Http\x10\x00\x12\a\n" +
"\x03SEE\x10\x01*m\n" + "\x03SEE\x10\x01*m\n" +

17
apps/services/pb/errorcode.pb.go

@ -116,6 +116,10 @@ const (
ErrorCode_TranslateNotConfigured ErrorCode = 5101 //未配置机器翻译服务(后台第三方服务配置里没有启用的 MT 服务) ErrorCode_TranslateNotConfigured ErrorCode = 5101 //未配置机器翻译服务(后台第三方服务配置里没有启用的 MT 服务)
ErrorCode_TranslateParamInvalid ErrorCode = 5102 //翻译参数不合法(原文为空 / 目标语言为空) ErrorCode_TranslateParamInvalid ErrorCode = 5102 //翻译参数不合法(原文为空 / 目标语言为空)
ErrorCode_TranslateProviderError ErrorCode = 5103 //翻译服务商调用失败 ErrorCode_TranslateProviderError ErrorCode = 5103 //翻译服务商调用失败
// 会员与算力相关错误码 5301-5303
ErrorCode_ComputeNotEnough ErrorCode = 5301 //算力不足(仅算力闸门开启时返回)
ErrorCode_VipRequired ErrorCode = 5302 //需要会员(翻译/会议纪要在 VIP 过期后受限;提示里要同时给出「续费」与「绑定带会员的设备」两条路)
ErrorCode_ComputeBalanceNegative ErrorCode = 5303 //后台调整算力后余额将为负,拒绝
) )
// Enum value maps for ErrorCode. // Enum value maps for ErrorCode.
@ -200,6 +204,9 @@ var (
5101: "TranslateNotConfigured", 5101: "TranslateNotConfigured",
5102: "TranslateParamInvalid", 5102: "TranslateParamInvalid",
5103: "TranslateProviderError", 5103: "TranslateProviderError",
5301: "ComputeNotEnough",
5302: "VipRequired",
5303: "ComputeBalanceNegative",
} }
ErrorCode_value = map[string]int32{ ErrorCode_value = map[string]int32{
"Success": 0, "Success": 0,
@ -281,6 +288,9 @@ var (
"TranslateNotConfigured": 5101, "TranslateNotConfigured": 5101,
"TranslateParamInvalid": 5102, "TranslateParamInvalid": 5102,
"TranslateProviderError": 5103, "TranslateProviderError": 5103,
"ComputeNotEnough": 5301,
"VipRequired": 5302,
"ComputeBalanceNegative": 5303,
} }
) )
@ -315,7 +325,7 @@ var File_errorcode_proto protoreflect.FileDescriptor
const file_errorcode_proto_rawDesc = "" + const file_errorcode_proto_rawDesc = "" +
"\n" + "\n" +
"\x0ferrorcode.proto*\x88\x0e\n" + "\x0ferrorcode.proto*\xce\x0e\n" +
"\tErrorCode\x12\v\n" + "\tErrorCode\x12\v\n" +
"\aSuccess\x10\x00\x12\x14\n" + "\aSuccess\x10\x00\x12\x14\n" +
"\x10GatewayException\x10\x01\x12\x11\n" + "\x10GatewayException\x10\x01\x12\x11\n" +
@ -398,7 +408,10 @@ const file_errorcode_proto_rawDesc = "" +
"\x12MemoryReportFailed\x10\xd6(\x12\x1b\n" + "\x12MemoryReportFailed\x10\xd6(\x12\x1b\n" +
"\x16TranslateNotConfigured\x10\xed'\x12\x1a\n" + "\x16TranslateNotConfigured\x10\xed'\x12\x1a\n" +
"\x15TranslateParamInvalid\x10\xee'\x12\x1b\n" + "\x15TranslateParamInvalid\x10\xee'\x12\x1b\n" +
"\x16TranslateProviderError\x10\xef'B\x06Z\x04.;pbb\x06proto3" "\x16TranslateProviderError\x10\xef'\x12\x15\n" +
"\x10ComputeNotEnough\x10\xb5)\x12\x10\n" +
"\vVipRequired\x10\xb6)\x12\x1b\n" +
"\x16ComputeBalanceNegative\x10\xb7)B\x06Z\x04.;pbb\x06proto3"
var ( var (
file_errorcode_proto_rawDescOnce sync.Once file_errorcode_proto_rawDescOnce sync.Once

810
apps/services/pb/user_msg.pb.go

File diff suppressed because it is too large

BIN
apps/services/services/.DS_Store

Binary file not shown.

13
deploy/app/dev-deploy.sh

@ -127,8 +127,19 @@ _ssh "mkdir -p '$DEV_DIR/confs' '$DEV_DIR/log'"
_scp "$SVC_DIR/docker-compose.yml" "$(_dest "$DEV_DIR/docker-compose.yml")" _scp "$SVC_DIR/docker-compose.yml" "$(_dest "$DEV_DIR/docker-compose.yml")"
_scp "$SVC_DIR"/confs/*.yaml.example "$(_dest "$DEV_DIR/confs/")" _scp "$SVC_DIR"/confs/*.yaml.example "$(_dest "$DEV_DIR/confs/")"
_scp "$SVC_DIR/env/env.example" "$(_dest "$DEV_DIR/env.example")" _scp "$SVC_DIR/env/env.example" "$(_dest "$DEV_DIR/env.example")"
# ip2region 离线 IP 库(静态数据非凭据,home 的 sys.ipinfo 启动必需,缺了会 panic) # ip2region 离线 IP 库(静态数据非凭据,home 的 sys.ipinfo 启动必需,缺了会 panic)。
# 约 46MB 且几乎从不变,只在远端缺失或大小对不上时才传 —— 到测试机的上行只有几十 KB/s,
# 无条件重传会让每次发 app 白白多花十几分钟(2026-09-08 实测 13 分钟,占整轮部署的八成)。
# 比大小而不是只判存在:上一次传到一半被中断会留下个非空的残文件,`test -s` 认它是好的,
# 于是永远补不上,而 home 读到截断的库会在启动时 panic。
XDB_LOCAL_SIZES="$(cd "$REPO_ROOT/apps/services/sys/ipinfo" && wc -c < ip2region_v4.xdb)/$(cd "$REPO_ROOT/apps/services/sys/ipinfo" && wc -c < ip2region_v6.xdb)"
XDB_REMOTE_SIZES="$(_ssh "cd '$DEV_DIR/confs' 2>/dev/null && echo \"\$(wc -c < ip2region_v4.xdb 2>/dev/null)/\$(wc -c < ip2region_v6.xdb 2>/dev/null)\"" 2>/dev/null || true)"
if [ "$(echo "$XDB_LOCAL_SIZES" | tr -d ' ')" = "$(echo "$XDB_REMOTE_SIZES" | tr -d ' ')" ]; then
echo " ip2region 离线 IP 库已是最新($XDB_LOCAL_SIZES 字节),跳过上传"
else
blue ">>> 同步 ip2region 离线 IP 库(约 46MB,仅在远端缺失/大小不符时传)"
_scp "$REPO_ROOT"/apps/services/sys/ipinfo/ip2region_v*.xdb "$(_dest "$DEV_DIR/confs/")" _scp "$REPO_ROOT"/apps/services/sys/ipinfo/ip2region_v*.xdb "$(_dest "$DEV_DIR/confs/")"
fi
# 3) 远端拉取并重启(用你在服务器维护的真实 .env / confs/*.yaml) # 3) 远端拉取并重启(用你在服务器维护的真实 .env / confs/*.yaml)
# REGISTRY/TAG 通过 inline 环境变量传给 compose(优先级高于 --env-file),确保拉到本次镜像。 # REGISTRY/TAG 通过 inline 环境变量传给 compose(优先级高于 --env-file),确保拉到本次镜像。

642
docs/2026-09-11-阿龙测试服上线正式服-落地文档.md

@ -0,0 +1,642 @@
# 阿龙测试服 → 阿龙正式服 · 服务端上线落地文档
**日期**:2026-09-11
**方向**:`8.133.166.29`(阿龙测试,跑 `dev-latest`)→ `47.116.104.181`(阿龙正式,跑 `0.1.4`)
**范围**:`app`(gateway/home/api/mcp/timer 单镜像)、`console`、`admin` 三个服务端。**客户端 App 本轮不发版。**
**数据口径**:用户数据不迁;只搬「平台能力配置」,产品/设备/批次等业务台账不搬。
---
## 0. 先看结论
本文档的前提全部是 2026-09-11 在两台机上**实测**得到的,不是照着脚本推的:
1. **正式服没有任何用户数据** —— `user=0`、`userdevice=0`、`echomeet_record=0`、`payorder` 空。
所以「用户数据不要部署过去」这条天然满足,**不需要任何排除规则、不需要脱敏**。
2. **正式服代码停在 8-18 的 `0.1.4`**,这三周的能力一个都没有:记忆中心(拾忆)、会员与算力、
身份证实名核验、设备表合表(`license_<pid>` → `device_mac`)、预签名直传 OSS、
MCP 令牌与会议纪要检索、发版配置拆表(`app_release`/`channel_app`)。
3. **正式服有三处地基性错配**(§2),不先修的话新能力部上去也是**静默失效**——不报错、看板恒为 0、
按应用作用域的配置全部退化成全局默认。
4. **正式服的 App 配置表(业务库 `config`)是空的,产品台账也是空的**。光升镜像,App 端拿不到
任何第三方能力配置,设备也绑不上。
**所以这次不是「升个版本」,是三件事叠加:升镜像 + 补齐配置 + 搬平台能力配置。**
建议本次版本号 **`0.2.0`**(正式现为 `0.1.4`)。
---
## 1. 两边现状实测对照
### 1.1 服务与版本
| | 测试 `8.133.166.29` | 正式 `47.116.104.181` |
|---|---|---|
| app 容器 / 目录 | `starpivot-app` · `/home/work/starpivot/app` | `ym-a11` · `/home/work/ym-a11` |
| console | `starpivot-console` · `/home/work/starpivot/console` | 同名 · `/home/work/starpivot/console` |
| admin | `starpivot-admin` · `/home/work/starpivot/admin` | 同名 · `/home/work/starpivot/admin` |
| 镜像 tag | `dev-latest`(今日构建) | `0.1.4`(2026-08-18/16 部署) |
| 镜像仓库 | `registry.ymaikj.com`(实体就在测试机上,账号 `admin`) | 同一个仓库 |
| 对外域名 | `ym-dev` / `console-dev` / `mcp-dev`.ymaikj.com | `ym` / `console` / `mcp`.ymaikj.com **DNS 都已就绪**;但 NPM 里只建了 ym 与 console 两条反代,mcp 那条待补(§8.3) |
| 依赖组件 | etcd / mysql / redis / postgres / nats 均在机上 | 同左,齐全 |
### 1.2 数据库
两边各自一套,互不相通:
| | 测试 | 正式 |
|---|---|---|
| console 主库(PG) | `starpivot_console`(容器 `postgres`,角色 `admin`) | 同名同角色 |
| 业务库(MySQL) | `starpivot-app` | `starpivot-ym` |
**console 主库表差异**(正式缺,升级后由 console 启动自动建):
`app_goods`、`app_release`、`chip_vendor`、`oem_factory`、`device_mac`、`settlement_month`。
正式库里还留着老表 `factory`、`factory_deliverynote`、`license`、`third_svc_config`、`third_svc_region_override`。
**业务库表差异**(正式缺,升级后由业务服务启动自动建):
`memory_item`、`memory_report`、`useridverify`。
**行数对照**(关键几张):
| 表 | 库 | 测试 | 正式 | 处置 |
|---|---|---|---|---|
| `user` / `userdevice` / `echomeet_record` | 业务库 | 25 / — / 252 | **0 / 0 / 0** | 不迁(本来就空) |
| `config`(App 自有配置,下发客户端的 env) | 业务库 | **42** | **0** | **要搬**(§7.2) |
| `app_module_config` | 业务库 | 36 | 36 | 不动 |
| `svc_config`(第三方服务池) | console 主库 | **10** | **8** | **要搬差额**(缺实名核验 2 条) |
| `svc_region_override` | console 主库 | 114 | 112 | 随上条一起 |
| `sys_service_config`(平台自用:对象存储/邮件) | console 主库 | 1(email) | **0** | **要搬** |
| `echomeet_orch` / `_setting` | console 主库 | 4 / 1 | 4 / 1 | 核对引用的服务 id 是否都存在 |
| `agent_config` / `call_translate_rule` / `mcp` | console 主库 | 0 | 0 | 无需处理 |
| `global_config` | console 主库 | 473 | 473 | 一致,不动 |
| `echomeet_template` | console 主库 | 287 | **1189** | 正式**偏多**(疑似重复 seed),不搬,见 §12 |
| `product` / `channel` / `production_batch` / `solution_provider` | console 主库 | 4 / 2 / 8 / 3 | **0 / 0 / 0 / 0** | 按你的决定**不搬**,后果见 §12 |
| `device_mac` | console 主库 | 有表 | **无表** | 升级后自动建,内容不搬 |
---
## 2. 阻塞项:上线前必须先处理的三件事
### P0-1 ⚠️ 应用身份三处对不上(最关键)
实测值:
| 位置 | 测试 | 正式 |
|---|---|---|
| `.env` 的 `ANALYZE_APP_NAME` | `EAIMAR-TEST` | **`starpivot-app-dev`**(照抄模板没改) |
| `app_registry.name` | `EAIMAR-TEST` | **`EAIMAR(国内)`** |
| `app_registry.app_name` | `EAIMAR-TEST` | **`EAIMAR`** |
| `app_service_config.app_name`(18 行) | `EAIMAR-TEST` | `starpivot-app-dev` |
测试服三处同值,是对的;**正式服三处两两不同,等于一处都没对上**。后果(两条都静默,不报错):
- 统计快照被 console 拒收 —— 判定点是 `model_registry.go` 的 `getAppByName`,查的是 **`name=?`**。
被拒时只在 console 日志留一行 `console.stat: 快照应用未在 console 注册,未落库`,数据滞留业务侧 Redis,
**后台看板恒为 0 且不报任何错**。
- 按应用作用域解析的配置(渠道分发、`user_getagents_v2`、`user_getthirdsvcs_v2`、会议编排)
**全部退化成全局默认**。
**修法**(把正式服统一成 `EAIMAR`,一次改四处,改完必须整体重启):
```sql
-- console 主库 starpivot_console
UPDATE app_registry SET name = 'EAIMAR', app_name = 'EAIMAR' WHERE id = 1;
UPDATE app_service_config SET app_name = 'EAIMAR' WHERE app_name = 'starpivot-app-dev';
-- 顺带:channel_app 里那条 Voitrans-Test 是测试残留,确认后删
-- DELETE FROM channel_app WHERE app_name = 'Voitrans-Test';
```
```bash
# 正式机 /home/work/ym-a11/.env
ANALYZE_APP_NAME=EAIMAR
```
> ⚠️ `name` 同时是后台应用切换器上的显示名,改完会从「EAIMAR(国内)」变成「EAIMAR」。这是必须付的代价:
> 两列不同值时,`ANALYZE_APP_NAME` 填哪个都只对一半(填 `app_name` → 配置读得到、统计被拒;
> 填 `name` → 统计能落库、作用域配置全退化)。
>
> ⚠️ 改 `app_service_config.app_name` 这一步**不能漏**,那 18 行是该应用的基础设施/运行配置,
> app_name 对不上就全读不到。
### P0-2 ⚠️ 会员与算力等改动还在工作区,没有提交
当前分支 `along`,工作区有大量未提交改动,其中**服务端**部分包括:
- 新增:`comm/compute.go`、`modules/console/api_compute.go`、`modules/user/api_getcompute.go`、`apps/admin/app/pages/compute.vue`
- 修改:`user` 模块 8 个文件、`console` 6 个文件、`echomeet`、`pay`、`api`,以及 `pb/*.pb.go` 与对应 `.proto`
**测试机上跑的 `dev-latest` 就是这份工作区代码构建的。** 生产镜像必须从一个确定的提交构建,
否则回滚时无从对照。上线前先把这批改动提交(是否提交/如何组织由你决定,我不代你 commit)。
建议顺手打个 tag:`git tag v0.2.0 && git push origin v0.2.0`,与镜像 tag 对齐。
### P0-3 ⚠️ 部署脚本里的 SSH 私钥路径在本机不存在
三个 `prod-deploy.sh`(app / console / admin)的 `along` 段都写死:
```
DEPLOY_KEY="/Users/liwei1dao/liwei/密钥/along/releae-guangzhou.pem"
```
本机实际只有 `~/Documents/keys/loginscre.pem`(已验证可连两台机)。二选一:
```bash
# 方案 A(推荐,不改 git 跟踪文件):做一个目录软链,一次性
sudo mkdir -p "/Users/liwei1dao/liwei/密钥/along"
sudo ln -sf ~/Documents/keys/loginscre.pem "/Users/liwei1dao/liwei/密钥/along/releae-guangzhou.pem"
```
```bash
# 方案 B:把三个脚本的 along 段改成可被环境变量覆盖(与 dev-deploy.sh 的写法一致)
# DEPLOY_KEY="${DEPLOY_KEY:-/Users/liwei1dao/liwei/密钥/along/releae-guangzhou.pem}"
# 然后:DEPLOY_KEY=~/Documents/keys/loginscre.pem ./prod-deploy.sh ...
```
---
## 3. 阶段 A · 备份(必做,且不可跳过)
本次升级会在正式服触发**不可逆的启动迁移**:
| 迁移 | 触发点 | 不可逆之处 |
|---|---|---|
| `license_<pid>` 分表 → `device_mac` 合表 | console 启动 `migrateLicenseToDeviceMac` | 建新表并搬数据(旧表刻意不删,可回退) |
| `product` DROP `devicetype` / `scanuuid` 列 | console + api 启动 | **列直接删掉** |
| `chip_vendor` DROP `cid` 列 | console 启动 | **列直接删掉** |
| 旧额度桶折算成算力 | home 启动 `migrateLegacyBucketsToCompute`(`config` 表打 `COMPUTE_LEGACY_MIGRATED` 标记) | 一次性折算 |
| `echomeet_record` 建 ngram 全文索引 | echomeet 启动 | 大表上耗时(正式表为空,本次瞬时) |
```bash
ssh -i ~/Documents/keys/loginscre.pem root@47.116.104.181
# console 主库
docker exec postgres pg_dump -U admin -d starpivot_console --no-owner --no-privileges \
-f /tmp/console-pre020.sql
docker cp postgres:/tmp/console-pre020.sql /root/backup-console-pre020-$(date +%Y%m%d-%H%M).sql
# 业务库
docker exec mysql sh -c 'mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction \
--databases "starpivot-ym"' > /root/backup-ym-pre020-$(date +%Y%m%d-%H%M).sql
# 三份运行时配置(脚本不会覆盖它们,但改坏了要能还原)
tar czf /root/backup-conf-pre020-$(date +%Y%m%d-%H%M).tgz \
/home/work/ym-a11/.env /home/work/ym-a11/confs \
/home/work/starpivot/console/.env /home/work/starpivot/console/confs \
/home/work/starpivot/admin/.env
```
备份文件**拉回本地一份**再继续。
---
## 4. 阶段 B · 构建镜像
三个服务各自构建,都推到阿龙仓库(第 2 个参数 `along` 不能省,省了会默认推云雁仓库,
到远端 `docker pull` 才暴露):
```bash
cd deploy/app && ./prod-build.sh 0.2.0 along
cd ../console && ./prod-build.sh 0.2.0 along
cd ../admin && ./prod-build.sh 0.2.0 along
```
构建完确认三个 tag 都在仓库里:
```bash
for img in starpivot-app starpivot-console starpivot-admin; do
echo -n "$img: "
curl -s -u admin:<仓库密码> "https://registry.ymaikj.com/v2/$img/tags/list" | grep -o '0\.2\.0'
done
```
---
## 5. 阶段 C · 补齐 `.env` 与 `confs`(部署前做)
部署脚本**只下发 `*.example` 模板,绝不覆盖服务器上的真实 `.env` / `confs/*.yaml`**,
所以下面这些必须**手工**在正式机上补。以下每一项都是实测出来的、正式机当前确实缺的。
### 5.1 `/home/work/ym-a11/.env`
| 键 | 现状 | 改成 | 为什么 |
|---|---|---|---|
| `ANALYZE_APP_NAME` | `starpivot-app-dev` | **`EAIMAR`** | 见 P0-1 |
| `MCP_TOKEN_KEY` | **缺** | 一个**与 `GATEWAY_TOKEN_KEY` 不同**的强随机串 | MCP 会话令牌要经百炼之手,不能用能开全站的那把钥匙签;留空则 EMAI 里查会议纪要/待办的工具全部不可用 |
| `ID_HASH_SALT` | **缺** | 强随机串,**一旦启用不要再改** | 实名认证身份证指纹盐;留空不会退化成裸哈希,而是不写指纹(后台查不了同证件多账号) |
| `CLUSTER_TAG` | `starpivot-app-dev` | 建议 `eaimar-prod` | 现值与测试机同名。两机 etcd 各自独立,暂不致命,但语义上是错的;改了要整体重启 |
| `REDIS_KEY_PREFIX` | `EAIMAR` | 不动 | 已是正确的应用身份 |
| `API_SITE_NAME` | `Voitrans(开发)` | 建议改成正式站点名 | 只影响 api 后台显示 |
| `MCP_ADDR` | `http://localhost:7300` | **`https://mcp.ymaikj.com`**(先建反代,见 §8.3) | 这个值会作为 SSE BaseURL 下发给百炼,`localhost` 对外不可达 |
> `.env` 通过 compose 的 `env_file` 整体注入容器,所以新增键**不需要改任何 yaml**,重建容器即生效。
> `docker restart` 不重读 env_file,必须 `up -d --force-recreate`(部署脚本已经是这么做的)。
### 5.2 `/home/work/ym-a11/confs/gateway.yaml`
未登录白名单补两行:
```yaml
- user_getchannelapps # 复数这个才是客户端在调的;少了它游客登录入口在 App 里永远不显示
- echomeet_alibackcall # 阿里录音文件转写的异步回调,外部服务发来、没有 token
```
> ⚠️ `user_getchannelapps` 仓库模板里已经有了,正式机那份是 8-18 的旧文件所以缺。
> ⚠️ `echomeet_alibackcall` **仓库模板里也还没有**(测试机是手工加的)。会议转写用阿里 ASR 就必须放行,
> 否则回调进不来、转写退化成客户端轮询,且静默。
> ⚠️ 该接口自身没有签名校验,只靠阿里下发的 UUID `task_id` 不可猜作保护——正式环境建议后续补回调密钥。
### 5.3 `/home/work/ym-a11/confs/home.yaml`
```yaml
user:
McpTokenKey: "${MCP_TOKEN_KEY:-}" # 与 mcp.yaml 同值;留空则 user_getmcptoken 直接报错
memory: # 记忆中心(拾忆)——正式机整段缺失,要新增
Debug: ${LOG_DEBUG:-false}
max_report_process: 3
report_min_items: 3
report_cron_week: "0 30 0 ? * MON"
report_cron_month: "0 30 0 1 * ?"
report_stale_sec: 900
meeting_extract: true
```
> 直接照 `deploy/app/confs/home.yaml.example` 对齐更稳妥。
> ⚠️ `wordfilter.WorldFile` 保持注释状态 —— 词库 txt 不在 git 里,列了却没有文件,home 会 panic、
> entrypoint 杀容器无限重启,而表象是 api 报 `Table 'xxx.userdevice' doesn't exist`。
### 5.4 `/home/work/ym-a11/confs/mcp.yaml`
```yaml
mcp:
TokenKey: "${GATEWAY_TOKEN_KEY:-}" # 校验登录 JWT,须与 gateway 同值
McpTokenKey: "${MCP_TOKEN_KEY:-}" # 校验 MCP 专用令牌,须与 home.yaml 的 user 段同值
Groups:
CHINA:
# ... 原有工具保留,补这四个:
- search_meeting_notes
- get_memory_items
- get_memory_stats
- get_memory_report
```
### 5.5 console / admin
`console/.env` 与 `admin/.env` 的键集合两边一致,**无需新增**。只确认一条:
⚠️ **`console/.env` 与 `ym-a11/.env` 的 `FIELD_ENCRYPT_KEY` 必须完全相同。**
console 后台把邮件/短信/支付/第三方密钥以 AES-256-GCM 写进库,业务服务启动时用这把 key 解密。
不一致时**解密会静默失败并把密文当明文用**——例如把密文当 Resend API Key 发出去,
表现为 401 `API key is invalid`,而后台显示配置填好了,极难排查。
```bash
# 在正式机上比指纹(不打印明文)
ssh root@47.116.104.181 \
'a=$(grep ^FIELD_ENCRYPT_KEY= /home/work/ym-a11/.env | cut -d= -f2-); \
b=$(grep ^FIELD_ENCRYPT_KEY= /home/work/starpivot/console/.env | cut -d= -f2-); \
[ "$a" = "$b" ] && echo "✓ 一致" || echo "✗ 不一致,必须先统一"'
```
**这一步也是 §7 能不能直接搬 `svc_config` 的前提**:那张表的 `fields` 里是密文。
如果正式服的 key 与测试服不同,从测试库导过去的密文在正式服解不开——那就只能在后台逐项重填,
不能走 SQL 搬迁。所以**在 §7 之前,先按同样方式比一次测试机与正式机的 `FIELD_ENCRYPT_KEY`**。
---
## 6. 阶段 D · 部署
顺序:**console → app → admin**。console 先起,因为建表/迁移和注册表都靠它;
admin 只是前端壳,最后起。
```bash
# 1) console(会跑 device_mac 合表、DROP devicetype/cid 列等迁移)
cd deploy/console && ./prod-deploy.sh 0.2.0 along
ssh -i ~/Documents/keys/loginscre.pem root@47.116.104.181 \
'docker logs --tail 200 starpivot-console 2>&1 | grep -iE "migrat|panic|error|拒收"'
# 2) app(五个业务服务,会建 memory_item/memory_report/useridverify 等表)
cd ../app && ./prod-deploy.sh ym along 0.2.0
ssh -i ~/Documents/keys/loginscre.pem root@47.116.104.181 \
'docker logs --tail 300 ym-a11 2>&1 | grep -iE "panic|fatal|no found file|doesn.t exist"'
# 3) admin
cd ../admin && ./prod-deploy.sh 0.2.0 along
```
部署脚本会做的事(不用你手动):下发 `*.example` 模板、按应用改写 compose 的 service 名、
缺失时补传 `ip2region_v*.xdb`、跑 home.yaml 词库/IP 库前置检查、把 `TAG` 与 `CONTAINER_NAME` 写回 `.env`、
维护 `.deploy_tag` / `.deploy_tag.prev`。
⚠️ app 用的是三段参数形式 `<应用> <区域> <tag>`(`ym along 0.2.0`),
console/admin 是 `<tag> <环境>`(`0.2.0 along`),两者参数顺序不同,别写混。
⚠️ **`.deploy_tag.prev` 目前是空的**(正式机从没部署过第二个版本),所以
**本次部署完成之前 `rollback` 不可用**。本次部完 prev 才会变成 `0.1.4`。
### 部完立刻确认迁移结果
```sql
-- console 主库
\dt -- 应出现 device_mac / app_release / app_goods / chip_vendor / oem_factory
SELECT count(*) FROM device_mac; -- 正式服台账为空,这里预期 0,属正常
SELECT column_name FROM information_schema.columns
WHERE table_name='product' AND column_name IN ('devicetype','scanuuid'); -- 应为 0 行
```
```sql
-- 业务库 starpivot-ym
SHOW TABLES LIKE 'memory%'; -- memory_item / memory_report
SHOW TABLES LIKE 'useridverify';
```
---
## 7. 阶段 E · 搬「平台能力配置」
两边 console 主库互不相通,下面这些要从测试库导到正式库。
**先做 §5.5 的 `FIELD_ENCRYPT_KEY` 比对**——不一致就不要走 SQL,改为在后台逐项重填。
### 7.1 console 主库:第三方服务池与平台服务
正式服相对测试服缺的,实测就是这些:
| 表 | 缺什么 |
|---|---|
| `svc_config` | 实名核验两条:`idverify_aliyun`、`idverify_chuanglan`(`categories=11`) |
| `svc_config` 的 `storage_aliyun_oss` | **少 `domain` 字段**(测试是 `https://oss.ymaikj.com`)。不补不报错,但上传后的地址会退成裸 OSS 域名,见 §8.4 |
| `svc_region_override` | 对应的区域覆盖行(114 vs 112) |
| `sys_service_config` | 整张表空,测试有 `email` 一条 |
```bash
# 在测试机上导出(这些表的 app_name 实测都是空串=全局作用域,所以不需要改 app_name)
ssh -i ~/Documents/keys/loginscre.pem root@8.133.166.29 \
"docker exec postgres pg_dump -U admin -d starpivot_console --data-only --no-owner \
-t svc_config -t svc_region_override -t sys_service_config -f /tmp/svc.sql"
ssh -i ~/Documents/keys/loginscre.pem root@8.133.166.29 'docker cp postgres:/tmp/svc.sql /tmp/svc.sql'
scp -i ~/Documents/keys/loginscre.pem root@8.133.166.29:/tmp/svc.sql /tmp/svc.sql
scp -i ~/Documents/keys/loginscre.pem /tmp/svc.sql root@47.116.104.181:/tmp/svc.sql
```
⚠️ **不要直接 `psql -f` 灌进去** —— `--data-only` 的 dump 是 `COPY` 全量插入,
会和正式库已有的 8 条 `svc_config` 主键冲突(或产生重复行)。正确做法是导进临时 schema 再按需合并:
```sql
-- 正式库 starpivot_console
CREATE SCHEMA IF NOT EXISTS stg;
-- 把 /tmp/svc.sql 里的 COPY 目标改到 stg(或用 psql -v 指定 search_path 导入),然后:
-- ⚠️ 两张表都有自增主键(svc_config.row_id、svc_region_override.id),
-- 必须显式列出列把它排除掉,用 SELECT * 会主键冲突。
-- 业务唯一键:svc_config 是 (app_name, id);svc_region_override 是 (app_name, svc_id, region)。
INSERT INTO svc_config
(app_name, id, name, provider, categories, description, enable, fields, createtime, updatetime)
SELECT s.app_name, s.id, s.name, s.provider, s.categories, s.description, s.enable, s.fields,
s.createtime, s.updatetime
FROM stg.svc_config s
WHERE NOT EXISTS (SELECT 1 FROM svc_config d WHERE d.app_name = s.app_name AND d.id = s.id);
INSERT INTO svc_region_override
(app_name, svc_id, region, overrides, extra_fields, disabled_keys, updatetime)
SELECT s.app_name, s.svc_id, s.region, s.overrides, s.extra_fields, s.disabled_keys, s.updatetime
FROM stg.svc_region_override s
WHERE NOT EXISTS (SELECT 1 FROM svc_region_override d
WHERE d.app_name = s.app_name AND d.svc_id = s.svc_id AND d.region = s.region);
-- sys_service_config 的主键 id 是业务串(如 'email'),可以整行插
INSERT INTO sys_service_config
SELECT * FROM stg.sys_service_config s
WHERE NOT EXISTS (SELECT 1 FROM sys_service_config d WHERE d.id = s.id);
DROP SCHEMA stg CASCADE;
```
> 更省事也更稳的替代路径:**只有 3 条记录**(2 条实名核验 + 1 条 email),
> 直接在正式后台「第三方服务配置 / 系统配置」里照测试后台重填一遍,凭据从服务商处取。
> 这样连 `FIELD_ENCRYPT_KEY` 是否一致都不用管。**记录少的时候优先走这条。**
⚠️ `svc_config` 里 `categories=11`(身份证实名核验)是**服务端专用类别**,
凭据是云账号主 AK/SK。它不会下发给客户端——拦截点是 `svcresolve.go` 里的
`comm.IsServerOnlySvc` 那三行,`comm/svcpool_serveronly_test.go` 守着。别动那段。
### 7.2 业务库:`config` 表(42 条,正式全空)
这是 `user_getappconfig` 下发给客户端的 `env`,客户端用 `AppConfig.env('X')` 读。
正式库为空 = App 端所有第三方能力(Azure 语音、火山、讯飞、COS、EMAI 百炼、Spotify)全无配置。
```bash
# 测试机导出整张 config(--where 的引号嵌套太容易写错,改成先全导、导入后再删那一行)
ssh -i ~/Documents/keys/loginscre.pem root@8.133.166.29 \
'docker exec mysql sh -c '"'"'mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --no-create-info \
--complete-insert "starpivot-app" config'"'"'' > /tmp/config.sql
scp -i ~/Documents/keys/loginscre.pem /tmp/config.sql root@47.116.104.181:/tmp/config.sql
# 正式机导入后,立刻删掉迁移标记那一行(id=43,group='compute')
# DELETE FROM config WHERE `group` = 'compute';
```
导入正式库前**逐条过一遍**,下面这些要按正式环境改,不能照搬:
| 分组 | 键 | 处置 |
|---|---|---|
| `compute` | `COMPUTE_LEGACY_MIGRATED` | **不要搬**(上面的 `--where` 已排除)。它是旧额度桶折算的完成标记,搬过去会让正式服跳过折算 |
| `COS` | `COS_SECRET_ID/KEY/APP_ID/REGION/BUCKET_NAME` | 老客户端仍读它直传腾讯云,**先保留**;新客户端走服务端预签名(走 `sys_service_config` 的对象存储 / `svc_config` 的 `storage_aliyun_oss`) |
| `ALIBABA_BAILIAN_APP_ID` / `_WORKSPACE_ID` | EMAI 百炼 | 正式服若用不同的百炼应用,**必须改**;代码里有硬编码兜底,配错了不报错、静默用写死的值 |
| `AI` / `AZURE` / `VOLCANO` / `VOLC_OPENSPEECH` / `XunFei` | 各家密钥 | 正式与测试用同一套账号就直接搬;要分账号就在后台改 |
| `APP` | `APP_AUTHCODE_OPEN` / `APP_NAVIGATION_MODE` 等 | 按正式的产品策略核一遍 |
### 7.3 不需要搬的
- `agent_config`、`call_translate_rule`、`mcp` —— 两边都是 0 行。
- `global_config` —— 两边都是 473 行,一致。
- `third_svc_template` —— console 启动会 seed 内置服务商模板,自动补齐。
- `echomeet_orch` / `echomeet_orch_setting` —— 两边行数一样(4 / 1)。
**但要核对它引用的服务 id 在正式的 `svc_config` 里都存在**,缺一个那条编排就哑掉:
```sql
-- 正式库:把编排里引用的 svc id 拉出来和 svc_config 对一遍
SELECT * FROM echomeet_orch;
SELECT id, name, enable FROM svc_config ORDER BY categories, id;
```
---
## 8. 阶段 F · 后台补配与反代
### 8.1 会员与算力参数(业务库 `config` 表,后台「会员与算力」页)
正式服这批键当前全缺,**全部允许配 0**(0 = 不送 / 不提示),解析只在「键不存在或非数字」时才回默认:
| 键 | 含义 |
|---|---|
| `COMPUTE_RATE_TRANSLATE` / `_MEETING` | 算力 / 分钟 |
| `COMPUTE_RATE_AICHAT` | 算力 / 100 字 |
| `COMPUTE_GATE` | `1`=余额不足即拒;`0`(默认)=只记账不拦 |
| `NEWUSER_GIFT_VIPDAYS` / `NEWUSER_GIFT_COMPUTE` | 开户礼 |
| `VIP_WARN_DAYS` / `COMPUTE_WARN` | 客户端提示阈值 |
⚠️ **`COMPUTE_GATE` 先保持 0(关)。** 现在 `user_usages` 是「用完才上报」,**没有预扣**,
开了闸门也形同虚设,反而可能误伤。要开闸门得先把预扣做出来。
### 8.2 对象存储(预签名直传的前提)
正式服 `sys_service_config` 空,`svc_config` 里有 `storage_aliyun_oss`(`enable=t`)。
配置的唯一读取点是 `comm/ossconf.go`,取值顺序:**系统配置→对象存储**(`sys_service_config`)优先,
回退**第三方服务配置→存储**(`svc_config`, provider=`aliyun_oss`)。
上线后必须实际验一次上传(§9),否则 App 的录音/图片上传会整条报「不支持的上传用途」或 403。
### 8.3 补上 MCP 的反代与证书(DNS 已就绪,缺的是 NPM 这一步)
正式服的三个域名 **DNS 都已经配好并指向 47.116.104.181**(2026-09-11 在正式机上 `getent hosts` 实测)。
但 NPM 里只建了两条 proxy host,`mcp` 这条还没有:
| 域名 | DNS | HTTPS | 实际落到哪 |
|---|---|---|---|
| `ym.ymaikj.com` | ✅ | ✅ 返回 404(网关对 `/` 的正常响应,真实路由是 `/api/:p1/:p2`) | `ym-a11` |
| `console.ymaikj.com` | ✅ | ✅ | `starpivot-admin` |
| `mcp.ymaikj.com` | ✅ | ❌ `http=000 ssl=1`(TLS 握手失败,没有该域名的证书) | HTTP 落到 openresty 默认页,**没转到 7300** |
实测依据:`/home/work/nginx-proxy-manager/data/nginx/proxy_host/` 下只有 `1.conf`(console) 与 `2.conf`(ym),
`dead_host` / `redirection_host` / `stream` 三个目录都是空的。
所以只剩三步:
1. NPM 新建 proxy host:`mcp.ymaikj.com` → `ym-a11:7300`,申请证书,
**必须勾上 WebSockets Support** —— MCP 走 SSE 长连接,默认配置会被缓冲/断开
2. `.env` 的 `MCP_ADDR=https://mcp.ymaikj.com`,重建容器(`MCP_ADDR` 是下发给百炼的 SSE BaseURL)
3. 百炼控制台把 MCP server 地址指到这个域名
### 8.4 域名全清单:服务端不需要再加域名
把所有对外入口过了一遍(2026-09-11 实测),**正式服部署不需要新增任何自有服务器域名**:
| 入口 | 容器:端口 | 正式域名 | 状态 |
|---|---|---|---|
| 业务网关 gateway HTTP | `ym-a11:7100` | `ym.ymaikj.com` | ✅ 已通 |
| 管理后台 admin | `starpivot-admin:3000` | `console.ymaikj.com` | ✅ 已通 |
| MCP HTTP/SSE | `ym-a11:7300` | `mcp.ymaikj.com` | DNS ✅,**反代待建(§8.3)** |
| api 的 Console 后台 | `ym-a11:8080` | — | **不需要对外**,测试服也没暴露;admin 走容器内 `CONSOLE_BACKEND` |
| gateway/home/timer/mcp/api 的 RPCX | 7001–7005 | — | 集群内部,走 etcd 发现,**绝不能对外** |
对照测试服:starpivot 相关也**只有三条** NPM 规则(`ym-dev`→7100、`mcp-dev`→7300、`console-dev`→3000),
其余 `gitea` / `registry` / `keemir-dev` / `dev.yimaizn.com` / `www.ymaikj.com` 都是别的项目。正式一一对应,没有遗漏的端口。
#### 但有一个 OSS 域名要注意(不是服务器域名,是 OSS 自定义域名)
`oss.ymaikj.com` → `8.133.135.83`(阿里云 OSS,不指向任何一台业务机)。实测**已生效**:
根路径 403(列桶被拒,正常)、不存在的 key 返回 404(说明域名确实绑到了桶并能正确路由)。
测试与正式**共用同一个桶 `ymaioss`(上海)**,所以这个域名正式服直接复用,**无需新增、无需改解析**。
⚠️ **但正式服的 `svc_config.storage_aliyun_oss` 里没有 `domain` 字段**(实测只有 `bucket=ymaioss`、
`endpoint=https://oss-cn-shanghai.aliyuncs.com` 两项),测试服那条是 `domain = https://oss.ymaikj.com`。
后果不是报错:`comm.AliyunOSSConf.PublicURL()` 在 domain 为空时回退成「把预签名 query 去掉的裸 OSS 地址」,
即 `https://ymaioss.oss-cn-shanghai.aliyuncs.com/EAIMAR/...`。桶是公共读,所以**能播能看**,
只是所有录音/图片地址都不走 `oss.ymaikj.com`。§7.1 搬配置时把 `domain` 这一项一起补上。
#### 客户端相关的辅助域名(本轮不发版,先记账)
这四个域名**目前全部解析到测试机 `8.133.166.29`**,而且测试机 NPM 上也没有它们的反代规则
(= 解析了但没服务):
| 域名 | 现解析 | 用途 | 什么时候必须处理 |
|---|---|---|---|
| `pay.ymaikj.com` | 测试机 | 支付回调。服务端的 `notify_url` 是**客户端下单时传上来的**(`CreateOrderReq.NotifyUrl`),不是服务端配置 | 正式环境要收款时。届时客户端传的 notify_url 必须能打到**正式** gateway 的 `pay_wechatnotify` / `pay_alipaynotify` |
| `share.ymaikj.com` | 测试机 | 会议分享落地页 | 正式启用分享时 |
| `web.ymaikj.com` | 测试机 | **EAIMAR 的用户侧 Web 版**:录音同步到云端后在网页上播放/管理/分享。App 里已有真实入口——`member_bottom_sheet.dart:97` 点了会用内嵌 WebView 打开 `https://web.ymaikj.com`(`meeting_mine_view.dart:95` 那条 WEB 只展示、`onTap` 是空的),41 个语言的文案里也都写了这个地址。但 `apps/web` 只有 `app.vue` + `index.vue` 两个文件,页面内容是「Yunyan / Web 前台(开发中)」,**功能没做、从未部署** | 做完 Web 版时。在那之前 App 里那个入口点开就是空白页/打不开 |
| `cdn.ymaikj.com` | 测试机 | **没有任何代码在用它。** 只出现在两处文字里:`comm/ossconf.go:135` 的注释举例,和 `api_svctemplate.go:299` 里后台「存储」表单 `domain` 字段的提示文案「自定义域名(可选,如 cdn.ymaikj.com;留空用 OSS 源站地址)」。实际在用的是 `oss.ymaikj.com` | 不用管。纯历史占位,解析到测试机没有任何影响 |
⚠️ 本轮只发服务端、客户端仍指向 `ym-dev`,所以这四个不阻塞上线。但**正式发客户端那一版之前必须先改解析**,
否则正式包的支付回调和分享链接会打到测试服。
### 8.5 应用登记与产品
- 确认 `app_registry` 那行改完后后台能正常切换应用(§2 P0-1)。
- 产品台账按你的决定不搬,需要在正式后台重新建产品、批次、导入设备 MAC —— 影响见 §12。
---
## 9. 阶段 G · 验收清单
逐条跑,每条都给了「失败时先看哪」:
| # | 验收项 | 怎么验 | 失败先看 |
|---|---|---|---|
| 1 | 三个容器都在跑且版本对 | `docker ps` 看 `ym-a11` / `starpivot-console` / `starpivot-admin` 都是 `0.2.0` | `docker logs` |
| 2 | home 没 panic | `docker logs ym-a11 \| grep -i panic` 无输出 | 词库/ip2region 文件缺失 |
| 3 | 新表都建出来了 | §6 的两段 SQL | 服务启动顺序 |
| 4 | **统计不再被拒收** | `docker logs starpivot-console \| grep "未在 console 注册"` **无输出** | P0-1 没改全 |
| 5 | 后台能登录、能切应用 | 打开 `https://console.ymaikj.com` | admin 的 `CONSOLE_BACKEND` |
| 6 | 游客登录入口结论能取到 | `curl -sX POST https://ym.ymaikj.com/api/home/user_getchannelapps -d '{}'` 不返回 `code:18` | gateway 白名单漏了 `user_getchannelapps` |
| 7 | 第三方配置能解密 | 后台触发一次发邮件/短信 | `FIELD_ENCRYPT_KEY` 两边不一致(报 401/鉴权失败但后台显示配置正常) |
| 8 | 文件上传 | App 或 `curl` 走 `user_getuploadurl` 拿预签名再 PUT | OSS 配置、scene 白名单、Content-Type 必须用服务端回带的那个 |
| 9 | MCP 可达 | `curl -o /dev/null -w "%{http_code}\n" https://mcp.ymaikj.com/` —— 拿到 `000` 说明证书没签,拿到 openresty 默认页说明反代没建 | §8.3 那条 proxy host / `MCP_ADDR` 还是 localhost |
| 10 | MCP 鉴权 | 登录后 `user_getmcptoken` 能拿到令牌 | `MCP_TOKEN_KEY` 缺失或两边不同值 |
| 11 | 实名核验 | `user_idverify` 走通 | `svc_config` 的 `idverify_*` 没搬 / `ID_HASH_SALT` 缺(缺盐不阻断,只是不写指纹) |
| 12 | 会议转写回调 | 起一次转写任务,看阿里回调有没有进来 | `echomeet_alibackcall` 没进白名单(静默) |
| 13 | 客户端配置下发 | `user_getappconfig` 的 `env` 非空 | 业务库 `config` 表没搬 |
---
## 10. 回滚
```bash
cd deploy/app && ./prod-deploy.sh ym along rollback
cd ../console && ./prod-deploy.sh rollback along
cd ../admin && ./prod-deploy.sh rollback along
```
⚠️ **镜像能回滚,数据库迁移不能。** `product.devicetype` / `chip_vendor.cid` 这两列是真删了的,
回到 `0.1.4` 的镜像后老代码仍会读它们。所以:
- 回滚**必须**连同 §3 的库备份一起回退,否则老镜像会报缺列;
- `device_mac` 合表刻意保留了旧的 `license_*` 分表,这一项本身可回退;
- 若只是某个功能不对劲,**优先在后台关掉该功能 / 改配置**,不要轻易整体回滚。
---
## 11. 红线:绝对不要从测试库搬到正式库的东西
| 表 | 为什么 |
|---|---|
| `app_service_config` | **存 DSN / Redis / NATS / COS 地址**。搬过去 = 把正式服指向测试库。18 行都要留在正式库自己的值,只改 `app_name`(P0-1) |
| `app_registry` | 正式服有自己的注册行(id=1),搬过去会把应用身份搞乱 |
| `app_release` / `channel_app` | 版本控制、强更、游客显隐、渠道下载地址。**测试服的版本号照搬到正式会误触发强更或让入口消失** |
| `console_account` | 后台账号与 bcrypt 密码,各环境独立 |
| `stats_global_day` / `settlement_month` | 统计与结算数据,搬过去等于伪造正式数据 |
| `user` / `userdevice` / `useridverify` / `echomeet_record` / `payorder` / `useruselog` / `userstatistics` / `memory_*` / `allhelp_task` / `chat_summary` / `product_stat` / `console_log` / `admin_resource_log` | **用户数据**。正式库本来就是空的,保持空 |
| `config` 的 `COMPUTE_LEGACY_MIGRATED` | 迁移完成标记,搬了会让正式服跳过旧额度折算 |
---
## 12. 遗留缺口(按你「业务台账不搬」的决定,这些是明确的后果)
1. **正式服没有任何产品** —— `product=0`、`production_batch=0`、`device_mac` 表为空。直接后果:
- **设备绑不上**:`user_binddevice` 对经典蓝牙耳机是「按 MAC 反查授权码」,MAC 不在 `device_mac` 里
一律 `AuthorizeNoCanUse`;恒玄那条链路还会因此**确权失败直接断开连接**并弹「设备连接受限」。
- **OTA 不可用**:固件版本挂在产品上(`user_getappconfig` 的 products),没有产品就永远「已是最新」。
- 要用起来,得在正式后台依次建:方案商 → 品牌商 → 渠道商 → 产品 → 生产批次 → 导入设备 MAC。
**如果正式服近期要接真机,这一步比什么都优先**,建议单独排期。
2. **`echomeet_template` 正式有 1189 行、测试只有 287 行** —— 疑似历史上重复 seed 过。
不影响功能(按 id 取),但后台模板列表会很长。要清理得先确认哪些是 seed、哪些是人工加的,本次不动。
3. **`CLUSTER_TAG` 两台机同名** —— 目前各自 etcd 独立,不致命;建议本次一并改成 `eaimar-prod`。
⚠️ 改了要**整体重启**,改一半会让同机内五个服务互相发现不到。
4. **`echomeet_alibackcall` 没有签名校验** —— 只靠阿里下发的 UUID `task_id` 不可猜。
正式环境建议后续补回调密钥或签名。
5. **仓库的 `gateway.yaml.example` 还缺 `echomeet_alibackcall`** —— 下次新环境部署会重复踩。
建议把这行补进模板(本次未改)。
6. **`pay` / `share` / `web` 三个域名仍解析到测试机 8.133.166.29** —— 见 §8.4。本轮不阻塞,
正式发客户端前必须改解析,否则正式包的支付回调、分享链接会打到测试服。
7. **客户端未发版** —— 老包仍指向 `ym-dev.ymaikj.com`(测试环境)。正式服上线后
**没有任何客户端在连它**,验收只能靠 curl / 后台。要真正投入使用需另发一版把
`SERVER_URL` 切到 `ym.ymaikj.com`,届时还要配「版本控制 / 游客显隐控制」两页。
---
## 附:本文档的事实来源
除标注为「建议」的部分,其余均为 2026-09-11 在两台服务器上实测:
`docker ps` / `.env` 键集合 / `confs/*.yaml` 逐字 diff / `pg_tables` 与 `show tables` 表清单 /
关键表行数与 `app_name` 分布 / NPM proxy host 清单。脚本行为来自
`deploy/{app,console,admin}/{prod-build,prod-deploy}.sh` 与 `deploy/registry-profiles.sh`。

587
docs/会员权益与用量统计-设计与开发文档.md

@ -0,0 +1,587 @@
# 会员权益与算力 · 设计与开发文档
> 状态:**v5 一期已落地(服务端 + 后台 + 客户端,2026-09-11)**,二期(带存储设备)仅设计|最后更新:2026-09-11
> 一期改动的落点见 CLAUDE.md「会员与算力」一节;第 10 节的「现在(线上)」列描述的是改造前的旧行为。
---
## 0. 一句话概括
两层权益,各管一件事:
| 层 | 管什么 | 来源 |
|---|---|---|
| **VIP** | **功能准入**:没有 VIP,翻译与会议纪要不可用 | 设备赠送(按天)+ 用户自购 |
| **算力** | **用量计量**:翻译、会议、AI 对话的消耗统一折算成算力 | 设备赠送(有上限)+ 用户充值 |
- 归属规则两层一致:**设备赠送的归设备**(解绑即失去、随设备流转),**用户自购的归账号**
- 算力的**换算系数后台可配**,客户端只上报原始用量、只展示算力
- 算力是否作为闸门(用完就拦)由**后台开关**控制,**默认关**——只记账不拦
### 相对 v4 的变化
v4 把权益简化成「只有 VIP」,用量只记原始数据。本版把**算力**加回来,作为统一的用量计量与设备赠送的载体:
| v4 | v5 |
|---|---|
| 生产批次只配 VIP 天数 | VIP 天数 + **赠送算力上限** |
| 用量按翻译秒 / 会议秒 / AI 字数分别记 | 原始数据照记,**另外统一折算成算力** |
| 无换算 | 换算系数**后台可配**,改价不发版 |
| 无消耗品 | 用户可**充值算力**,商品加算力发放项 |
| 无闸门 | 算力闸门做成**开关,默认关** |
---
## 1. 权益模型
### 1.1 VIP:功能准入
```
翻译 / 会议纪要是否可用 = 自购 VIP 未过期
或(当前绑定着某台设备 且 该设备的 VIP 期未结束)
```
任一成立即可用。不相减、不收回、不结转。受限范围见 1.5。
### 1.2 算力:用量计量
**算力(compute)是平台自定义的计量单位**,所有产生服务商成本的功能都折算成它:
| 功能 | 原始计量 | 换算 | 后台配置键 |
|---|---|---|---|
| 翻译(同传 / 面对面 / 通话 / 影音 / 文本 / 图片) | 秒 | 每分钟 = `N` 算力 | `COMPUTE_RATE_TRANSLATE` |
| 会议纪要 | 秒 | 每分钟 = `N` 算力 | `COMPUTE_RATE_MEETING` |
| AI 对话 | 输入 + 输出字符数 | 每 100 字 = `N` 算力 | `COMPUTE_RATE_AICHAT` |
- **系数在服务端换算**,客户端不参与。改系数只影响改动之后的消耗,不追溯
- 三个系数默认都是 `1`,即 1 分钟翻译 = 1 分钟会议 = 100 字对话 = 1 算力
- 单次会话内按原始量累计,**结束时换算并向上取整**,单次最低 1 算力
- 字符按 Unicode 码点计:中文 1 字 = 1,英文 1 字母 = 1
> ⚠️ **算力不是大模型的 token**,与模型侧的 tokenizer 无关。代码里统一用 `compute`,
> 不要再出现 token 这个词,避免与服务商用量混淆。
### 1.3 算力的两个来源与归属
| | 设备赠送算力 | 用户充值算力 |
|---|---|---|
| 来源 | 生产批次配置,激活时快照 | 充值、后台调整 |
| 归属 | 设备硬件 | 账号 |
| 存储 | 设备行 `compute_grant`(总量)/ `compute_used`(已用) | 用户行 `compute_balance` |
| 有效期 | 不过期,用完为止 | 不过期,用完为止 |
| 解绑 | 立即失去剩余部分 | 不受影响 |
| 流转 | 剩余部分随设备给下一个绑定者 | 跟着账号走 |
| 扣减 | **先扣这一份** | 设备剩余为 0 时才动用 |
> ⚠️ **设备算力是共用一份,不是每人一份。** 前一个人用掉 300,下一个人接手只剩 700。
> `compute_used` 在解绑时**不重置**,否则一台设备在几个账号间轮流绑定就能无限刷。
> 设备算力与设备 VIP 期**相互独立**:VIP 期到了算力还在,算力用完了 VIP 期还在,都是正常状态。
### 1.4 算力闸门:后台开关,默认关
| 开关状态 | 消耗时的行为 |
|---|---|
| **关(默认)** | 换算 → 扣减(先设备后用户,扣到 0 为止)→ 超出部分记为「超额」→ **照常放行** |
| 开 | 换算 → 设备剩余 + 用户余额 < 本次消耗 → **拒绝**,返回算力不足 |
默认关的理由:上一轮已定「取消额度闸门」——用户在 VIP 期内不限量。
算力先作为**记账与展示**跑起来,数据积累到位、商业策略明确后再决定要不要开。
> ⚠️ **开闸门需要配合预扣。** 现在 `user_usages` 是客户端用完之后上报、服务端才扣,
> 余额校验发生在扣减那一刻——余额只剩 1 的用户仍可先用满 100 分钟再上报。
> 开关关着时这不是问题(反正放行);**开关打开前必须先把预扣 + 心跳续扣做了**,
> 否则闸门形同虚设。预扣逻辑本期不做,列为开关的前置条件。
### 1.5 功能权限边界(VIP 过期后)
| 功能 | VIP 期内 | VIP 过期 | 算力 | 说明 |
|---|---|---|---|---|
| 同传 / 面对面 / 通话 / 影音翻译 | ✅ | ❌ 受限 | 扣 | |
| 文本翻译 / 图片翻译 | ✅ | ❌ 受限 | 扣 | 同属翻译,同样按量计费 |
| 会议纪要(新建转写 / 总结) | ✅ | ❌ 受限 | 扣 | |
| **历史翻译记录、历史会议纪要** | ✅ | ✅ **可查看** | 不扣 | 数据是用户的 |
| EMAI AI 对话 | ✅ | ✅ 不受限 | **扣** | 唯一「不受 VIP 限但扣算力」的功能 |
| 通话录音 / 现场录音 | ✅ | ✅ | 不扣 | 本地能力 |
| 设备连接、电量、设备管理、OTA | ✅ | ✅ | 不扣 | 不产生服务商成本 |
| EQ / 音色 / 音乐 | ✅ | ✅ | 不扣 | 同上 |
| 商城、支付、实名认证 | ✅ | ✅ | 不扣 | **必须可用**,否则用户无法续费 |
> - **历史数据永远可查看。** 受限的是「新建」,不是「回看」
> - **VIP 与算力的组合**:VIP 过期但算力还有 → 翻译/会议不可用(准入没过),AI 对话可用并扣算力;
> VIP 在但算力为 0 → 闸门关时照常可用(记超额),闸门开时拒绝
### 1.6 受限怎么落地:两类拦截点
**不是所有功能都能在服务端拦住**,关键区别是这条链路走不走我们自己的服务器:
| 功能 | 数据流向 | 服务端能否强拦 | 拦截点 |
|---|---|---|---|
| 文本 / 图片翻译 | 客户端 → **自家网关** → 服务商 | ✅ | [api_translate.go](../apps/services/modules/user/api_translate.go) 入口 |
| 同传 / 面对面(三段式) | ASR 直连,**机器翻译走自家网关** | ✅ | 同上,拦住翻译那一跳即整体不可用 |
| 会议纪要 | 录音传 OSS,**服务端起任务** | ✅ | `echomeet` starttask 入口 |
| **通话翻译(端到端)** | **客户端直连服务商 WS** | ⚠️ **拦不住** | 只能靠「不下发凭据」+ 客户端自觉 |
> ⚠️ 通话翻译走阿里端到端,客户端拿着服务端下发的凭据直连服务商 WebSocket,
> **整个过程一个请求都不经过我们的服务器**。唯一的服务端手段是在
> [svcresolve.go](../apps/services/modules/user/svcresolve.go) 的 `resolveThirdSvcs`
> 里按 VIP 状态决定**要不要下发翻译类凭据**。凭据是启动时拉一次的,
> VIP 在使用期间过期时客户端手里的凭据仍有效,直到下次重新拉配置——
> 这个窗口(最长一个启动周期)已接受,不值得为它引入短 TTL。
**双端都要做,职责不同**:客户端进入功能前判断,负责体验;服务端接口入口判断,负责兜底。
### 1.7 受限时的交互口径
| 项目 | 口径 | 理由 |
|---|---|---|
| 入口表现 | **可点击,点击后弹开通引导**;不置灰 | 置灰不说明原因,用户只会以为功能坏了 |
| 提示内容 | 说明「续费会员」与「绑定带会员的设备」**两条路** | 设备也能带来 VIP |
| 进行中的会话 | **允许跑完,不中途掐断** | 翻译到一半断掉是事故级体验 |
| 算力展示 | 「设备剩余 + 账户余额」合并展示,明细可拆 | 数字无声变少用户一律当 bug |
---
## 2. 设备权益:VIP 天数 + 赠送算力
### 2.1 生产批次配置
在后台「MAC 生成管理 → 生成 MAC」为每个批次配置:
| 配置项 | 单位 | 示例 | `0` 的含义 |
|---|---|---|---|
| `vipdays` | 天 | 180 | 不送 VIP |
| `viplevel` | 等级 | 1 | 1 = VIP,2 = VIP-Pro(预留) |
| `compute_grant` | 算力 | 1000 | 不送算力 |
原先的 `rewardtranslate` / `rewardmeeting`(翻译分钟、会议分钟)**不再使用**,存量列保留不动。
- **生产批次优先,未配置回退产品档案**——判据是批次三项全 0
- **激活时快照**:首次绑定时把三项固化到设备行,此后改批次配置只影响尚未激活的库存设备
### 2.2 激活日与连续计时
**激活日 = 设备第一次被任意账号成功绑定的服务端时间**,已有字段 `device_mac.usedtime`,直接复用。
设备 VIP 期 = `[激活日, 激活日 + vipdays)`,按日历天连续走,**解绑不暂停**。
不需要定时任务,任何时刻的状态都能直接算出。
### 2.3 设备 VIP 与自购 VIP 必须分开存
现有做法是把赠送天数**累加到 `user.vipexptime`**,一旦累加就与自购混成同一个数字,解绑时无法拆分。
**正确做法:设备侧不写用户表。**
| 来源 | 存在哪 | 怎么算 |
|---|---|---|
| 自购 VIP | `user.vipexptime`(只记自购) | 直接读 |
| 设备 VIP | 设备行 `usedtime` + `vipdays` | `激活日 + vipdays` |
解绑时**什么都不用做**——不绑了自然就不享有了。
### 2.4 VIP 等级
`user.viplv` 已存在(支付成功时置 1)。实际等级 = max(自购等级, 当前绑定设备的等级)。vip-pro 用等级 2,无需新字段。
---
## 3. 绑定规则 · 一期:无存储设备
| 场景 | 行为 |
|---|---|
| 别人直接绑定 | **允许顶掉**(接管),原绑定自动失效。与现有实现一致 |
| 同账号换手机 | 允许(uid 相同,不算接管) |
| VIP 期 / 设备算力 | 接着走、接着用,**不重新开始**,不重置激活日 |
| 原持有者 | 立即失去设备 VIP 与设备剩余算力,**需给提示** |
数据不跟着走:新用户看不到前一个人的录音、纪要、对话——那些按 uid 存在服务端。
---
## 4. 用量与算力记账
### 4.1 谁上报什么
| 数据 | 来源 | 现状 |
|---|---|---|
| 翻译时长(按模式) | **App 上报**原始秒数 | ✅ 已有:`Trademodel1~5Time` |
| AI 对话输入 / 输出字数 | **App 上报**原始字符数 | ⚠️ 字段有但从未写入:`Aichatuptoken` / `Aichatdowntoken` |
| 会议纪要时长 | **服务端自己统计** | ✅ 已有:`echomeet` 里 `Meettime += model.Seconds` |
> ⚠️ **客户端只上报原始量,不上报算力。** 换算系数在后台可改,客户端如果自己算,
> 改一次系数就要发一次版,且新老版本算出的数不一致。客户端拿到的算力是服务端**返回**的。
### 4.2 消耗流程(`user_usages` 与 `echomeet` starttask 共用)
1. 收到原始用量(App 上报的秒数 / 字数,或服务端自算的会议秒数)
2. 写原始统计(现有字段照写,不动)
3. 按 `COMPUTE_RATE_*` 换算成算力 `c`,向上取整,最低 1
4. **扣减**:先扣当前绑定设备的剩余(`compute_grant - compute_used`),不够再扣 `user.compute_balance`,扣到 0 为止;仍不够的部分记为**超额**
5. 闸门**开**且超额 > 0 → 拒绝,返回算力不足;闸门**关** → 放行
6. 写流水:`user_use_log` 记本次算力 `c` 与来源拆分(设备 / 用户 / 超额)
7. 累计:`user_statistics.compute_total += c`
8. 返回当前算力余额(设备剩余 + 用户余额)
绑定多台设备时,先扣**剩余最少**的那台(用完一台再动下一台,避免多台都剩一点)。
### 4.3 展示
客户端「用量」页展示:
- VIP 状态、来源(自购 / 设备)、到期日
- 算力:**设备剩余 + 账户余额**合并数,明细可拆
- 累计消耗、本月消耗
数据从服务端 `user_getcompute` 一次拿齐,客户端不做任何换算。
### 4.4 已定取舍:算力闸门默认关
VIP 期内不限量意味着单用户成本上不封顶。这是产品上明确接受的取舍。
算力记账跑起来之后,后台看板与异常告警自然就有了数据基础;要不要开闸门,看数据再定。
---
## 5. 带存储设备 · 二期
> 本阶段暂不实现,先把设计定下来。现役设备均不带存储。
### 5.1 两个产品开关
| 字段 | 含义 | 影响 |
|---|---|---|
| `has_storage` | 带不带本地存储 | 为 0 时下面一项无意义 |
| `need_erase` | 解绑时需不需要清空 | 驱动**解绑确权弹窗** + **禁止接管** |
| `has_storage` | `need_erase` | 可否被顶掉 | 解绑弹确权 |
|---|---|---|---|
| 0 | — | ✅ | 否 |
| 1 | 0 | ✅ | 否 |
| 1 | 1 | ❌ **禁止** | **是** |
> 建议「禁止接管」挂在 `need_erase` 上(见 Q1)。`devicetype` 已废弃,不要依赖它。
### 5.2 清空标签
设备行新增 `erasestate`(0 = 无残留,1 = 待清空)与 `lastuid`(最后绑定者,解绑时写入不清空)。
**解绑时写标签,绑定时随「校验 MAC」返回给客户端。**
| 解绑时 | `erasestate` |
|---|---|
| 设备在线,清空成功 | 0 |
| 设备在线,用户选保留 / 清空失败 | 1 |
| 设备未连接 | 1,**照常允许解绑** |
| `need_erase = 0` | 0 |
| 绑定时读到 | 客户端动作 |
|---|---|
| 0 | 直接握手 |
| 1 且 `lastuid ≠ 当前 uid` | 握手后**第一时间下发清空指令**,成功后上报置 0 |
| 1 且 `lastuid = 当前 uid` | **跳过清空**,直接置 0 |
> ⚠️ `lastuid` 不能省:A 离线解绑后又自己绑回来,无脑清空删的是 A 自己的数据。
清空失败不阻断使用:标签保持 1,每次连接重试,App 顶部提示。
### 5.3 解绑流程(顺序不能反)
1. 点解绑 → 2. 判 `need_erase`,为 0 跳到 6 → 3. 弹确权:清空并解绑 / 保留并解绑 →
4. 选清空且在线:下发**带回执**的清空指令 → 5. 失败/超时让用户选重试或仍然解绑,**不能卡死** →
6. 调解绑接口写标签 → 7. 清本地痕迹并断连
> ⚠️ 现有实现是「先调解绑接口 → 清白名单 → 断连」。一旦解绑链路已断,清空指令没通道。
> **清空必须排在解绑接口之前。**
### 5.4 必须一并解决的两件事
- **解绑时 `uid` 清理**现在是尽力而为的(失败只记日志)。禁止接管的规则下,`uid` 没清掉 = 设备永久锁死。带存储设备的解绑必须**强一致**
- **设备锁死的兜底**:原用户手机丢了 / 卸载 / 注销 / 忘记解绑就转卖 —— **后台强制解绑必须做**;设备端物理重置推荐做;超时自动释放不推荐
### 5.5 三个前提,目前都不成立
产品档案无存储能力位;固件无清空指令(且命令号随固件版本变,新指令必须带回执);现役耳机不带存储(录音流式推给手机)。
---
## 6. 管理后台功能清单
按页面列,每项标明字段与接口。
### 6.1 系统配置 → 算力设置(新页面)
| 项 | 键(`config` 表,KV) | 类型 | 默认 |
|---|---|---|---|
| 翻译换算系数(算力 / 分钟) | `COMPUTE_RATE_TRANSLATE` | int | 1 |
| 会议换算系数(算力 / 分钟) | `COMPUTE_RATE_MEETING` | int | 1 |
| AI 对话换算系数(算力 / 100 字) | `COMPUTE_RATE_AICHAT` | int | 1 |
| 算力闸门 | `COMPUTE_GATE` | 0 / 1 | **0** |
| 开户礼 · VIP 天数 | `NEWUSER_GIFT_VIPDAYS` | int | 30 |
| 开户礼 · 算力 | `NEWUSER_GIFT_COMPUTE` | int | 100 |
| 提示 · VIP 到期前(天) | `VIP_WARN_DAYS` | int | 7 |
| 提示 · 算力低于 | `COMPUTE_WARN` | int | 100 |
> ⚠️ **后四项与换算系数的校验规则不同:允许配 0**(0 = 不送 / 不提示,是合法表达)。
> 所以解析时只有「键不存在」或「解析不出数字」才回落默认值,解析出 0 就用 0;
> 前端读取也必须用 `??` 而不是 `||`,否则后台配的 0 会被当成「没配」显示成默认值。
- 存 `config` 表(`comm.TableAppConfig`),按应用作用域,随 `user_getappconfig` 的 env 下发——客户端可读到系数,**仅用于本地预估展示,不用于上报**
- 改系数后触发现有的 `Rpc_ModifyAppConifg` 事件刷新缓存
- 闸门开关旁必须有提示:**「开启前需确认预扣逻辑已上线」**(见 1.4)
### 6.2 MAC 生成管理 → 生成 MAC(改表单)
| 字段 | 变更 |
|---|---|
| VIP 天数 | 保留(列 `rewardvip`) |
| VIP 等级 | **新增**,下拉:1 = VIP / 2 = VIP-Pro,默认 1 |
| 赠送算力 | **新增**(列 `compute_grant`),0 = 不送 |
| 翻译分钟 / 会议分钟 | **去掉** |
批次列表页同步展示这三项。
### 6.3 产品管理(改表单)
- 产品档案上的旧奖励字段(`bindrewardvpitime` / `bindrewardtranslate` / `bindrewardmeeting`)改为:VIP 天数、VIP 等级、赠送算力,作为批次未配置时的回退
- 二期:加 `has_storage` / `need_erase` 两个开关
### 6.4 用户管理(改详情页)
| 项 | 变更 |
|---|---|
| VIP 到期时间 | 保留,标注「自购部分」 |
| 设备赋予的 VIP | **新增只读展示**:来自哪台设备、到期日 |
| 算力余额 | **新增可调**:`compute_balance`,支持加减,必填备注 |
| 设备算力 | **新增只读展示**:各绑定设备的 已用 / 总量 |
| 旧三个额度 | 保留只读,标注「已停用」 |
| 消耗流水 | **新增**:按时间倒序,列 时间 / 功能 / 原始量 / 算力 / 来源拆分 |
后台调整算力走 `api_adjustusercompute`,写一条 `logtype = AdminAdjust` 的流水。
### 6.5 设备管理(改列表 / 详情)
| 列 | 说明 |
|---|---|
| 激活日 | `usedtime` |
| VIP 到期 | `usedtime + vipdays`,未激活显示「未激活」 |
| 算力 已用 / 总量 | `compute_used / compute_grant` |
| 当前绑定者 | `uid` |
| 二期:清空状态 | `erasestate` |
| 二期:强制解绑 | 按钮,限超管,写审计日志 |
### 6.6 商品管理(改表单)
| 字段 | 变更 |
|---|---|
| VIP 天数 | 保留(`viptime`) |
| 算力 | **新增**(`compute`) |
| AI 次数 / 会议分钟 / 翻译分钟 | **去掉**(`ainum` / `meetnum` / `tradenum` 列保留不读) |
### 6.7 统计看板(新页面)
| 维度 | 指标 |
|---|---|
| 按日 | 算力消耗总量、活跃消耗用户数 |
| 按功能 | 翻译 / 会议 / AI 各自的原始量与算力 |
| 按渠道 / 产品 | 算力消耗(沿用现有 `StatEvent` 的渠道归属) |
| 按用户 Top N | 单日 / 单月消耗排行——**异常用量的发现入口** |
| 设备算力 | 已发放总量、已消耗、剩余 |
---
## 7. 服务端接口清单
### 7.1 App 侧(`modules/user/`,反射注册,路由 `user_<方法小写>`)
| 接口 | 变更 | 请求 | 响应 |
|---|---|---|---|
| `user_usages` | **改** | 原样:`usagetype` + `usages` 原始量;AI 分支补 `upchars` / `downchars` | 增加 `compute`(本次扣减)、`compute_device_left`、`compute_user_left`、`over`(超额,闸门关时才可能 > 0) |
| `user_getcompute` | **新增** | 无 | `vip{ level, exptime, source[] }`、`compute{ device_left, user_balance, total_used, month_used }`、`rates{}` |
| `user_binddevice` | **改** | 原样 | 不再往 `vipexptime` 累加;改为在设备行写 `vipdays` / `viplevel` / `compute_grant` 快照;响应带设备算力剩余 |
| `user_translate` | **改** | 原样 | 入口加 VIP 校验;成功后走 4.2 扣减流程 |
| `user_getinfo` 等返回 VIP 的接口 | **改** | — | VIP 状态改为「自购 或 设备赋予」合并判定 |
| `user_getappconfig` | **改** | — | env 里带出 `COMPUTE_RATE_*` / `COMPUTE_GATE` |
| `resolveThirdSvcs` | **改** | — | 按 VIP 状态决定是否下发翻译类凭据 |
| `echomeet` starttask | **改** | — | 入口加 VIP 校验;任务开始时按会议秒数走 4.2 扣减流程 |
| 二期 `user_binddevice` | 改 | — | 响应加 `erasestate`;带存储被占用时返回明确错误码 |
| 二期 `user_unbinddevice` | 改 | 加 `erased` | — |
| 二期 `user_reportdeviceerased` | 新增 | `devicemac` | — |
**VIP 判定统一封装一个函数**(如 `comm.UserHasVip(user, devices) (level, exptime, source)`),
所有需要判 VIP 的入口都调它,别各写一份。
### 7.2 Console 侧(`modules/console/` 或 `modules/api/`)
| 接口 | 变更 |
|---|---|
| `api_getcomputeconfig` / `api_savecomputeconfig` | **新增**,读写 6.1 四个键 |
| 批次新增 / 编辑 | 加 `viplevel` / `compute_grant` |
| 产品保存 | 奖励字段改为三项 |
| `api_adjustusercompute` | **新增**:`uid`、`delta`(可负)、`remark`,写 AdminAdjust 流水 |
| `api_getusercomputelogs` | **新增**:分页返回消耗流水 |
| 用户详情 | 加设备 VIP / 设备算力只读展示 |
| 设备列表 / 详情 | 加激活日、VIP 到期、算力字段 |
| 商品保存 | 加 `compute` |
| `api_getcomputestats` | **新增**:按日 / 功能 / 渠道 / 用户 Top N |
| 二期 `api_forceunbind` | 新增,限超管,写审计 |
### 7.3 支付发货(`modules/pay/`)
`deliverGrant` 里按商品的 `compute` 累加 `user.compute_balance`,写 `logtype = Pay` 流水;
原来发 `Ainum / Meetnum / Tradenum` 的三段**删除**。
---
## 8. 数据结构变更
### 8.1 设备表 `device_mac`(`pb.DBAuthCode`)
| 列 | 类型 | 阶段 | 说明 |
|---|---|---|---|
| `vipdays` | int32 | 一期 | 激活时从批次快照,0 = 不送 |
| `viplevel` | int32 | 一期 | 1 = VIP,2 = VIP-Pro |
| `compute_grant` | int64 | 一期 | 赠送算力总量(快照) |
| `compute_used` | int64 | 一期 | 已消耗,**解绑不重置** |
| `erasestate` | int32 | 二期 | 0 / 1 |
| `lastuid` | string(50) | 二期 | 最后绑定者 |
复用:`usedtime`(激活日)、`uid`、`status`、`disabled`。
### 8.2 生产批次表 `production_batch`
| 列 | 变更 |
|---|---|
| `rewardvip` | 复用为 VIP 天数 |
| `viplevel` | **新增**,默认 1 |
| `compute_grant` | **新增**,默认 0 |
| `rewardtranslate` / `rewardmeeting` | 不再读写 |
### 8.3 用户表 `user`
| 列 | 变更 |
|---|---|
| `vipexptime` | 语义收窄为**只记自购** |
| `viplv` | 保持 |
| `compute_balance` | **新增** int64,默认 0 |
| `tradeintegral` / `meetintegral` / `aichatintegral` | 停用,保留不清(见 Q3) |
### 8.4 用量流水 `user_use_log`
| 列 | 变更 |
|---|---|
| `compute` | **新增** int64,本次算力(消耗为负,发放为正) |
| `compute_device` | **新增** int64,从设备扣的部分 |
| `compute_user` | **新增** int64,从用户余额扣的部分 |
| `compute_over` | **新增** int64,超额部分(闸门关时) |
| `devicemac` | **新增** string,扣了哪台设备 |
| `logtype` | 枚举加 `AdminAdjust` |
| 现有 `addvipday` / `addtradesecond` / `addmeetsecond` / `addagentintegral` | 保留:原始量照记 |
### 8.5 统计表 `user_statistics`
| 列 | 变更 |
|---|---|
| `compute_total` | **新增** int64,累计消耗算力 |
| `aichatuptoken` / `aichatdowntoken` | 补写入(存字符数,注释写死口径) |
| 其余 | 不变 |
### 8.6 商品表 `goods`
`compute` **新增** int64。`ainum` / `meetnum` / `tradenum` 保留不读。
### 8.7 产品表 `product`
一期:奖励三字段语义改为 VIP 天数 / 等级 / 算力(可复用 `bindrewardvpitime`,新增 `bindviplevel` / `bindcompute`)。
二期:`has_storage` / `need_erase`。
### 8.8 配置表 `config`(KV)
四个键见 6.1。
> ⚠️ `pb/*.pb.go` 由 protoc 生成,**不能手改**。改 `apps/proto/` 下 `.proto` 后
> `cd apps/proto && python3 pb.py`,生成器须是 `protoc-gen-go v1.36.6`,
> 之后 `git diff --stat -- apps/services/pb/` 确认只动了该动的文件。
> 客户端 `appconfig_model.dart` 是手写镜像,新增下发字段后要同步并重跑 `build_runner`。
---
## 9. 边界场景
| 场景 | 系统行为 |
|---|---|
| 出厂后长期未激活 | 不计时。激活后仍是完整 `vipdays` 与 `compute_grant` |
| A 解绑后 B 绑定 | B 继承剩余 VIP 天数与剩余算力,不重新开始 |
| A 未解绑,B 直接绑定 | 一期允许接管;A 立即失去设备 VIP 与设备算力,需提示 |
| 同账号换手机 | 允许,不算接管 |
| 同一用户绑多台设备 | VIP 取期最晚、等级取最高;算力求和,扣减时先扣剩余最少的那台 |
| 设备算力用完,VIP 还在 | 闸门关:照常用,记超额;闸门开:扣用户余额,也没了则拒绝 |
| VIP 过期,算力还有 | 翻译 / 会议不可用;AI 对话可用并扣算力 |
| VIP 在使用过程中过期 | **当前会话允许跑完**,下次开启才拦 |
| VIP 过期后查看历史 | 照常可看 |
| 改换算系数 | 只影响之后的消耗,历史流水里的算力值不重算 |
| 闸门从关切到开 | 已有超额记录不追溯;开启前必须先上线预扣 |
| 后台调整用户算力为负 | 拒绝,余额不能低于 0 |
| 账号注销 | 视同解绑;自购 VIP 与用户算力随账号消失 |
| 设备被后台禁用 / 退货 | 立即不再赋予 VIP 与算力 |
| 时区 | 全部按服务端秒级时间戳 |
---
## 10. 与线上现状的差异
### 一期
| 维度 | 现在(线上) | 目标 |
|---|---|---|
| 功能准入 | 三个额度桶各自拦 | **只看 VIP** |
| 用量计量 | 翻译秒 / 会议秒 / AI 次数三桶 | 原始量照记,**统一折算算力** |
| 换算系数 | 无 | **后台可配** |
| 生产批次配置 | VIP 天 + 翻译分钟 + 会议分钟 | VIP 天 + **等级** + **算力** |
| VIP 发放 | 绑定时累加天数到用户表 | 设备行存激活日 + 天数,不碰用户表 |
| 发放判据 | 只有第一个绑定者能拿 | 随绑定流转,剩余部分给下一个人 |
| 设备算力记账 | 无 | 设备行 `compute_grant / compute_used` |
| 用户算力 | 无 | `compute_balance`,可充值可后台调 |
| 消耗时行为 | 扣桶,不足则拒绝 | 换算 → 先设备后用户扣减 → 闸门关时放行 |
| 商品发放 | AI 次 / 会议分 / 翻译分 / VIP 天 | **算力 + VIP 天** |
| AI 用量 | 只记次数 | 补记输入 / 输出字数 |
| 用量看板 | 无 | 新增 |
### 二期
接管禁止、清空标签、产品存储开关、固件清空指令、后台强制解绑——见第 5 节。
### 存量迁移
1. **VIP 天数不动**:存量 `vipexptime` 视同自购
2. **旧三桶余额折算成算力**写入 `compute_balance`:翻译秒 ÷ 60 × 系数 + 会议秒 ÷ 60 × 系数 + AI 次 × 1,向上取整。旧字段保留不清
3. **存量设备补快照**:`vipdays` 按批次 `rewardvip` 回填;`compute_grant` 按批次新值回填(批次没配就是 0);`compute_used = 0`
4. **存量用户已领过的一次性绑定礼不追溯**
---
## 11. 待决策
已拍板不再讨论:**VIP 是功能准入闸门**、**VIP 过期后翻译与会议纪要受限**、**用量统一折算算力**、**设备带赠送算力上限**、**算力闸门默认关**。
| # | 问题 | 建议 |
|---|---|---|
| Q1 | 「禁止接管」挂 `has_storage` 还是 `need_erase`? | **挂 `need_erase`** |
| Q2 | 设备赠送算力要不要设有效期(如与 VIP 期同步)? | **不设**,用完为止。「上限」语义就是总量,加有效期多一套结算 |
| Q3 | 旧三桶余额:折算进 `compute_balance` 还是直接放弃? | **折算**,用户已购买的额度不能凭空消失 |
| ~~Q4~~ | ~~三个换算系数的初始值~~ | ✅ 已落地:后台可配,默认全 1 |
| ~~Q5~~ | ~~算力包商品的定价与档位~~ | ✅ 已落地:`UsageType.Compute=5`,后台可建算力包商品,客户端会员页展示并可购买。**具体档位与定价由运营在后台建商品** |
| ~~Q6~~ | ~~开户礼送多少~~ | ✅ 已落地:后台可配(VIP 天数 + 算力),默认 30 天 / 100 算力,**都可配 0 = 不送** |
| ~~Q7~~ | ~~提前提示阈值~~ | ✅ 已落地:后台可配(到期前 N 天、算力低于 N),默认 7 天 / 100,**配 0 = 不提示**;随 `user_getcompute` 下发,客户端据此显示提示条 |
| Q8 | 设备端物理重置要不要做? | 二期与固件一起评估 |
| Q9 | 后台强制解绑的权限与审计? | 限超管,留日志 |
---
## 附:口径演进
| 轮次 | 变更 |
|---|---|
| v1 | 权益归属设备、按期发放、期末清零、灵活可配 |
| v2 | 确认「优先扣设备额度」「激活日起算」;新增共享绑定、解绑隐私确权 |
| v3 | 统一 token 计量;带存储禁止接管;清空标签;连续计时 |
| v4 | VIP 成为唯一闸门;生产配置只留 VIP 天数;token 退回纯统计;绑定规则拆两期 |
| v4 补充 | 取消额度闸门;VIP 过期受限范围 = 翻译与会议纪要;功能权限清单与拦截点 |
| **v5(本版)** | **用量统一折算「算力」**,系数后台可配;**设备带赠送算力上限**;用户可充值算力;**算力闸门做成开关默认关**;补齐后台功能与接口清单到字段级 |
Loading…
Cancel
Save