Browse Source
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
52 changed files with 4847 additions and 778 deletions
@ -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> |
|||
Binary file not shown.
@ -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 |
|||
} |
|||
@ -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) |
|||
} |
|||
}) |
|||
} |
|||
Binary file not shown.
Binary file not shown.
@ -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, |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
File diff suppressed because it is too large
Binary file not shown.
@ -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`。 |
|||
@ -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…
Reference in new issue