@@ -194,17 +238,25 @@
清空 = 不设到期(等于没有会员)
-
-
-
+
+
+
@@ -325,8 +377,14 @@ const canEdit = computed(() => auth.isAdmin)
const giftAllowed = computed(() => [1, 2, 3].includes(auth.identity))
const saving = ref(false)
-const edit = reactive({ name: '', state: 0, viplv: 0, vipexpDate: '', trademin: 0, meetmin: 0, aichat: 0 })
-const editBase = reactive({ name: '', state: 0, viplv: 0, vipexpDate: '', trademin: 0, meetmin: 0, aichat: 0 })
+const edit = reactive({ name: '', state: 0, viplv: 0, vipexpDate: '' })
+const editBase = reactive({ name: '', state: 0, viplv: 0, vipexpDate: '' })
+// 算力调整(增量 + 备注),与「设置参数」分开提交
+const adj = reactive({ delta: 0, remark: '' })
+const adjusting = ref(false)
+// 算力流水(详情打开时拉最近一页)
+const logs = ref
[]>([])
+const logsTotal = ref(0)
const giftModal = ref(null)
const gift = reactive({ vipday: 0, trademin: 0, meetmin: 0 })
@@ -412,11 +470,16 @@ const sections: Section[] = [
['lastbindproductid', '最后绑定产品ID'], ['lastbindchannelid', '最后绑定渠道商'],
['isactivatecode', '已领公码', 'bool'], ['isgiveaway', '已领过赠送', 'bool'],
] },
- { title: '会员与积分', rows: [
- ['viplv', 'VIP等级'], ['vipexptime', 'VIP到期', 'ts'],
- ['aichatintegral', 'AI积分(次)'], ['aichattotalintegral', 'AI累计'],
- ['tradeintegral', '翻译积分(秒)'], ['tradetotalintegral', '翻译累计(秒)'],
- ['meetintegral', '会议积分(秒)'], ['meettotalintegral', '会议累计(秒)'],
+ { title: '会员(有效 = 自购 或 设备赋予)', rows: [
+ ['vip_effective_level', '有效VIP等级'], ['vip_effective_exptime', '有效VIP到期', 'ts'],
+ ['vip_effective_source', '当前来源'],
+ ['viplv', '自购VIP等级'], ['vipexptime', '自购VIP到期', 'ts'],
+ ] },
+ { title: '算力', rows: [
+ ['computebalance', '账户余额'], ['compute_deviceleft', '设备剩余合计'],
+ ] },
+ { title: '已停用的旧额度(2026-09 起不再作为闸门,存量已折算进算力)', rows: [
+ ['aichatintegral', 'AI积分(次)'], ['tradeintegral', '翻译积分(秒)'], ['meetintegral', '会议积分(秒)'],
] },
{ title: '实名认证', rows: [
['idverified', '已实名', 'bool'], ['idverifiedtime', '实名时间', 'ts'],
@@ -504,8 +567,14 @@ async function openDetail(row: UserRow) {
auth.setCurrentApp(Number(lastAppId.value))
const d = await webApi('getuserinfo', { uid: lastUid.value })
if (d) {
+ // 后端把有效 VIP 放在嵌套的 vip_effective 里;详情表按扁平 key 取值,这里拍平
+ const ve = (d.vip_effective || {}) as Record
+ d.vip_effective_level = Number(ve.level || 0)
+ d.vip_effective_exptime = Number(ve.exptime || 0)
+ d.vip_effective_source = ve.level ? (ve.fromdevice ? '设备赋予' : '自购') : '无'
user.value = d
fillEdit(d)
+ loadLogs()
}
} catch (e: any) {
// 详情拉不到不清空:列表那一行的数据仍然是对的,够看大部分字段
@@ -519,9 +588,6 @@ function fillEdit(u: UserRow) {
state: Number(u.State || 0),
viplv: Number(u.viplv || 0),
vipexpDate: unixToLocalInput(u.vipexptime),
- trademin: Math.floor((Number(u.tradeintegral) || 0) / 60),
- meetmin: Math.floor((Number(u.meetintegral) || 0) / 60),
- aichat: Number(u.aichatintegral || 0),
}
Object.assign(edit, snap)
Object.assign(editBase, snap)
@@ -534,6 +600,67 @@ const dirtyCount = computed(() =>
(Object.keys(editBase) as (keyof typeof editBase)[]).filter((k) => edit[k] !== editBase[k]).length,
)
+function vipBadgeText(u: UserRow) {
+ const lv = Number(u.vip_effective_level ?? u.viplv ?? 0)
+ if (!lv) return '无 VIP'
+ return `${lv >= 2 ? 'VIP-Pro' : 'VIP'}${u.vip_effective_source === '设备赋予' ? '(设备)' : ''}`
+}
+function vipBadgeTitle(u: UserRow) {
+ const exp = Number(u.vip_effective_exptime || 0)
+ return exp ? `有效到 ${fmtTs(exp)}` : ''
+}
+
+async function submitAdjust() {
+ if (!lastUid.value || !adj.delta || !adj.remark.trim()) return
+ const delta = Number(adj.delta)
+ if (!confirm(`确定给该用户${delta > 0 ? '增加' : '扣减'} ${Math.abs(delta)} 算力?`)) return
+ adjusting.value = true
+ try {
+ auth.setCurrentApp(Number(lastAppId.value))
+ await webApi('adjustusercompute', { uid: lastUid.value, delta, remark: adj.remark.trim() })
+ success('已调整')
+ adj.delta = 0
+ adj.remark = ''
+ await refreshCurrent()
+ } catch (e: any) {
+ error(e?.message || '调整失败')
+ } finally {
+ adjusting.value = false
+ }
+}
+
+async function loadLogs() {
+ if (!lastUid.value) return
+ try {
+ auth.setCurrentApp(Number(lastAppId.value))
+ const d = await webApi<{ logs: Record[]; total: number }>('getusercomputelogs', { uid: lastUid.value, page: 1, page_size: 30 })
+ logs.value = d?.logs || []
+ logsTotal.value = Number(d?.total || 0)
+ } catch {
+ logs.value = []
+ }
+}
+const LOGTYPE: Record = { 1: '购买', 2: '活动/激活', 3: '后台赠送', 4: '消耗', 5: '后台调整' }
+function logKind(l: Record) {
+ if (Number(l.addtradesecond) < 0) return '翻译'
+ if (Number(l.addmeetsecond) < 0) return '会议纪要'
+ if (Number(l.addagentintegral) < 0) return 'AI 对话'
+ return LOGTYPE[Number(l.logtype)] || String(l.logtype)
+}
+function logRaw(l: Record) {
+ if (Number(l.addtradesecond) < 0) return `${Math.round(-Number(l.addtradesecond) / 60)} 分钟`
+ if (Number(l.addmeetsecond) < 0) return `${Math.round(-Number(l.addmeetsecond) / 60)} 分钟`
+ if (Number(l.addvipday) > 0) return `VIP +${l.addvipday} 天`
+ return ''
+}
+function logSource(l: Record) {
+ const parts: string[] = []
+ if (Number(l.computedevice) > 0) parts.push(`设备 ${l.computedevice}`)
+ if (Number(l.computeuser) > 0) parts.push(`账户 ${l.computeuser}`)
+ if (Number(l.computeover) > 0) parts.push(`超额 ${l.computeover}`)
+ return parts.join(' · ')
+}
+
async function saveEdit() {
if (!lastUid.value || !dirtyCount.value) return
const payload: Record = { uid: lastUid.value }
@@ -541,10 +668,6 @@ async function saveEdit() {
if (edit.state !== editBase.state) payload.state = Number(edit.state)
if (edit.viplv !== editBase.viplv) payload.viplv = Number(edit.viplv)
if (edit.vipexpDate !== editBase.vipexpDate) payload.vipexptime = localInputToUnix(edit.vipexpDate)
- // 分钟 → 秒:库里存的是秒,表单填的是分钟
- if (edit.trademin !== editBase.trademin) payload.tradeintegral = Math.max(0, Number(edit.trademin) || 0) * 60
- if (edit.meetmin !== editBase.meetmin) payload.meetintegral = Math.max(0, Number(edit.meetmin) || 0) * 60
- if (edit.aichat !== editBase.aichat) payload.aichatintegral = Math.max(0, Number(edit.aichat) || 0)
saving.value = true
try {
auth.setCurrentApp(Number(lastAppId.value))
@@ -564,8 +687,14 @@ async function refreshCurrent() {
auth.setCurrentApp(Number(lastAppId.value))
const d = await webApi('getuserinfo', { uid: lastUid.value })
if (d) {
+ // 后端把有效 VIP 放在嵌套的 vip_effective 里;详情表按扁平 key 取值,这里拍平
+ const ve = (d.vip_effective || {}) as Record
+ d.vip_effective_level = Number(ve.level || 0)
+ d.vip_effective_exptime = Number(ve.exptime || 0)
+ d.vip_effective_source = ve.level ? (ve.fromdevice ? '设备赋予' : '自购') : '无'
user.value = d
fillEdit(d)
+ loadLogs()
}
} catch { /* 详情刷新失败不影响已保存的结果 */ }
loadUsers()
diff --git a/apps/admin/app/utils/menus.ts b/apps/admin/app/utils/menus.ts
index a9135c00..15f4443c 100644
--- a/apps/admin/app/utils/menus.ts
+++ b/apps/admin/app/utils/menus.ts
@@ -66,6 +66,7 @@ export const MENU_GROUPS: MenuGroup[] = [
{ id: 'globalconfig', label: '全局环境配置', to: '/globalconfig', icon: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9' },
{ id: 'appconfig', label: '应用环境配置', to: '/appconfig', icon: 'M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4' },
{ id: 'appgoods', label: '应用商品配置', to: '/appgoods', icon: 'M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z' },
+ { id: 'compute', label: '会员与算力', to: '/compute', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
{ id: 'meettemplate', label: '会议模板', to: '/meettemplates', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
// 版本控制 / 游客显隐控制:按应用配、不分渠道(存 app_release 表)。
// 这两组开关原先埋在「SaaS 管理 → 渠道分发」里按渠道各存一份,同一个开关有 8 处能改,
diff --git a/apps/proto/.DS_Store b/apps/proto/.DS_Store
index 0fb2860d..c08baa0a 100644
Binary files a/apps/proto/.DS_Store and b/apps/proto/.DS_Store differ
diff --git a/apps/proto/api/api_msg.proto b/apps/proto/api/api_msg.proto
index 4ee01eec..6eea06d5 100644
--- a/apps/proto/api/api_msg.proto
+++ b/apps/proto/api/api_msg.proto
@@ -480,7 +480,9 @@ message ApiCreateAuthCodesReq {
int32 sharerate = 11; //@go_tags(`json:"sharerate"`) 本批次渠道分成比例(万分比 0~10000),生产时冻结进批次台账
uint32 rewardvip = 12; //@go_tags(`json:"rewardvip"`) 本批次绑定赠送VIP天数(0=不送)
uint32 rewardtranslate = 13; //@go_tags(`json:"rewardtranslate"`) 本批次绑定赠送翻译时长(分钟,0=不送)
- uint32 rewardmeeting = 14; //@go_tags(`json:"rewardmeeting"`) 本批次绑定赠送会议时长(分钟,0=不送)
+ uint32 rewardmeeting = 14; //@go_tags(`json:"rewardmeeting"`) 【已停用】本批次绑定赠送会议时长(分钟),2026-09 起不再读写
+ int32 viplevel = 15; //@go_tags(`json:"viplevel"`) 本批次绑定赠送VIP等级(1=VIP 2=VIP-Pro,默认1)
+ int64 computegrant = 16; //@go_tags(`json:"computegrant"`) 本批次绑定赠送算力(0=不送)
}
message ApiCreateAuthCodesResp {
diff --git a/apps/proto/db.proto b/apps/proto/db.proto
index 80ab8860..967a8a17 100644
--- a/apps/proto/db.proto
+++ b/apps/proto/db.proto
@@ -62,6 +62,10 @@ message DBUser {
string lastbindchannelid = 50; //@go_tags(`gorm:"lastbindchannelid;size:16;index" json:"lastbindchannelid"`) 最后绑定设备的渠道商ID(充值/消耗按此渠道商归属统计与结算)
bool idverified = 51; //@go_tags(`gorm:"idverified;index" json:"idverified"`) 是否已通过身份证实名校验(明细见 useridverify 表)
int64 idverifiedtime = 52; //@go_tags(`gorm:"idverifiedtime" json:"idverifiedtime"`) 实名校验通过时间(0=未通过)
+ // 用户算力余额(充值/后台调整获得,归属账号,不过期)。
+ // 三个旧额度桶(aichatintegral/tradeintegral/meetintegral)自 2026-09 起停用、不再作为闸门,
+ // 存量值已按换算系数折进本字段(见 docs/会员权益与用量统计-设计与开发文档.md §10 存量迁移)。
+ int64 computebalance = 53; //@go_tags(`gorm:"computebalance" json:"computebalance"`) 算力余额(账号自有,不过期)
}
//用户实名认证记录(表 useridverify)。一个用户一行。
@@ -294,16 +298,35 @@ message DBProductionBatch{
uint32 rewardvip = 15; //@go_tags(`gorm:"rewardvip" json:"rewardvip"`) 绑定赠送VIP天数(0=不送)
uint32 rewardtranslate = 16; //@go_tags(`gorm:"rewardtranslate" json:"rewardtranslate"`) 绑定赠送翻译时长(分钟,0=不送)
uint32 rewardmeeting = 17; //@go_tags(`gorm:"rewardmeeting" json:"rewardmeeting"`) 绑定赠送会议时长(分钟,0=不送)
+ // 2026-09 起绑定赠送只剩「VIP 天数 + VIP 等级 + 算力」三项:rewardvip 复用为天数,
+ // rewardtranslate/rewardmeeting **不再读写**(列保留,避免存量批次表结构变动)。
+ int32 viplevel = 18; //@go_tags(`gorm:"viplevel" json:"viplevel"`) 绑定赠送VIP等级(1=VIP 2=VIP-Pro,默认1)
+ int64 computegrant = 19; //@go_tags(`gorm:"computegrant" json:"computegrant"`) 绑定赠送算力(0=不送)
}
//工厂产品
message DBProduct{
uint32 id = 1; //@go_tags(`gorm:"id;primaryKey;autoIncrement" json:"id"`) 设备编码
uint32 brandid = 2; //@go_tags(`gorm:"brandid;index" json:"brandid"`) 品牌商id
- DeviceType devicetype = 3; //@go_tags(`gorm:"devicetype" json:"devicetype"`) 设备类型
+ //【已废弃 2026-09-08】产品上的「设备类型」已删除:服务端没有任何逻辑读它,
+ //后台表单也已去掉这一项。枚举把「连接方式」(经典/BLE单双端)和「产品形态」(录音笔/眼镜/卡片)
+ //混在一根轴上,客户端早已另建 DeviceCategories 做品类,这里只剩误配来源。
+ //⚠️ 字段保留为**纯下发兼容字段**,理由同下面的 factoryid:已发布的 App 包按「非空 int」解析
+ //devicetype,字段一旦不下发,整个 user_getappconfig 响应就抛 TypeError(产品表、设备列表全废)。
+ //gorm:"-" 不建列、不参与读写,恒为 0 下发。老包淘汰干净后可整条删除。
+ //⚠️ 字段号 3 不要复用。
+ DeviceType devicetype = 3; //@go_tags(`gorm:"-" json:"devicetype"`) [兼容]已废弃,恒为0
string devicename = 4; //@go_tags(`gorm:"devicename" json:"devicename"`) 设备名称
string productimage = 5; //@go_tags(`gorm:"productimage" json:"productimage"`) 产品图片
- string scanuuid = 6; //@go_tags(`gorm:"scanuuid" json:"scanuuid"`) 设备扫描唯一id
+ //【已废弃 2026-09-08】设备扫描UUID 已删除:设备校验早就换成按 MAC 走(device_mac 全局表,
+ //见 comm.TableDeviceMac),绑定奖励也是按 MAC 判「这台设备有没有被绑过」(codestate),
+ //跟这个 UUID 没有任何关系。后台产品表单里那一栏也已去掉。
+ //⚠️ 字段保留为**纯下发兼容字段**,理由同 devicetype / factoryid:已发布的 App 包按
+ //「非空 String」解析 scanuuid(`json['scanuuid'] as String`),字段一旦不下发,
+ //整个 user_getappconfig 响应就抛 TypeError(产品表、设备列表全废)。
+ //gorm:"-" 不建列、不参与读写,恒为空串下发。老包淘汰干净后可整条删除。
+ //⚠️ 字段号 6 不要复用。
+ string scanuuid = 6; //@go_tags(`gorm:"-" json:"scanuuid"`) [兼容]已废弃,恒为空
string version = 7; //@go_tags(`gorm:"version" json:"version"`) 版本
string updatedescription = 9; //@go_tags(`gorm:"updatedescription" json:"updatedescription"`) 描述
string updatepackageaddress = 10; //@go_tags(`gorm:"updatepackageaddress" json:"updatepackageaddress"`) 更新包地址=url
@@ -359,6 +382,10 @@ message DBProduct{
//也没法把耳机刷回旧固件反复验证 OTA 流程。
//⚠️ 正式产品别开:一次配置失误(比如后台挂了个旧版固件)就会把全量用户降级。
bool updateonmismatch = 40; //@go_tags(`gorm:"updateonmismatch" json:"updateonmismatch"`) 版本不一致即可升级(开发用,默认关)
+ // 批次未配置赠送时的回退(与 production_batch 同语义)。bindrewardvpitime 复用为 VIP 天数,
+ // bindrewardtranslate/bindrewardmeeting 不再读写。
+ int32 bindviplevel = 60; //@go_tags(`gorm:"bindviplevel" json:"bindviplevel"`) 绑定赠送VIP等级回退值(默认1)
+ int64 bindcompute = 61; //@go_tags(`gorm:"bindcompute" json:"bindcompute"`) 绑定赠送算力回退值(0=不送)
}
@@ -404,6 +431,15 @@ message DBAuthCode {
string devicemac = 12; //@go_tags(`gorm:"devicemac;index:idx_product_mac" json:"devicemac"`) 地址
int32 disabled = 13; //@go_tags(`gorm:"disabled;index" json:"disabled"`) 封禁状态 0:正常 -1:禁用
string channelid = 14; //@go_tags(`gorm:"channelid;size:16;index" json:"channelid"`) 渠道商id(短码,生产时随批次落库;空=未分配渠道)
+ // ── 设备权益快照(首次绑定=激活时从生产批次固化,此后改批次不影响已激活设备)──
+ // 设备 VIP 期 = [usedtime, usedtime + vipdays*86400),按日历天连续走、解绑不暂停;
+ // 判定时按「当前绑定着且未到期」算,**不写用户表**,解绑无需回收。
+ int32 vipdays = 15; //@go_tags(`gorm:"vipdays" json:"vipdays"`) 设备赠送VIP天数(激活时快照,0=不送)
+ int32 viplevel = 16; //@go_tags(`gorm:"viplevel" json:"viplevel"`) 设备赠送VIP等级(1=VIP 2=VIP-Pro,激活时快照)
+ // 设备赠送算力:总量快照 + 已消耗。剩余 = computegrant - computeused。
+ // computeused 解绑**不重置**:设备算力是共用一份、随设备流转,重置等于换个账号就能无限刷。
+ int64 computegrant = 17; //@go_tags(`gorm:"computegrant" json:"computegrant"`) 设备赠送算力总量(激活时快照,0=不送)
+ int64 computeused = 18; //@go_tags(`gorm:"computeused" json:"computeused"`) 设备算力已消耗(解绑不重置)
}
@@ -444,6 +480,7 @@ message DBGoods{
int64 meetnum = 9; //@go_tags(`gorm:"meetnum" json:"meetnum"`) 会议使用量资源点
int64 tradenum = 10; //@go_tags(`gorm:"tradenum" json:"tradenum"`) 翻译资源点
int32 viptime = 11; //@go_tags(`gorm:"viptime" json:"viptime"`) 会员时间
+ int64 compute = 12; //@go_tags(`gorm:"compute" json:"compute"`) 发放算力(2026-09 起 ainum/meetnum/tradenum 不再发放)
}
//语音包管理
@@ -467,6 +504,12 @@ message DBUserUseLog {
int64 addtradesecond = 7; //@go_tags(`gorm:"addtradesecond" json:"addtradesecond"`) 翻译资源点使用时常
int64 addmeetsecond = 8; //@go_tags(`gorm:"addmeetsecond" json:"addmeetsecond"`) 会议使用时常
string extra = 9; //@go_tags(`gorm:"extra" json:"extra"`) 附加数据
+ // ── 算力记账(2026-09 起)。消耗为负、发放/调整为正;来源拆分三列之和 = compute 的绝对值 ──
+ int64 compute = 10; //@go_tags(`gorm:"compute" json:"compute"`) 本次算力变动(消耗为负)
+ int64 computedevice = 11; //@go_tags(`gorm:"computedevice" json:"computedevice"`) 从设备赠送额度扣的部分
+ int64 computeuser = 12; //@go_tags(`gorm:"computeuser" json:"computeuser"`) 从用户余额扣的部分
+ int64 computeover = 13; //@go_tags(`gorm:"computeover" json:"computeover"`) 超额部分(闸门关时余额不足仍放行的量)
+ string devicemac = 14; //@go_tags(`gorm:"devicemac;size:50" json:"devicemac"`) 扣了哪台设备(为空=未动设备额度)
}
//用户统计数据
@@ -487,6 +530,7 @@ message DBUserStatistics{
int64 trademodel4time = 14; //@go_tags(`gorm:"trademodel4time" json:"trademodel4time"`) 翻译模式4时常
int32 trademodel5num = 15; //@go_tags(`gorm:"trademodel5num" json:"trademodel5num"`) 翻译模式5次数
int32 tradewordcount = 16; //@go_tags(`gorm:"tradewordcount" json:"tradewordcount"`) 翻译文本长度
+ int64 computetotal = 17; //@go_tags(`gorm:"computetotal" json:"computetotal"`) 累计消耗算力
}
// App综合统计(日/月/年三张表共用同一结构)
diff --git a/apps/proto/enum.proto b/apps/proto/enum.proto
index 863e6c36..798f3c5f 100644
--- a/apps/proto/enum.proto
+++ b/apps/proto/enum.proto
@@ -99,6 +99,10 @@ enum UsageType {
// @Description 购买VIP时长
// @example
VIP = 4; //购买VIP时长
+ // 购买算力
+ // @Description 购买算力包(2026-09 起;商品的 compute 字段即本次发放量,归账号、不过期)
+ // @example
+ Compute = 5; //购买算力
}
@@ -124,6 +128,10 @@ enum UserLogType {
// @Description 用户消耗
// @example
UserConsume = 4; //用户消耗
+ // 后台调整算力
+ // @Description 后台调整用户算力余额(加减)
+ // @example
+ AdminAdjust = 5; //后台调整算力
}
//MCP服务类型
diff --git a/apps/proto/errorcode.proto b/apps/proto/errorcode.proto
index e397b621..aece7d07 100644
--- a/apps/proto/errorcode.proto
+++ b/apps/proto/errorcode.proto
@@ -107,4 +107,9 @@ enum ErrorCode {
TranslateNotConfigured = 5101; //未配置机器翻译服务(后台第三方服务配置里没有启用的 MT 服务)
TranslateParamInvalid = 5102; //翻译参数不合法(原文为空 / 目标语言为空)
TranslateProviderError = 5103; //翻译服务商调用失败
+
+ //会员与算力相关错误码 5301-5303
+ ComputeNotEnough = 5301; //算力不足(仅算力闸门开启时返回)
+ VipRequired = 5302; //需要会员(翻译/会议纪要在 VIP 过期后受限;提示里要同时给出「续费」与「绑定带会员的设备」两条路)
+ ComputeBalanceNegative = 5303; //后台调整算力后余额将为负,拒绝
}
diff --git a/apps/proto/user/user_msg.proto b/apps/proto/user/user_msg.proto
index 1685056b..54d02e70 100644
--- a/apps/proto/user/user_msg.proto
+++ b/apps/proto/user/user_msg.proto
@@ -99,7 +99,9 @@ message UserBindDeviceResp {
int64 tradetotalintegral = 8; //@go_tags(`gorm:"tradetotalintegral" json:"tradetotalintegral"`) 翻译总积分
int64 meetintegral = 9; //@go_tags(`gorm:"meetintegral" json:"meetintegral"`) 会议积分
int64 meettotalintegral = 10; //@go_tags(`gorm:"meettotalintegral" json:"meettotalintegral"`) 会议总积分
-
+ int64 devicevipexptime = 11; //这台设备赋予的 VIP 到期时间(0=不送)
+ int32 deviceviplevel = 12; //这台设备赋予的 VIP 等级
+ int64 devicecomputeleft = 13; //这台设备剩余算力
}
//解绑定耳机请求
@@ -246,11 +248,55 @@ message UserGetDevicesResp {
//上传资源使用
message UserUsagesReq {
UsageType usagetype = 1; //使用类型
- map usages = 2; //用量统计数据
+ map usages = 2; //用量统计数据(翻译:各模式秒数;会议:MEETING_ASR_AUDIO_SECONDS;AI:可不传,改用下面两项)
+ int64 upchars = 3; //AI 对话本次输入字符数(Unicode 码点,仅 usagetype=AI 时有效)
+ int64 downchars = 4; //AI 对话本次输出字符数(同上)
}
message UserUsagesResp {
- map usages = 1; //资源剩余使用量
+ map usages = 1; //【兼容】旧三桶剩余量,2026-09 起恒为 0,老客户端仍按此字段解析
+ int64 compute = 2; //本次扣减的算力
+ int64 computedeviceleft = 3; //当前绑定设备的剩余算力合计
+ int64 computeuserleft = 4; //用户自有算力余额
+ int64 over = 5; //超额(闸门关时余额不足仍放行的部分;闸门开时恒为 0)
+}
+
+//获取会员与算力状态(客户端「用量」页一次拿齐,客户端不做任何换算)
+message UserGetComputeReq {
+}
+message UserComputeVipInfo {
+ int32 level = 1; //实际 VIP 等级 = max(自购, 设备赋予);0 = 无 VIP
+ int64 exptime = 2; //实际到期时间(自购与各设备取最晚)
+ int64 selfexptime = 3; //自购 VIP 到期时间
+ repeated UserDeviceVipInfo devices = 4; //各绑定设备赋予的 VIP
+}
+message UserDeviceVipInfo {
+ string devicemac = 1;
+ string devicename = 2;
+ int32 level = 3;
+ int64 exptime = 4; //usedtime + vipdays*86400
+ int64 computegrant = 5; //设备赠送算力总量
+ int64 computeused = 6; //已消耗
+}
+message UserComputeInfo {
+ int64 deviceleft = 1; //各绑定设备剩余算力合计
+ int64 userbalance = 2; //用户自有余额
+ int64 totalused = 3; //累计消耗
+ int64 monthused = 4; //本月消耗
+}
+message UserComputeRates {
+ int64 translate = 1; //算力 / 分钟
+ int64 meeting = 2; //算力 / 分钟
+ int64 aichat = 3; //算力 / 100 字
+ bool gate = 4; //算力闸门是否开启
+ // 运营阈值(后台可配,客户端据此提前提示;0 = 不提示)
+ int32 vipwarndays = 5; //VIP 到期前多少天开始提示
+ int64 computewarn = 6; //算力低于多少时开始提示
+}
+message UserGetComputeResp {
+ UserComputeVipInfo vip = 1;
+ UserComputeInfo compute = 2;
+ UserComputeRates rates = 3;
}
//获取唤醒词列表
diff --git a/apps/services/comm/appgoods.go b/apps/services/comm/appgoods.go
index 4073a143..376db899 100644
--- a/apps/services/comm/appgoods.go
+++ b/apps/services/comm/appgoods.go
@@ -24,6 +24,7 @@ type AppGoods struct {
Meetnum int64 `gorm:"column:meetnum" json:"meetnum"` // 发放的会议资源点
Tradenum int64 `gorm:"column:tradenum" json:"tradenum"` // 发放的翻译资源点
Viptime int32 `gorm:"column:viptime" json:"viptime"` // 发放的会员时长
+ Compute int64 `gorm:"column:compute" json:"compute"` // 发放的算力(2026-09 起;为 0 时发货按旧三桶折算,见 pay.grantOfAppGoods)
// 多语言名称/货币/价格,JSON 同 pb.DBGoods.localname;后台读写经 console 的 api_goods.go 结构化收发。
Localname string `gorm:"column:localname;type:text" json:"localname"`
diff --git a/apps/services/comm/compute.go b/apps/services/comm/compute.go
new file mode 100644
index 00000000..5b2fb9b9
--- /dev/null
+++ b/apps/services/comm/compute.go
@@ -0,0 +1,664 @@
+package comm
+
+import (
+ "errors"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "yunyan/lego/sys/mysql"
+ "yunyan/lego/sys/postgres"
+ "yunyan/pb"
+ "yunyan/utils/devcode"
+
+ "gorm.io/gorm"
+)
+
+// 会员与算力:两层权益的唯一判定与记账入口。
+//
+// - VIP 管**功能准入**:没有 VIP,翻译与会议纪要不可用;
+// - 算力管**用量计量**:翻译 / 会议 / AI 对话的消耗统一折算成算力,先扣设备赠送、再扣用户余额。
+//
+// 两层的归属规则一致:设备赠送的归设备(解绑即失去、随设备流转),用户自购的归账号。
+// 设计文档:docs/会员权益与用量统计-设计与开发文档.md
+//
+// ⚠️ 需要判 VIP / 扣算力的入口一律调本文件的函数,**别各写一份**——散落的判定过去就出过
+// 「设备送的天数累加进用户表、解绑拆不开」的事故(见 ResolveVip 的说明)。
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 换算系数:存业务库 config 表(KV),随 user_getappconfig 的 env 下发。
+// 客户端拿到系数**只用于本地预估展示**,上报的永远是原始量,换算在服务端做——
+// 否则改一次系数就要发一次版,且新老版本算出的数不一致。
+// ─────────────────────────────────────────────────────────────────────────────
+
+const (
+ ConfigKeyComputeRateTranslate = "COMPUTE_RATE_TRANSLATE" // 算力 / 分钟
+ ConfigKeyComputeRateMeeting = "COMPUTE_RATE_MEETING" // 算力 / 分钟
+ ConfigKeyComputeRateAIChat = "COMPUTE_RATE_AICHAT" // 算力 / 100 字
+ ConfigKeyComputeGate = "COMPUTE_GATE" // 1 = 余额不足即拒绝;0(默认)= 只记账不拦
+
+ // 运营参数(后台「会员与算力」页可配,改了不用发版)
+ ConfigKeyNewUserGiftVipDays = "NEWUSER_GIFT_VIPDAYS" // 开户礼 VIP 天数
+ ConfigKeyNewUserGiftCompute = "NEWUSER_GIFT_COMPUTE" // 开户礼算力
+ ConfigKeyVipWarnDays = "VIP_WARN_DAYS" // VIP 到期前多少天开始提示(0=不提示)
+ ConfigKeyComputeWarn = "COMPUTE_WARN" // 算力低于多少开始提示(0=不提示)
+)
+
+// 运营参数默认值。写成常量而不是 0:配置表还没建这几行时(新部署、或运营没填过),
+// 取 0 会变成「开户礼什么都不送、永不提示」,那是静默的功能缺失而不是合理的默认。
+const (
+ DefaultNewUserGiftVipDays = 30
+ DefaultNewUserGiftCompute = 100
+ DefaultVipWarnDays = 7
+ DefaultComputeWarn = 100
+)
+
+// OpsParams 一份生效中的运营参数。
+type OpsParams struct {
+ NewUserGiftVipDays int32
+ NewUserGiftCompute int64
+ VipWarnDays int32
+ ComputeWarn int64
+}
+
+// DefaultOpsParams 未配置时的取值。
+func DefaultOpsParams() OpsParams {
+ return OpsParams{
+ NewUserGiftVipDays: DefaultNewUserGiftVipDays,
+ NewUserGiftCompute: DefaultNewUserGiftCompute,
+ VipWarnDays: DefaultVipWarnDays,
+ ComputeWarn: DefaultComputeWarn,
+ }
+}
+
+// ParseOpsParams 从 KV 解析;缺省回默认值。
+//
+// ⚠️ 与换算系数不同,这四项**允许配 0**:0 是「不送 / 不提示」的合法表达。
+// 所以只有「键不存在」或「解析不出数字」才回默认,解析出 0 就用 0。
+func ParseOpsParams(kv map[string]string) OpsParams {
+ p := DefaultOpsParams()
+ pick := func(key string, set func(int64)) {
+ if v, ok := kv[key]; ok {
+ if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && n >= 0 {
+ set(n)
+ }
+ }
+ }
+ pick(ConfigKeyNewUserGiftVipDays, func(n int64) { p.NewUserGiftVipDays = int32(n) })
+ pick(ConfigKeyNewUserGiftCompute, func(n int64) { p.NewUserGiftCompute = n })
+ pick(ConfigKeyVipWarnDays, func(n int64) { p.VipWarnDays = int32(n) })
+ pick(ConfigKeyComputeWarn, func(n int64) { p.ComputeWarn = n })
+ return p
+}
+
+// ToKV 反向:给后台读写用。
+func (p OpsParams) ToKV() map[string]string {
+ return map[string]string{
+ ConfigKeyNewUserGiftVipDays: strconv.FormatInt(int64(p.NewUserGiftVipDays), 10),
+ ConfigKeyNewUserGiftCompute: strconv.FormatInt(p.NewUserGiftCompute, 10),
+ ConfigKeyVipWarnDays: strconv.FormatInt(int64(p.VipWarnDays), 10),
+ ConfigKeyComputeWarn: strconv.FormatInt(p.ComputeWarn, 10),
+ }
+}
+
+var opsParamsCache struct {
+ sync.Mutex
+ at time.Time
+ params OpsParams
+}
+
+// LoadOpsParams 读取生效中的运营参数(业务库 config 表),缓存同换算系数。
+func LoadOpsParams() OpsParams {
+ opsParamsCache.Lock()
+ defer opsParamsCache.Unlock()
+ if !opsParamsCache.at.IsZero() && time.Since(opsParamsCache.at) < computeRatesTTL {
+ return opsParamsCache.params
+ }
+ items := make([]*pb.DBAppConfigItem, 0, 4)
+ keys := []string{ConfigKeyNewUserGiftVipDays, ConfigKeyNewUserGiftCompute, ConfigKeyVipWarnDays, ConfigKeyComputeWarn}
+ kv := map[string]string{}
+ if err := mysql.Find(TableAppConfig, &items, "`key` IN ?", keys); err == nil {
+ for _, it := range items {
+ kv[it.Key] = it.Value
+ }
+ }
+ opsParamsCache.params = ParseOpsParams(kv)
+ opsParamsCache.at = time.Now()
+ return opsParamsCache.params
+}
+
+// InvalidateOpsParams 让下一次 LoadOpsParams 重新读库。
+func InvalidateOpsParams() {
+ opsParamsCache.Lock()
+ opsParamsCache.at = time.Time{}
+ opsParamsCache.Unlock()
+}
+
+// ComputeRates 一份生效中的换算系数。
+type ComputeRates struct {
+ Translate int64
+ Meeting int64
+ AIChat int64
+ Gate bool
+}
+
+// DefaultComputeRates 三个系数默认全 1:1 分钟翻译 = 1 分钟会议 = 100 字对话 = 1 算力;闸门默认关。
+func DefaultComputeRates() ComputeRates {
+ return ComputeRates{Translate: 1, Meeting: 1, AIChat: 1, Gate: false}
+}
+
+// ParseComputeRates 从 env 形式的 KV 解析系数;缺省或非法一律回默认值,不让一个写坏的配置把计费打到 0。
+func ParseComputeRates(kv map[string]string) ComputeRates {
+ r := DefaultComputeRates()
+ pick := func(key string, dst *int64) {
+ if v, ok := kv[key]; ok {
+ if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && n > 0 {
+ *dst = n
+ }
+ }
+ }
+ pick(ConfigKeyComputeRateTranslate, &r.Translate)
+ pick(ConfigKeyComputeRateMeeting, &r.Meeting)
+ pick(ConfigKeyComputeRateAIChat, &r.AIChat)
+ if v, ok := kv[ConfigKeyComputeGate]; ok {
+ v = strings.TrimSpace(strings.ToLower(v))
+ r.Gate = v == "1" || v == "true" || v == "on"
+ }
+ return r
+}
+
+// ToKV 反向:给后台读写与下发用。
+func (r ComputeRates) ToKV() map[string]string {
+ gate := "0"
+ if r.Gate {
+ gate = "1"
+ }
+ return map[string]string{
+ ConfigKeyComputeRateTranslate: strconv.FormatInt(r.Translate, 10),
+ ConfigKeyComputeRateMeeting: strconv.FormatInt(r.Meeting, 10),
+ ConfigKeyComputeRateAIChat: strconv.FormatInt(r.AIChat, 10),
+ ConfigKeyComputeGate: gate,
+ }
+}
+
+var computeRatesCache struct {
+ sync.Mutex
+ at time.Time
+ rates ComputeRates
+}
+
+// computeRatesTTL 读库缓存时长。改系数后最多这么久生效;后台保存时会调 InvalidateComputeRates 立即刷新本进程。
+const computeRatesTTL = 30 * time.Second
+
+// LoadComputeRates 读取生效中的换算系数(业务库 config 表)。读失败回默认值——计费不能因为配置表抖动而中断。
+func LoadComputeRates() ComputeRates {
+ computeRatesCache.Lock()
+ defer computeRatesCache.Unlock()
+ if !computeRatesCache.at.IsZero() && time.Since(computeRatesCache.at) < computeRatesTTL {
+ return computeRatesCache.rates
+ }
+ items := make([]*pb.DBAppConfigItem, 0, 4)
+ keys := []string{ConfigKeyComputeRateTranslate, ConfigKeyComputeRateMeeting, ConfigKeyComputeRateAIChat, ConfigKeyComputeGate}
+ kv := map[string]string{}
+ // `key` 是 MySQL 保留字,必须反引号。
+ if err := mysql.Find(TableAppConfig, &items, "`key` IN ?", keys); err == nil {
+ for _, it := range items {
+ kv[it.Key] = it.Value
+ }
+ }
+ computeRatesCache.rates = ParseComputeRates(kv)
+ computeRatesCache.at = time.Now()
+ return computeRatesCache.rates
+}
+
+// InvalidateComputeRates 让下一次 LoadComputeRates 重新读库。
+func InvalidateComputeRates() {
+ computeRatesCache.Lock()
+ computeRatesCache.at = time.Time{}
+ computeRatesCache.Unlock()
+}
+
+// SaveComputeRates 把系数写回 config 表(有则改、无则建),并使本进程缓存失效。
+// 其它进程靠 computeRatesTTL 自然过期,或由调用方广播 Rpc_ModifyAppConifg。
+func SaveComputeRates(r ComputeRates) error {
+ if r.Translate <= 0 || r.Meeting <= 0 || r.AIChat <= 0 {
+ return errors.New("换算系数必须大于 0")
+ }
+ for k, v := range r.ToKV() {
+ item := &pb.DBAppConfigItem{}
+ err := mysql.FindOne(TableAppConfig, item, "`key`=?", k)
+ if err != nil && err != mysql.ErrNoDocuments {
+ return err
+ }
+ if err == mysql.ErrNoDocuments {
+ item = &pb.DBAppConfigItem{Group: "compute", Key: k, Value: v, Description: computeKeyDesc(k)}
+ if err = mysql.Insert(TableAppConfig, item); err != nil {
+ return err
+ }
+ continue
+ }
+ if item.Value != v {
+ if err = mysql.Table(TableAppConfig).Where("`key`=?", k).Update("value", v).Error; err != nil {
+ return err
+ }
+ }
+ }
+ InvalidateComputeRates()
+ return nil
+}
+
+func computeKeyDesc(k string) string {
+ switch k {
+ case ConfigKeyComputeRateTranslate:
+ return "算力换算:翻译,算力/分钟"
+ case ConfigKeyComputeRateMeeting:
+ return "算力换算:会议纪要,算力/分钟"
+ case ConfigKeyComputeRateAIChat:
+ return "算力换算:AI对话,算力/100字"
+ case ConfigKeyComputeGate:
+ return "算力闸门:1=余额不足即拒绝,0=只记账不拦(开启前须先上线预扣)"
+ case ConfigKeyNewUserGiftVipDays:
+ return "开户礼:赠送VIP天数(0=不送)"
+ case ConfigKeyNewUserGiftCompute:
+ return "开户礼:赠送算力(0=不送)"
+ case ConfigKeyVipWarnDays:
+ return "提示阈值:VIP到期前多少天开始提示(0=不提示)"
+ case ConfigKeyComputeWarn:
+ return "提示阈值:算力低于多少开始提示(0=不提示)"
+ }
+ return ""
+}
+
+func ceilDiv(a, b int64) int64 {
+ if a <= 0 || b <= 0 {
+ return 0
+ }
+ return (a + b - 1) / b
+}
+
+// ForTranslate 翻译时长(秒)→ 算力。按会话累计后向上取整到分钟,单次最低 1。
+func (r ComputeRates) ForTranslate(sec int64) int64 {
+ if sec <= 0 {
+ return 0
+ }
+ if c := ceilDiv(sec, 60) * r.Translate; c > 0 {
+ return c
+ }
+ return 1
+}
+
+// ForMeeting 会议时长(秒)→ 算力。
+func (r ComputeRates) ForMeeting(sec int64) int64 {
+ if sec <= 0 {
+ return 0
+ }
+ if c := ceilDiv(sec, 60) * r.Meeting; c > 0 {
+ return c
+ }
+ return 1
+}
+
+// ForAIChat 对话字符数(输入 + 输出,Unicode 码点)→ 算力。每 100 字向上取整,单次最低 1。
+func (r ComputeRates) ForAIChat(chars int64) int64 {
+ if chars <= 0 {
+ return 0
+ }
+ if c := ceilDiv(chars, 100) * r.AIChat; c > 0 {
+ return c
+ }
+ return 1
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 绑定设备:VIP 与设备算力都挂在 device_mac 行上,按「当前绑定关系」取。
+// 绑定关系在 userdevice(业务库 MySQL),设备行在 device_mac(console 主库 Postgres),
+// 两库之间只能靠 MAC 对;device_mac.uid 只是台账、解绑时尽力清理,不拿它当绑定判据。
+// ─────────────────────────────────────────────────────────────────────────────
+
+// BoundFactoryDevices 取用户当前绑定的全部设备行(已被后台禁用的不算)。
+// 找不到对应设备行的绑定记录静默跳过——那是台账不一致,不该让整次判定失败。
+func BoundFactoryDevices(uid string) ([]*pb.DBAuthCode, error) {
+ uds := make([]*pb.DBUserDivice, 0, 2)
+ if err := mysql.Find(TableUserdevice, &uds, "uid=?", uid); err != nil && err != mysql.ErrNoDocuments {
+ return nil, err
+ }
+ out := make([]*pb.DBAuthCode, 0, len(uds))
+ seen := map[string]bool{}
+ for _, ud := range uds {
+ mac := strings.TrimSpace(ud.Devicemac)
+ if mac == "" {
+ continue
+ }
+ if n, ok := devcode.NormalizeMac(mac); ok {
+ mac = n
+ }
+ if seen[mac] {
+ continue
+ }
+ seen[mac] = true
+ d := &pb.DBAuthCode{}
+ if err := postgres.FindOne(TableDeviceMac, d, "devicemac=?", mac); err != nil {
+ continue
+ }
+ if d.Disabled != 0 {
+ continue
+ }
+ out = append(out, d)
+ }
+ return out, nil
+}
+
+// DeviceVipExpire 这台设备赋予的 VIP 到期时间:激活日 + vipdays 天,按日历天连续走、解绑不暂停。
+// 未激活(usedtime=0)或不送 VIP(vipdays=0)返回 0。
+func DeviceVipExpire(d *pb.DBAuthCode) int64 {
+ if d == nil || d.Usedtime <= 0 || d.Vipdays <= 0 {
+ return 0
+ }
+ return d.Usedtime + int64(d.Vipdays)*86400
+}
+
+// DeviceVipLevel 设备赋予的等级;配了天数但没配等级视为 1。
+func DeviceVipLevel(d *pb.DBAuthCode) int32 {
+ if d == nil || d.Vipdays <= 0 {
+ return 0
+ }
+ if d.Viplevel <= 0 {
+ return 1
+ }
+ return d.Viplevel
+}
+
+// DeviceComputeLeft 设备赠送算力剩余。
+func DeviceComputeLeft(d *pb.DBAuthCode) int64 {
+ if d == nil {
+ return 0
+ }
+ if left := d.Computegrant - d.Computeused; left > 0 {
+ return left
+ }
+ return 0
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// VIP 判定
+// ─────────────────────────────────────────────────────────────────────────────
+
+// VipStatus 合并后的会员状态。
+type VipStatus struct {
+ Level int32 // 生效等级 = max(自购, 各绑定设备);0 = 当前无 VIP
+ ExpTime int64 // 生效到期时间(各来源取最晚);无 VIP 时为各来源中最晚的历史到期,便于显示「已于 x 到期」
+ SelfExpTime int64 // 自购 VIP 到期时间(user.vipexptime,只记自购)
+ FromDevice bool // 当前生效的 VIP 是否来自设备(用于展示来源)
+}
+
+// Active 此刻是否有 VIP。
+func (v VipStatus) Active(now int64) bool { return v.Level > 0 && v.ExpTime > now }
+
+// ResolveVip 合并自购与设备赋予的 VIP。
+//
+// 判定 = 自购未过期 **或** (当前绑定着某设备 且 该设备 VIP 期未结束)。任一成立即有 VIP。
+//
+// ⚠️ 设备侧**不写用户表**:user.vipexptime 只记自购。以前是把赠送天数直接累加进去,
+// 一旦累加就与自购混成同一个数字,解绑时无法拆分该扣回多少天。现在设备 VIP 由
+// device_mac 行的 usedtime + vipdays 算出,解绑时什么都不用做——不绑了自然就不享有了。
+func ResolveVip(user *pb.DBUser, devices []*pb.DBAuthCode, now int64) VipStatus {
+ v := VipStatus{}
+ if user != nil {
+ v.SelfExpTime = user.Vipexptime
+ }
+ // 候选:每个来源一条 (level, exp, fromDevice)。先挑生效中的,按 level 高、exp 晚取;
+ // 全部过期时只记最晚的历史到期,Level 保持 0。
+ type cand struct {
+ level int32
+ exp int64
+ fromDevice bool
+ }
+ cands := make([]cand, 0, 1+len(devices))
+ if user != nil && user.Vipexptime > 0 {
+ lv := user.Viplv
+ if lv <= 0 {
+ lv = 1
+ }
+ cands = append(cands, cand{level: lv, exp: user.Vipexptime})
+ }
+ for _, d := range devices {
+ if exp := DeviceVipExpire(d); exp > 0 {
+ cands = append(cands, cand{level: DeviceVipLevel(d), exp: exp, fromDevice: true})
+ }
+ }
+ var latestExpired int64
+ for _, c := range cands {
+ if c.exp <= now {
+ if c.exp > latestExpired {
+ latestExpired = c.exp
+ }
+ continue
+ }
+ better := v.Level == 0 || c.level > v.Level || (c.level == v.Level && c.exp > v.ExpTime)
+ if better {
+ v.Level, v.ExpTime, v.FromDevice = c.level, c.exp, c.fromDevice
+ }
+ }
+ if v.Level == 0 {
+ v.ExpTime = latestExpired
+ }
+ return v
+}
+
+// ResolveUserVip 按 uid 一步到位:读用户与其绑定设备后合并。user 可传 nil 由本函数读取。
+func ResolveUserVip(uid string, user *pb.DBUser) (VipStatus, []*pb.DBAuthCode, error) {
+ if user == nil {
+ user = &pb.DBUser{}
+ if err := mysql.FindOne(TableUser, user, "uid=?", uid); err != nil {
+ return VipStatus{}, nil, err
+ }
+ }
+ devices, err := BoundFactoryDevices(uid)
+ if err != nil {
+ return VipStatus{}, nil, err
+ }
+ return ResolveVip(user, devices, time.Now().Unix()), devices, nil
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 算力扣减
+// ─────────────────────────────────────────────────────────────────────────────
+
+// ComputeUsageKind 消耗类型,决定用哪个换算系数。
+type ComputeUsageKind int
+
+const (
+ ComputeUsageTranslate ComputeUsageKind = iota + 1
+ ComputeUsageMeeting
+ ComputeUsageAIChat
+)
+
+// ErrComputeNotEnough 闸门开启且余额不足。
+var ErrComputeNotEnough = errors.New("compute not enough")
+
+// ErrComputeBalanceNegative 调整后余额将为负。
+var ErrComputeBalanceNegative = errors.New("compute balance would be negative")
+
+// ComputeResult 一次扣减的结果。
+type ComputeResult struct {
+ Compute int64 // 本次折算出的算力
+ Device int64 // 从设备赠送额度扣的部分
+ User int64 // 从用户余额扣的部分
+ Over int64 // 超额(闸门关时余额不足仍放行的量;闸门开时恒为 0)
+ DeviceMac string // 扣了哪台设备(多台以逗号分隔)
+ DeviceLeft int64 // 扣后各绑定设备剩余合计
+ UserLeft int64 // 扣后用户余额
+ Rejected bool // 闸门开启且不足,已拒绝、未写库
+}
+
+type deviceCut struct {
+ d *pb.DBAuthCode
+ n int64
+}
+
+// planComputeDeduction 决定从哪里扣多少:先扣设备(剩余最少的先扣,用完一台再动下一台),再扣用户余额,剩下算超额。
+func planComputeDeduction(need int64, devices []*pb.DBAuthCode, userBalance int64) (cuts []deviceCut, fromUser, over int64) {
+ rem := need
+ sorted := make([]*pb.DBAuthCode, 0, len(devices))
+ for _, d := range devices {
+ if DeviceComputeLeft(d) > 0 {
+ sorted = append(sorted, d)
+ }
+ }
+ sort.SliceStable(sorted, func(i, j int) bool { return DeviceComputeLeft(sorted[i]) < DeviceComputeLeft(sorted[j]) })
+ for _, d := range sorted {
+ if rem <= 0 {
+ break
+ }
+ n := DeviceComputeLeft(d)
+ if n > rem {
+ n = rem
+ }
+ cuts = append(cuts, deviceCut{d: d, n: n})
+ rem -= n
+ }
+ if rem > 0 && userBalance > 0 {
+ fromUser = userBalance
+ if fromUser > rem {
+ fromUser = rem
+ }
+ rem -= fromUser
+ }
+ over = rem
+ return
+}
+
+// ApplyComputeUsage 一次用量的算力记账:换算 → 先设备后用户扣减 → 闸门判定 → 原子写库 → 回填流水与统计。
+//
+// - raw:原始量(翻译/会议为秒,AI 为输入+输出字符数);
+// - userlog:调用方已填好原始字段的流水行,本函数只补算力五列,**由调用方负责 Insert**;
+// - stats:调用方持有的统计行,本函数累加 Computetotal,**由调用方负责保存**。
+//
+// 闸门关(默认):余额不够也放行,差额记进 Over。闸门开:不够则 Rejected=true 且不写任何东西。
+//
+// 设备扣减用 `computeused = computeused + n` 原子更新、用户余额用 `computebalance - n`,
+// 不走读-改-写,避免并发上报把余额算飞。
+func ApplyComputeUsage(uid string, kind ComputeUsageKind, raw int64, userlog *pb.DBUserUseLog, stats *pb.DBUserStatistics) (res ComputeResult, err error) {
+ rates := LoadComputeRates()
+ switch kind {
+ case ComputeUsageTranslate:
+ res.Compute = rates.ForTranslate(raw)
+ case ComputeUsageMeeting:
+ res.Compute = rates.ForMeeting(raw)
+ case ComputeUsageAIChat:
+ res.Compute = rates.ForAIChat(raw)
+ }
+ user := &pb.DBUser{}
+ if err = mysql.FindOne(TableUser, user, "uid=?", uid); err != nil {
+ return
+ }
+ devices, derr := BoundFactoryDevices(uid)
+ if derr != nil {
+ err = derr
+ return
+ }
+ if res.Compute == 0 {
+ res.UserLeft = user.Computebalance
+ for _, d := range devices {
+ res.DeviceLeft += DeviceComputeLeft(d)
+ }
+ return
+ }
+ cuts, fromUser, over := planComputeDeduction(res.Compute, devices, user.Computebalance)
+ if rates.Gate && over > 0 {
+ res.Rejected = true
+ res.Over = over
+ res.UserLeft = user.Computebalance
+ for _, d := range devices {
+ res.DeviceLeft += DeviceComputeLeft(d)
+ }
+ return
+ }
+ macs := make([]string, 0, len(cuts))
+ for _, c := range cuts {
+ if err = postgres.Table(TableDeviceMac).Where("code=?", c.d.Code).
+ Update("computeused", gorm.Expr("computeused + ?", c.n)).Error; err != nil {
+ return
+ }
+ c.d.Computeused += c.n
+ res.Device += c.n
+ macs = append(macs, c.d.Devicemac)
+ }
+ if fromUser > 0 {
+ if err = mysql.Table(TableUser).Where("uid=?", uid).
+ Update("computebalance", gorm.Expr("computebalance - ?", fromUser)).Error; err != nil {
+ return
+ }
+ user.Computebalance -= fromUser
+ res.User = fromUser
+ }
+ res.Over = over
+ res.DeviceMac = strings.Join(macs, ",")
+ res.UserLeft = user.Computebalance
+ for _, d := range devices {
+ res.DeviceLeft += DeviceComputeLeft(d)
+ }
+ if userlog != nil {
+ userlog.Compute = -res.Compute
+ userlog.Computedevice = res.Device
+ userlog.Computeuser = res.User
+ userlog.Computeover = res.Over
+ userlog.Devicemac = res.DeviceMac
+ }
+ if stats != nil {
+ stats.Computetotal += res.Compute
+ }
+ return
+}
+
+// AdjustUserCompute 后台调整用户算力余额(delta 可负),余额不得低于 0;同时写一条 AdminAdjust 流水。
+func AdjustUserCompute(uid string, delta int64, remark string) (newBalance int64, err error) {
+ user := &pb.DBUser{}
+ if err = mysql.FindOne(TableUser, user, "uid=?", uid); err != nil {
+ return
+ }
+ if user.Computebalance+delta < 0 {
+ err = ErrComputeBalanceNegative
+ return
+ }
+ if err = mysql.Table(TableUser).Where("uid=?", uid).
+ Update("computebalance", gorm.Expr("computebalance + ?", delta)).Error; err != nil {
+ return
+ }
+ newBalance = user.Computebalance + delta
+ _ = mysql.Insert(TableUserUseLog, &pb.DBUserUseLog{
+ Uid: uid,
+ Ts: time.Now().Unix(),
+ Logtype: pb.UserLogType_AdminAdjust,
+ Compute: delta,
+ Extra: remark,
+ })
+ return
+}
+
+// GrantUserCompute 发放用户算力(支付发货 / 开户礼等),delta 必须 > 0。流水由调用方写(它们各有自己的 logtype 与 extra)。
+func GrantUserCompute(uid string, delta int64) error {
+ if delta <= 0 {
+ return nil
+ }
+ return mysql.Table(TableUser).Where("uid=?", uid).
+ Update("computebalance", gorm.Expr("computebalance + ?", delta)).Error
+}
+
+// LegacyBucketsToCompute 把旧三桶的量折算成算力:翻译秒 / 会议秒按分钟向上取整 × 系数,AI 次数 1:1。
+// 用于:存量余额迁移、老商品(只配了 ainum/meetnum/tradenum 没配 compute)发货、公码与后台赠送的旧字段。
+func LegacyBucketsToCompute(r ComputeRates, tradeSec, meetSec, aiCount int64) int64 {
+ var c int64
+ if tradeSec > 0 {
+ c += ceilDiv(tradeSec, 60) * r.Translate
+ }
+ if meetSec > 0 {
+ c += ceilDiv(meetSec, 60) * r.Meeting
+ }
+ if aiCount > 0 {
+ c += aiCount
+ }
+ return c
+}
diff --git a/apps/services/comm/compute_test.go b/apps/services/comm/compute_test.go
new file mode 100644
index 00000000..e0948e83
--- /dev/null
+++ b/apps/services/comm/compute_test.go
@@ -0,0 +1,172 @@
+package comm
+
+import (
+ "testing"
+ "yunyan/pb"
+)
+
+func TestComputeRates_Conversion(t *testing.T) {
+ r := DefaultComputeRates()
+ cases := []struct {
+ name string
+ got int64
+ want int64
+ }{
+ {"翻译 0 秒不计", r.ForTranslate(0), 0},
+ {"翻译 1 秒向上取整到 1 分钟", r.ForTranslate(1), 1},
+ {"翻译 60 秒 = 1", r.ForTranslate(60), 1},
+ {"翻译 61 秒 = 2", r.ForTranslate(61), 2},
+ {"会议 3600 秒 = 60", r.ForMeeting(3600), 60},
+ {"对话 1 字最低 1", r.ForAIChat(1), 1},
+ {"对话 100 字 = 1", r.ForAIChat(100), 1},
+ {"对话 101 字 = 2", r.ForAIChat(101), 2},
+ }
+ for _, c := range cases {
+ if c.got != c.want {
+ t.Errorf("%s: got %d want %d", c.name, c.got, c.want)
+ }
+ }
+ r2 := ComputeRates{Translate: 3, Meeting: 2, AIChat: 5}
+ if got := r2.ForTranslate(90); got != 6 {
+ t.Errorf("系数 3 × 2 分钟应为 6,got %d", got)
+ }
+ if got := r2.ForAIChat(250); got != 15 {
+ t.Errorf("系数 5 × 3 段应为 15,got %d", got)
+ }
+}
+
+func TestParseComputeRates_BadValuesFallBack(t *testing.T) {
+ r := ParseComputeRates(map[string]string{
+ ConfigKeyComputeRateTranslate: "0", // 非法 → 默认 1
+ ConfigKeyComputeRateMeeting: "abc", // 非法 → 默认 1
+ ConfigKeyComputeRateAIChat: "4",
+ ConfigKeyComputeGate: "1",
+ })
+ if r.Translate != 1 || r.Meeting != 1 || r.AIChat != 4 || !r.Gate {
+ t.Fatalf("unexpected %+v", r)
+ }
+ if r := ParseComputeRates(nil); r != DefaultComputeRates() {
+ t.Fatalf("空配置应回默认,got %+v", r)
+ }
+}
+
+func TestResolveVip(t *testing.T) {
+ now := int64(2_000_000_000) // 2033 年,减几十天也不会跌成负数(Usedtime<=0 视为未激活)
+ day := int64(86400)
+ dev := func(used int64, days, level int32) *pb.DBAuthCode {
+ return &pb.DBAuthCode{Code: "AA", Devicemac: "AA", Usedtime: used, Vipdays: days, Viplevel: level}
+ }
+ t.Run("无任何来源", func(t *testing.T) {
+ v := ResolveVip(&pb.DBUser{}, nil, now)
+ if v.Active(now) || v.Level != 0 {
+ t.Fatalf("%+v", v)
+ }
+ })
+ t.Run("只有自购且有效", func(t *testing.T) {
+ v := ResolveVip(&pb.DBUser{Viplv: 1, Vipexptime: now + day}, nil, now)
+ if !v.Active(now) || v.FromDevice || v.ExpTime != now+day {
+ t.Fatalf("%+v", v)
+ }
+ })
+ t.Run("自购过期、设备在期 → 来自设备", func(t *testing.T) {
+ v := ResolveVip(&pb.DBUser{Vipexptime: now - day}, []*pb.DBAuthCode{dev(now-10*day, 30, 1)}, now)
+ if !v.Active(now) || !v.FromDevice || v.ExpTime != now-10*day+30*day {
+ t.Fatalf("%+v", v)
+ }
+ })
+ t.Run("设备未激活不算", func(t *testing.T) {
+ v := ResolveVip(&pb.DBUser{}, []*pb.DBAuthCode{dev(0, 30, 1)}, now)
+ if v.Active(now) {
+ t.Fatalf("%+v", v)
+ }
+ })
+ t.Run("设备 VIP 期按日历天连续走,解绑不暂停:激活 40 天前送 30 天 → 已过期", func(t *testing.T) {
+ v := ResolveVip(&pb.DBUser{}, []*pb.DBAuthCode{dev(now-40*day, 30, 1)}, now)
+ if v.Active(now) {
+ t.Fatalf("%+v", v)
+ }
+ if v.ExpTime != now-10*day {
+ t.Fatalf("过期时仍应给出历史到期时间, %+v", v)
+ }
+ })
+ t.Run("两者都有效:等级高的赢;同级取最晚", func(t *testing.T) {
+ v := ResolveVip(&pb.DBUser{Viplv: 1, Vipexptime: now + 100*day}, []*pb.DBAuthCode{dev(now-day, 10, 2)}, now)
+ if v.Level != 2 || !v.FromDevice {
+ t.Fatalf("设备是 VIP-Pro 应优先, %+v", v)
+ }
+ v = ResolveVip(&pb.DBUser{Viplv: 1, Vipexptime: now + 100*day}, []*pb.DBAuthCode{dev(now-day, 10, 1)}, now)
+ if v.Level != 1 || v.FromDevice || v.ExpTime != now+100*day {
+ t.Fatalf("同级取到期最晚的自购, %+v", v)
+ }
+ })
+ t.Run("设备配了天数没配等级视为 1", func(t *testing.T) {
+ v := ResolveVip(&pb.DBUser{}, []*pb.DBAuthCode{dev(now-day, 10, 0)}, now)
+ if v.Level != 1 {
+ t.Fatalf("%+v", v)
+ }
+ })
+}
+
+func TestPlanComputeDeduction(t *testing.T) {
+ d := func(mac string, grant, used int64) *pb.DBAuthCode {
+ return &pb.DBAuthCode{Code: mac, Devicemac: mac, Computegrant: grant, Computeused: used}
+ }
+ t.Run("先扣设备,剩余最少的先扣", func(t *testing.T) {
+ cuts, user, over := planComputeDeduction(7, []*pb.DBAuthCode{d("big", 100, 0), d("small", 10, 7)}, 50)
+ if len(cuts) != 2 || cuts[0].d.Code != "small" || cuts[0].n != 3 || cuts[1].n != 4 || user != 0 || over != 0 {
+ t.Fatalf("cuts=%+v user=%d over=%d", cuts, user, over)
+ }
+ })
+ t.Run("设备不够扣用户", func(t *testing.T) {
+ cuts, user, over := planComputeDeduction(10, []*pb.DBAuthCode{d("a", 4, 0)}, 50)
+ if len(cuts) != 1 || cuts[0].n != 4 || user != 6 || over != 0 {
+ t.Fatalf("cuts=%+v user=%d over=%d", cuts, user, over)
+ }
+ })
+ t.Run("都不够记超额", func(t *testing.T) {
+ cuts, user, over := planComputeDeduction(10, []*pb.DBAuthCode{d("a", 4, 0)}, 2)
+ if len(cuts) != 1 || user != 2 || over != 4 {
+ t.Fatalf("cuts=%+v user=%d over=%d", cuts, user, over)
+ }
+ })
+ t.Run("用完的设备跳过", func(t *testing.T) {
+ cuts, user, _ := planComputeDeduction(1, []*pb.DBAuthCode{d("a", 4, 4)}, 5)
+ if len(cuts) != 0 || user != 1 {
+ t.Fatalf("cuts=%+v user=%d", cuts, user)
+ }
+ })
+}
+
+func TestParseOpsParams(t *testing.T) {
+ t.Run("空配置回默认", func(t *testing.T) {
+ if got := ParseOpsParams(nil); got != DefaultOpsParams() {
+ t.Fatalf("got %+v", got)
+ }
+ })
+ t.Run("0 是合法值,不回落默认", func(t *testing.T) {
+ got := ParseOpsParams(map[string]string{
+ ConfigKeyNewUserGiftVipDays: "0",
+ ConfigKeyNewUserGiftCompute: "0",
+ ConfigKeyVipWarnDays: "0",
+ ConfigKeyComputeWarn: "0",
+ })
+ if got.NewUserGiftVipDays != 0 || got.NewUserGiftCompute != 0 || got.VipWarnDays != 0 || got.ComputeWarn != 0 {
+ t.Fatalf("配 0 应保持 0(= 不送 / 不提示),got %+v", got)
+ }
+ })
+ t.Run("非法值回默认", func(t *testing.T) {
+ got := ParseOpsParams(map[string]string{
+ ConfigKeyNewUserGiftVipDays: "abc",
+ ConfigKeyNewUserGiftCompute: "-5",
+ })
+ if got.NewUserGiftVipDays != DefaultNewUserGiftVipDays || got.NewUserGiftCompute != DefaultNewUserGiftCompute {
+ t.Fatalf("got %+v", got)
+ }
+ })
+ t.Run("往返一致", func(t *testing.T) {
+ in := OpsParams{NewUserGiftVipDays: 15, NewUserGiftCompute: 250, VipWarnDays: 3, ComputeWarn: 50}
+ if got := ParseOpsParams(in.ToKV()); got != in {
+ t.Fatalf("got %+v want %+v", got, in)
+ }
+ })
+}
diff --git a/apps/services/comm/svcpool.go b/apps/services/comm/svcpool.go
index 89edb2bc..035dd84e 100644
--- a/apps/services/comm/svcpool.go
+++ b/apps/services/comm/svcpool.go
@@ -162,6 +162,11 @@ func ResolveSvcPlainFields(svc *ThirdSvcConfig, ovr *SvcRegionOverride, encKey s
// 只是客户端现在不直接消费第三方服务下发,改由服务端代调,凭据因此不出网关。
const SvcCatMT int32 = 3
+// SvcCatAST 端到端实时翻译(通话翻译走的阿里 livetranslate 等,与 admin 前端 SCAT 的 4 一致)。
+// 客户端拿着它的凭据**直连服务商**,翻译过程不经过我们的服务器——VIP 过期后唯一的服务端手段
+// 就是不下发这一类凭据(见 modules/user 的 filterSvcsByVip)。
+const SvcCatAST int32 = 4
+
// SvcCatIdVerify 身份证实名核验(阿里云 Id2MetaVerify / 腾讯云 IdCardVerification)。
// 服务端专用——凭据是云账号主 AK/SK,绝不下发客户端。
const SvcCatIdVerify int32 = 11
diff --git a/apps/services/lego/.DS_Store b/apps/services/lego/.DS_Store
index f63a0647..71c144c2 100644
Binary files a/apps/services/lego/.DS_Store and b/apps/services/lego/.DS_Store differ
diff --git a/apps/services/modules/.DS_Store b/apps/services/modules/.DS_Store
index 5bf4106f..a4311c49 100644
Binary files a/apps/services/modules/.DS_Store and b/apps/services/modules/.DS_Store differ
diff --git a/apps/services/modules/api/api_dispatchresource.go b/apps/services/modules/api/api_dispatchresource.go
index 64054201..7bc4206c 100644
--- a/apps/services/modules/api/api_dispatchresource.go
+++ b/apps/services/modules/api/api_dispatchresource.go
@@ -72,20 +72,11 @@ func (this *apiComp) DispatchResource(session comm.IUserSession, req *pb.ApiDisp
}
userlog.Addvipday = addVipDay
}
- if addAichat > 0 {
- user.Aichatintegral += addAichat
- user.Aichattotalintegral += addAichat
- userlog.Addagentintegral = addAichat
- }
- if addTradeSec > 0 {
- user.Tradeintegral += addTradeSec
- user.Tradetotalintegral += addTradeSec
- userlog.Addtradesecond = addTradeSec
- }
- if addMeetSec > 0 {
- user.Meetintegral += addMeetSec
- user.Meettotalintegral += addMeetSec
- userlog.Addmeetsecond = addMeetSec
+ // 2026-09 起三个额度桶停用:派发表单上的 AI 次 / 翻译分 / 会议分按当前换算系数折成算力发到用户余额。
+ // 管理员资源池仍按旧单位扣(池是后台侧的账,与用户侧计量无关)。
+ if addCompute := comm.LegacyBucketsToCompute(comm.LoadComputeRates(), addTradeSec, addMeetSec, addAichat); addCompute > 0 {
+ user.Computebalance += addCompute
+ userlog.Compute = addCompute
}
if err = this.module.model.updateuser(user); err != nil {
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
diff --git a/apps/services/modules/api/model.go b/apps/services/modules/api/model.go
index 3694a5b8..978e4ed7 100644
--- a/apps/services/modules/api/model.go
+++ b/apps/services/modules/api/model.go
@@ -106,6 +106,15 @@ func (this *modelComp) Init(service core.IService, module core.IModule, comp cor
// ⚠️ 不叫 factoryid:DBProduct 的 factoryid 是老客户端兼容字段(gorm:"-",值同 brandid),同名会打架。
"ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS oemfactoryid bigint DEFAULT 0",
"CREATE INDEX IF NOT EXISTS idx_product_providerid ON " + comm.TableProduct + " (providerid)",
+ // devicetype 已于 2026-09-08 废弃并删列(同 console 的 ensureDeviceTables):
+ // 服务端无逻辑读它,后台表单已去掉。pb 里的 Devicetype 改成了 gorm:"-" 的老客户端兼容
+ // 字段(恒为 0,只为老 App 包能解析 user_getappconfig),新库不会建这一列。
+ // ⚠️ 不可逆:执行后存量列里的历史取值即消失。该值不参与任何判断,故不做备份。
+ "ALTER TABLE " + comm.TableProduct + " DROP COLUMN IF EXISTS devicetype",
+ // scanuuid 同上(2026-09-08 废弃):设备校验与绑定奖励都已改成按 MAC 判定,
+ // pb 里的 Scanuuid 是 gorm:"-" 的老客户端兼容字段,新库不会建这一列。
+ // ⚠️ 不可逆:执行后存量列里的历史取值即消失。
+ "ALTER TABLE " + comm.TableProduct + " DROP COLUMN IF EXISTS scanuuid",
} {
if res := postgres.Exec(ddl); res.Error != nil {
this.module.Errorln(res.Error)
diff --git a/apps/services/modules/console/api_compute.go b/apps/services/modules/console/api_compute.go
new file mode 100644
index 00000000..9d91e186
--- /dev/null
+++ b/apps/services/modules/console/api_compute.go
@@ -0,0 +1,427 @@
+package console
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "yunyan/comm"
+ "yunyan/lego/sys/log"
+ "yunyan/lego/sys/mysql"
+ "yunyan/pb"
+ "yunyan/utils/devcode"
+
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+// 会员与算力 · 后台接口(设计文档:docs/会员权益与用量统计-设计与开发文档.md §6/§7)。
+//
+// 所有接口都按选中应用(X-App-Id)直连其业务库:换算系数存业务库 config 表(随 user_getappconfig 的 env
+// 下发给客户端,客户端仅用于本地预估展示);用户算力余额与流水在业务库 user / useruselog。
+// 设备赠送的 VIP / 算力挂在 console 主库的 device_mac 行上,用户详情里跨库拼出来。
+//
+// comm.ApplyComputeUsage / AdjustUserCompute 用的是业务进程自己的全局 mysql 连接,console 这边
+// 拿到的是按应用取出的连接对象,所以余额调整逻辑在这里按 conn 重写了一份(语义完全一致)。
+
+var computeConfigKeys = []string{
+ comm.ConfigKeyComputeRateTranslate,
+ comm.ConfigKeyComputeRateMeeting,
+ comm.ConfigKeyComputeRateAIChat,
+ comm.ConfigKeyComputeGate,
+ comm.ConfigKeyNewUserGiftVipDays,
+ comm.ConfigKeyNewUserGiftCompute,
+ comm.ConfigKeyVipWarnDays,
+ comm.ConfigKeyComputeWarn,
+}
+
+// computeRatesView 与前端约定的形状:换算系数 + 闸门 + 运营参数,一次读写。
+type computeRatesView struct {
+ Translate int64 `json:"translate"` // 算力 / 分钟
+ Meeting int64 `json:"meeting"` // 算力 / 分钟
+ Aichat int64 `json:"aichat"` // 算力 / 100 字
+ Gate bool `json:"gate"` // 算力闸门
+
+ // 运营参数:允许配 0(= 不送 / 不提示),所以校验用 >= 0 而不是 > 0。
+ NewUserGiftVipDays int32 `json:"newuser_gift_vipdays"` // 开户礼 VIP 天数
+ NewUserGiftCompute int64 `json:"newuser_gift_compute"` // 开户礼算力
+ VipWarnDays int32 `json:"vip_warn_days"` // VIP 到期前多少天提示
+ ComputeWarn int64 `json:"compute_warn"` // 算力低于多少提示
+}
+
+func (this *serverComp) appConnFromCtx(c *gin.Context) (mysql.ISys, bool) {
+ appId := parseAppId(c.GetHeader("X-App-Id"))
+ if !this.requireAppScope(c, appId) {
+ return nil, false
+ }
+ conn, err := this.module.registry.getServiceDB(appId)
+ if err != nil {
+ writeErr(c, pb.ErrorCode_DBError, err.Error())
+ return nil, false
+ }
+ return conn, true
+}
+
+// getComputeConfig 读选中应用的算力换算系数与闸门。
+func (this *serverComp) getComputeConfig(c *gin.Context) {
+ conn, ok := this.appConnFromCtx(c)
+ if !ok {
+ return
+ }
+ items := make([]*pb.DBAppConfigItem, 0, 4)
+ if err := conn.Find(comm.TableAppConfig, &items, "`key` IN ?", computeConfigKeys); err != nil && !errors.Is(err, mysql.ErrNoDocuments) {
+ writeErr(c, pb.ErrorCode_DBError, err.Error())
+ return
+ }
+ kv := map[string]string{}
+ for _, it := range items {
+ kv[it.Key] = it.Value
+ }
+ r := comm.ParseComputeRates(kv)
+ o := comm.ParseOpsParams(kv)
+ writeOK(c, &computeRatesView{
+ Translate: r.Translate, Meeting: r.Meeting, Aichat: r.AIChat, Gate: r.Gate,
+ NewUserGiftVipDays: o.NewUserGiftVipDays, NewUserGiftCompute: o.NewUserGiftCompute,
+ VipWarnDays: o.VipWarnDays, ComputeWarn: o.ComputeWarn,
+ })
+}
+
+// saveComputeConfig 保存系数与闸门(仅超管/管理员)。
+//
+// ⚠️ 闸门开启前必须先上线预扣:现在 user_usages 是用完才上报,余额只剩 1 的用户仍可先用满再报——
+// 开关关着时无所谓(反正放行),开了而没预扣等于形同虚设。前端在开关旁有提示,这里只做值校验。
+func (this *serverComp) saveComputeConfig(c *gin.Context) {
+ if idt := currentIdentity(c); idt != pb.Identity_Admin && idt != pb.Identity_Manager {
+ writeErr(c, pb.ErrorCode_InsufficientPermissions, "仅超管/管理员可修改算力配置")
+ return
+ }
+ conn, ok := this.appConnFromCtx(c)
+ if !ok {
+ return
+ }
+ var req computeRatesView
+ if err := c.ShouldBindJSON(&req); err != nil {
+ writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
+ return
+ }
+ const rateMax = 1_000_000
+ for name, v := range map[string]int64{"translate": req.Translate, "meeting": req.Meeting, "aichat": req.Aichat} {
+ if v <= 0 || v > rateMax {
+ writeErr(c, pb.ErrorCode_ReqParameterError, fmt.Sprintf("换算系数 %s 需在 1~%d 之间", name, rateMax))
+ return
+ }
+ }
+ // 运营参数允许为 0(不送 / 不提示),只挡负数与离谱的大值
+ if req.NewUserGiftVipDays < 0 || req.NewUserGiftVipDays > 3650 {
+ writeErr(c, pb.ErrorCode_ReqParameterError, "开户礼 VIP 天数需在 0~3650 之间")
+ return
+ }
+ if req.NewUserGiftCompute < 0 || req.NewUserGiftCompute > 10_000_000 {
+ writeErr(c, pb.ErrorCode_ReqParameterError, "开户礼算力需在 0~10000000 之间")
+ return
+ }
+ if req.VipWarnDays < 0 || req.VipWarnDays > 365 {
+ writeErr(c, pb.ErrorCode_ReqParameterError, "VIP 到期提示天数需在 0~365 之间")
+ return
+ }
+ if req.ComputeWarn < 0 || req.ComputeWarn > 10_000_000 {
+ writeErr(c, pb.ErrorCode_ReqParameterError, "算力提示阈值需在 0~10000000 之间")
+ return
+ }
+ r := comm.ComputeRates{Translate: req.Translate, Meeting: req.Meeting, AIChat: req.Aichat, Gate: req.Gate}
+ o := comm.OpsParams{
+ NewUserGiftVipDays: req.NewUserGiftVipDays, NewUserGiftCompute: req.NewUserGiftCompute,
+ VipWarnDays: req.VipWarnDays, ComputeWarn: req.ComputeWarn,
+ }
+ kvAll := r.ToKV()
+ for k, v := range o.ToKV() {
+ kvAll[k] = v
+ }
+ for k, v := range kvAll {
+ item := &pb.DBAppConfigItem{}
+ err := conn.FindOne(comm.TableAppConfig, item, "`key`=?", k)
+ switch {
+ case errors.Is(err, mysql.ErrNoDocuments):
+ item = &pb.DBAppConfigItem{Group: "compute", Key: k, Value: v, Description: computeConfigDesc(k)}
+ if err = conn.Insert(comm.TableAppConfig, item); err != nil {
+ writeErr(c, pb.ErrorCode_DBError, err.Error())
+ return
+ }
+ case err != nil:
+ writeErr(c, pb.ErrorCode_DBError, err.Error())
+ return
+ case item.Value != v:
+ if err = conn.Table(comm.TableAppConfig).Where("`key`=?", k).Update("value", v).Error; err != nil {
+ writeErr(c, pb.ErrorCode_DBError, err.Error())
+ return
+ }
+ }
+ }
+ // 让业务进程立即重载应用配置(env 里的系数是从它的缓存下发的);服务端换算本身走 30s TTL 缓存。
+ broadcastConfigChanged(comm.ConfigKindGlobalConfig, "update", 0)
+ writeOK(c, &req)
+}
+
+func computeConfigDesc(k string) string {
+ switch k {
+ case comm.ConfigKeyComputeRateTranslate:
+ return "算力换算:翻译,算力/分钟"
+ case comm.ConfigKeyComputeRateMeeting:
+ return "算力换算:会议纪要,算力/分钟"
+ case comm.ConfigKeyComputeRateAIChat:
+ return "算力换算:AI对话,算力/100字"
+ case comm.ConfigKeyComputeGate:
+ return "算力闸门:1=余额不足即拒绝,0=只记账不拦(开启前须先上线预扣)"
+ case comm.ConfigKeyNewUserGiftVipDays:
+ return "开户礼:赠送VIP天数(0=不送)"
+ case comm.ConfigKeyNewUserGiftCompute:
+ return "开户礼:赠送算力(0=不送)"
+ case comm.ConfigKeyVipWarnDays:
+ return "提示阈值:VIP到期前多少天开始提示(0=不提示)"
+ case comm.ConfigKeyComputeWarn:
+ return "提示阈值:算力低于多少开始提示(0=不提示)"
+ }
+ return ""
+}
+
+// adjustUserCompute 后台调整用户算力余额(加减),余额不得低于 0,必填备注,写 AdminAdjust 流水。
+func (this *serverComp) adjustUserCompute(c *gin.Context) {
+ if idt := currentIdentity(c); idt != pb.Identity_Admin && idt != pb.Identity_Manager {
+ writeErr(c, pb.ErrorCode_InsufficientPermissions, "仅超管/管理员可调整算力余额")
+ return
+ }
+ conn, ok := this.appConnFromCtx(c)
+ if !ok {
+ return
+ }
+ var req struct {
+ Uid string `json:"uid"`
+ Delta int64 `json:"delta"`
+ Remark string `json:"remark"`
+ }
+ _ = c.ShouldBindJSON(&req)
+ uid := strings.TrimSpace(req.Uid)
+ remark := strings.TrimSpace(req.Remark)
+ if uid == "" || req.Delta == 0 || remark == "" {
+ writeErr(c, pb.ErrorCode_ReqParameterError, "uid、非零的 delta、备注 都必填")
+ return
+ }
+ user := &pb.DBUser{}
+ if err := conn.FindOne(comm.TableUser, user, "uid=?", uid); err != nil {
+ if errors.Is(err, mysql.ErrNoDocuments) {
+ writeErr(c, pb.ErrorCode_UserSessionNobeing, "未找到该用户")
+ return
+ }
+ writeErr(c, pb.ErrorCode_DBError, err.Error())
+ return
+ }
+ if user.Computebalance+req.Delta < 0 {
+ writeErr(c, pb.ErrorCode_ComputeBalanceNegative, fmt.Sprintf("当前余额 %d,调整后将为负", user.Computebalance))
+ return
+ }
+ if err := conn.Table(comm.TableUser).Where("uid=?", uid).
+ Update("computebalance", gorm.Expr("computebalance + ?", req.Delta)).Error; err != nil {
+ writeErr(c, pb.ErrorCode_DBError, err.Error())
+ return
+ }
+ _ = conn.Insert(comm.TableUserUseLog, &pb.DBUserUseLog{
+ Uid: uid,
+ Ts: time.Now().Unix(),
+ Logtype: pb.UserLogType_AdminAdjust,
+ Compute: req.Delta,
+ Extra: fmt.Sprintf("console adjust by account=%d: %s", currentAccountId(c), remark),
+ })
+ writeOK(c, gin.H{"uid": uid, "computebalance": user.Computebalance + req.Delta})
+}
+
+// getUserComputeLogs 用户流水(按时间倒序分页),含发放/消耗/后台调整全部类型,前端按 logtype 与算力列展示。
+func (this *serverComp) getUserComputeLogs(c *gin.Context) {
+ conn, ok := this.appConnFromCtx(c)
+ if !ok {
+ return
+ }
+ var req struct {
+ Uid string `json:"uid"`
+ Page int `json:"page"`
+ PageSize int `json:"page_size"`
+ }
+ _ = c.ShouldBindJSON(&req)
+ uid := strings.TrimSpace(req.Uid)
+ if uid == "" {
+ writeErr(c, pb.ErrorCode_ReqParameterError, "uid 必填")
+ return
+ }
+ if req.Page <= 0 {
+ req.Page = 1
+ }
+ if req.PageSize <= 0 || req.PageSize > 200 {
+ req.PageSize = 50
+ }
+ var total int64
+ if err := conn.Table(comm.TableUserUseLog).Where("uid=?", uid).Count(&total).Error; err != nil {
+ writeErr(c, pb.ErrorCode_DBError, err.Error())
+ return
+ }
+ rows := make([]*pb.DBUserUseLog, 0, req.PageSize)
+ if err := conn.Table(comm.TableUserUseLog).Where("uid=?", uid).
+ Order("ts DESC, id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).
+ Find(&rows).Error; err != nil {
+ writeErr(c, pb.ErrorCode_DBError, err.Error())
+ return
+ }
+ writeOK(c, gin.H{"logs": rows, "total": total, "page": req.Page, "page_size": req.PageSize})
+}
+
+// computeStatsDay 按日消耗。
+type computeStatsDay struct {
+ Day string `json:"day"`
+ Compute int64 `json:"compute"`
+ Users int64 `json:"users"`
+}
+
+// computeStatsKind 按功能消耗(raw 为原始量:翻译/会议为秒,AI 为次)。
+type computeStatsKind struct {
+ Compute int64 `json:"compute"`
+ Raw int64 `json:"raw"`
+ Times int64 `json:"times"`
+}
+
+type computeStatsTop struct {
+ Uid string `json:"uid"`
+ Compute int64 `json:"compute"`
+}
+
+// getComputeStats 算力看板:按日 / 按功能 / 用户 Top N(选中应用业务库流水),以及设备算力发放与消耗总量(console 主库)。
+// 用户 Top N 是**异常用量的发现入口**——额度闸门取消后,单用户成本上不封顶,这里是唯一能看出来的地方。
+func (this *serverComp) getComputeStats(c *gin.Context) {
+ conn, ok := this.appConnFromCtx(c)
+ if !ok {
+ return
+ }
+ var req struct {
+ From int64 `json:"from"` // unix 秒,含
+ To int64 `json:"to"` // unix 秒,含
+ Top int `json:"top"`
+ }
+ _ = c.ShouldBindJSON(&req)
+ now := time.Now()
+ if req.To <= 0 {
+ req.To = now.Unix()
+ }
+ if req.From <= 0 || req.From > req.To {
+ req.From = now.AddDate(0, 0, -30).Unix()
+ }
+ if req.Top <= 0 || req.Top > 100 {
+ req.Top = 20
+ }
+ base := func() *gorm.DB {
+ return conn.Table(comm.TableUserUseLog).
+ Where("logtype=? AND compute<0 AND ts BETWEEN ? AND ?", int32(pb.UserLogType_UserConsume), req.From, req.To)
+ }
+ days := make([]*computeStatsDay, 0, 31)
+ if err := base().
+ Select("FROM_UNIXTIME(ts,'%Y-%m-%d') AS day, COALESCE(SUM(-compute),0) AS compute, COUNT(DISTINCT uid) AS users").
+ Group("day").Order("day").Scan(&days).Error; err != nil {
+ writeErr(c, pb.ErrorCode_DBError, err.Error())
+ return
+ }
+ var kinds struct {
+ TranslateCompute, TranslateRaw, TranslateTimes int64
+ MeetingCompute, MeetingRaw, MeetingTimes int64
+ AichatCompute, AichatRaw, AichatTimes int64
+ }
+ if err := base().Select(
+ "COALESCE(SUM(CASE WHEN addtradesecond<0 THEN -compute ELSE 0 END),0) AS translate_compute," +
+ "COALESCE(SUM(CASE WHEN addtradesecond<0 THEN -addtradesecond ELSE 0 END),0) AS translate_raw," +
+ "COALESCE(SUM(CASE WHEN addtradesecond<0 THEN 1 ELSE 0 END),0) AS translate_times," +
+ "COALESCE(SUM(CASE WHEN addmeetsecond<0 THEN -compute ELSE 0 END),0) AS meeting_compute," +
+ "COALESCE(SUM(CASE WHEN addmeetsecond<0 THEN -addmeetsecond ELSE 0 END),0) AS meeting_raw," +
+ "COALESCE(SUM(CASE WHEN addmeetsecond<0 THEN 1 ELSE 0 END),0) AS meeting_times," +
+ "COALESCE(SUM(CASE WHEN addagentintegral<0 THEN -compute ELSE 0 END),0) AS aichat_compute," +
+ "COALESCE(SUM(CASE WHEN addagentintegral<0 THEN -addagentintegral ELSE 0 END),0) AS aichat_raw," +
+ "COALESCE(SUM(CASE WHEN addagentintegral<0 THEN 1 ELSE 0 END),0) AS aichat_times").
+ Scan(&kinds).Error; err != nil {
+ writeErr(c, pb.ErrorCode_DBError, err.Error())
+ return
+ }
+ top := make([]*computeStatsTop, 0, req.Top)
+ if err := base().Select("uid, COALESCE(SUM(-compute),0) AS compute").
+ Group("uid").Order("compute DESC").Limit(req.Top).Scan(&top).Error; err != nil {
+ writeErr(c, pb.ErrorCode_DBError, err.Error())
+ return
+ }
+ // 设备算力总量(console 主库,跨应用;product 才按应用归属,这里不细分)
+ var dev struct{ Grant, Used, Devices int64 }
+ if adb := consoleDeviceConn().AdminDB(); adb != nil {
+ if err := adb.Table(comm.TableDeviceMac).
+ Select("COALESCE(SUM(computegrant),0) AS grant, COALESCE(SUM(computeused),0) AS used, COUNT(*) AS devices").
+ Where("usedtime>0 AND computegrant>0").Scan(&dev).Error; err != nil {
+ log.Warn("console: 设备算力汇总失败", log.Field{Key: "err", Value: err.Error()})
+ }
+ }
+ writeOK(c, gin.H{
+ "from": req.From, "to": req.To,
+ "days": days,
+ "kinds": gin.H{
+ "translate": &computeStatsKind{Compute: kinds.TranslateCompute, Raw: kinds.TranslateRaw, Times: kinds.TranslateTimes},
+ "meeting": &computeStatsKind{Compute: kinds.MeetingCompute, Raw: kinds.MeetingRaw, Times: kinds.MeetingTimes},
+ "aichat": &computeStatsKind{Compute: kinds.AichatCompute, Raw: kinds.AichatRaw, Times: kinds.AichatTimes},
+ },
+ "top": top,
+ "device": gin.H{"grant": dev.Grant, "used": dev.Used, "left": dev.Grant - dev.Used, "devices": dev.Devices},
+ })
+}
+
+// userComputeSummary 用户详情里的会员与算力附加信息:设备赋予的 VIP / 算力按当前绑定关系跨库取。
+func userComputeSummary(conn mysql.ISys, user *pb.DBUser) (out gin.H) {
+ uds := make([]*pb.DBUserDivice, 0, 2)
+ _ = conn.Find(comm.TableUserdevice, &uds, "uid=?", user.Uid)
+ devs := make([]*pb.DBAuthCode, 0, len(uds))
+ views := make([]gin.H, 0, len(uds))
+ var deviceLeft int64
+ if adb := consoleDeviceConn().AdminDB(); adb != nil {
+ seen := map[string]bool{}
+ for _, ud := range uds {
+ mac := strings.TrimSpace(ud.Devicemac)
+ if mac == "" {
+ continue
+ }
+ if n, ok := devcode.NormalizeMac(mac); ok {
+ mac = n
+ }
+ if seen[mac] {
+ continue
+ }
+ seen[mac] = true
+ d := &pb.DBAuthCode{}
+ if err := adb.FindOne(comm.TableDeviceMac, d, "devicemac=?", mac); err != nil || d.Disabled != 0 {
+ continue
+ }
+ devs = append(devs, d)
+ deviceLeft += comm.DeviceComputeLeft(d)
+ views = append(views, gin.H{
+ "devicemac": d.Devicemac,
+ "devicename": ud.Devicename,
+ "usedtime": d.Usedtime,
+ "vipdays": d.Vipdays,
+ "viplevel": comm.DeviceVipLevel(d),
+ "vipexptime": comm.DeviceVipExpire(d),
+ "computegrant": d.Computegrant,
+ "computeused": d.Computeused,
+ "computeleft": comm.DeviceComputeLeft(d),
+ })
+ }
+ }
+ vip := comm.ResolveVip(user, devs, time.Now().Unix())
+ return gin.H{
+ "vip_effective": gin.H{
+ "level": vip.Level,
+ "exptime": vip.ExpTime,
+ "selfexptime": vip.SelfExpTime,
+ "fromdevice": vip.FromDevice,
+ },
+ "compute_devices": views,
+ "compute_deviceleft": deviceLeft,
+ }
+}
diff --git a/apps/services/modules/console/api_config.go b/apps/services/modules/console/api_config.go
index 7741a5e8..72f60087 100644
--- a/apps/services/modules/console/api_config.go
+++ b/apps/services/modules/console/api_config.go
@@ -120,6 +120,10 @@ func ensureConfigTables() error {
if err := pg.CreateTable(comm.TableAppGoods, &AppGoods{}); err != nil {
return err
}
+ // 2026-09 会员与算力:商品改发「VIP 天数 + 算力」。CreateTable 对已存在的表跳过 AutoMigrate,故显式补列。
+ if res := pg.Exec("ALTER TABLE " + comm.TableAppGoods + " ADD COLUMN IF NOT EXISTS compute bigint DEFAULT 0"); res.Error != nil {
+ return res.Error
+ }
// 一次性迁移:老表数据 → 新表 app_name='' 全局作用域(新表为空且老表存在时)。
migrateLegacyConfigToScoped()
// 一次性迁移:独立 mcp 表 → svc_config 的 MCP 服务类型(含区域覆盖)。
diff --git a/apps/services/modules/console/api_device.go b/apps/services/modules/console/api_device.go
index 2f96d4f9..963a1788 100644
--- a/apps/services/modules/console/api_device.go
+++ b/apps/services/modules/console/api_device.go
@@ -18,7 +18,9 @@ import (
// 批次绑定赠送的上限(防手滑,不是业务硬约束)。
const (
batchRewardVipDayMax uint32 = 3650 // 10 年
- batchRewardMinuteMax uint32 = 525600 // 1 年的分钟数
+ batchRewardMinuteMax uint32 = 525600 // 【已停用】翻译/会议分钟数不再作为赠送项
+ batchRewardVipLevelMax int32 = 2 // 1=VIP 2=VIP-Pro
+ batchRewardComputeMax int64 = 10_000_000
)
// defaultBrandContactMail 品牌商联系邮箱缺省必含项(与 modules/api 一致)。
@@ -78,12 +80,16 @@ func (this *serverComp) updateProduct(c *gin.Context, sys *appConn) {
// 品牌商 / 渠道方 / 绑定奖励 / 功能开关已从产品表单移除,前端不再回传这些字段。
// dvUpdateProduct 是全字段 Save,不显式保留就会被抹成 0/false——那会让存量产品的
// 绑定奖励失效、客户端功能开关集体关掉(这些值仍在下发给 App,见 user_getappconfig)。
+ // ⚠️ Scanuuid 不在此列:它已于 2026-09-08 整体废弃,pb 里改成 gorm:"-" 的兼容字段,
+ // 不建列也不参与读写,Save 碰不到它。
req.Product.Brandid = old.Brandid
req.Product.Channel = old.Channel
req.Product.Channelextra = old.Channelextra
req.Product.Bindrewardvpitime = old.Bindrewardvpitime
req.Product.Bindrewardtranslate = old.Bindrewardtranslate
req.Product.Bindrewardmeeting = old.Bindrewardmeeting
+ req.Product.Bindviplevel = old.Bindviplevel // 2026-09 会员与算力:批次未配置时的回退值,产品表单同样不回传
+ req.Product.Bindcompute = old.Bindcompute
req.Product.Iscanbindagain = old.Iscanbindagain
req.Product.Iscontinuouschat = old.Iscontinuouschat
req.Product.Eqenable = old.Eqenable
@@ -720,7 +726,7 @@ func (this *serverComp) createAuthCodes(c *gin.Context, sys *appConn) {
writeErr(c, pb.ErrorCode_ReqParameterError, "分成比例需在 0~10000(万分比) 之间")
return
}
- if msg := checkBatchRewards(req.Rewardvip, req.Rewardtranslate, req.Rewardmeeting); msg != "" {
+ if msg := checkBatchRewards(req.Rewardvip, req.Viplevel, req.Computegrant); msg != "" {
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
return
}
@@ -802,10 +808,11 @@ func (this *serverComp) createAuthCodes(c *gin.Context, sys *appConn) {
Channelid: channel.Id,
Factoryid: factoryId,
Sharerate: req.Sharerate,
- // 绑定赠送冻结进批次:事后改这里不影响已发出的货,绑定时按设备所属批次发放。
- Rewardvip: req.Rewardvip,
- Rewardtranslate: req.Rewardtranslate,
- Rewardmeeting: req.Rewardmeeting,
+ // 绑定赠送冻结进批次:事后改这里不影响已发出的货,设备激活时按所属批次把三项快照到设备行。
+ // 2026-09 起只有「VIP 天数 + VIP 等级 + 算力」,翻译/会议分钟数两列不再写入。
+ Rewardvip: req.Rewardvip,
+ Viplevel: normalizeVipLevel(req.Viplevel),
+ Computegrant: req.Computegrant,
}
if err = dvAddProductionBatch(sys, batch); err != nil { // Insert 回填 batch.Id
writeErr(c, pb.ErrorCode_DBError, err.Error())
@@ -1007,23 +1014,28 @@ func (this *serverComp) restFactoryDevics(c *gin.Context, sys *appConn) {
writeOK(c, &pb.ApiRestUserDevicsResp{})
}
-// checkBatchRewards 校验批次的绑定赠送三项。返回空串表示通过。
-//
-// 上限不是技术限制,是防手滑:VIP 天数多打一个 0 就是 3650 天永久会员,
-// 而授权码一旦生产出去就发给工厂了,撤不回来。
-func checkBatchRewards(vipDays, translateMin, meetingMin uint32) string {
+// checkBatchRewards 校验批次的绑定赠送三项(VIP 天数 / VIP 等级 / 算力)。返回空串表示通过。
+func checkBatchRewards(vipDays uint32, vipLevel int32, compute int64) string {
if vipDays > batchRewardVipDayMax {
return fmt.Sprintf("赠送VIP天数不能超过 %d 天", batchRewardVipDayMax)
}
- if translateMin > batchRewardMinuteMax {
- return fmt.Sprintf("赠送翻译时长不能超过 %d 分钟", batchRewardMinuteMax)
+ if vipLevel < 0 || vipLevel > batchRewardVipLevelMax {
+ return fmt.Sprintf("VIP等级只能是 1(VIP) 或 2(VIP-Pro),得到 %d", vipLevel)
}
- if meetingMin > batchRewardMinuteMax {
- return fmt.Sprintf("赠送会议时长不能超过 %d 分钟", batchRewardMinuteMax)
+ if compute < 0 || compute > batchRewardComputeMax {
+ return fmt.Sprintf("赠送算力需在 0~%d 之间", batchRewardComputeMax)
}
return ""
}
+// normalizeVipLevel 没填等级时按 1(VIP) 处理,避免批次表里出现「有天数没等级」的行。
+func normalizeVipLevel(lv int32) int32 {
+ if lv <= 0 {
+ return 1
+ }
+ return lv
+}
+
// normalizeImportMacs 规范化并校验导入模式的 MAC 列表。返回 (规范化后的列表, 错误提示)。
//
// MAC 现在就是授权码分表的主键,所以三件事必须在写库之前挡住,否则代价很高:
diff --git a/apps/services/modules/console/api_goods.go b/apps/services/modules/console/api_goods.go
index 7526d19a..8aa55518 100644
--- a/apps/services/modules/console/api_goods.go
+++ b/apps/services/modules/console/api_goods.go
@@ -45,6 +45,7 @@ type appGoodsReq struct {
Meetnum int64 `json:"meetnum"`
Tradenum int64 `json:"tradenum"`
Viptime int32 `json:"viptime"`
+ Compute int64 `json:"compute"`
Locales map[string]*GoodsLocale `json:"locales"`
Sort int32 `json:"sort"`
Remark string `json:"remark"`
@@ -109,6 +110,7 @@ func applyGoodsReq(m *AppGoods, req *appGoodsReq, localname string) {
m.Meetnum = req.Meetnum
m.Tradenum = req.Tradenum
m.Viptime = req.Viptime
+ m.Compute = req.Compute
m.Localname = localname
m.Sort = req.Sort
m.Remark = strings.TrimSpace(req.Remark)
diff --git a/apps/services/modules/console/registry.go b/apps/services/modules/console/registry.go
index 4a3035b6..3e5bcdd3 100644
--- a/apps/services/modules/console/registry.go
+++ b/apps/services/modules/console/registry.go
@@ -89,6 +89,15 @@ var serviceColumns = []serviceColumn{
{comm.TablePayOrder, &pb.DBPayOrder{}, "src_productid"},
{comm.TablePayOrder, &pb.DBPayOrder{}, "src_brandid"},
{comm.TablePayOrder, &pb.DBPayOrder{}, "src_channelid"},
+ // 2026-09 会员与算力(业务服务启动会 AutoMigrate,这里只为 console 先升级的窗口兜底)
+ {comm.TableUser, &pb.DBUser{}, "computebalance"},
+ {comm.TableUserUseLog, &pb.DBUserUseLog{}, "compute"},
+ {comm.TableUserUseLog, &pb.DBUserUseLog{}, "computedevice"},
+ {comm.TableUserUseLog, &pb.DBUserUseLog{}, "computeuser"},
+ {comm.TableUserUseLog, &pb.DBUserUseLog{}, "computeover"},
+ {comm.TableUserUseLog, &pb.DBUserUseLog{}, "devicemac"},
+ {comm.TableUserStatistics, &pb.DBUserStatistics{}, "computetotal"},
+ {comm.TableGoods, &pb.DBGoods{}, "compute"},
}
// ensureServiceColumns 幂等补齐业务库缺失的列。全程 best-effort:
@@ -226,6 +235,9 @@ func ensureDeviceTables() error {
"ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS rewardvip bigint DEFAULT 0",
"ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS rewardtranslate bigint DEFAULT 0",
"ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS rewardmeeting bigint DEFAULT 0",
+ // 2026-09 会员与算力:批次只配「VIP 天数(rewardvip 复用) + VIP 等级 + 赠送算力」,翻译/会议两列不再读写。
+ "ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS viplevel integer DEFAULT 1",
+ "ALTER TABLE " + comm.TableProductionBatch + " ADD COLUMN IF NOT EXISTS computegrant bigint DEFAULT 0",
} {
if res := adb.Exec(ddl); res.Error != nil {
return res.Error
@@ -253,6 +265,19 @@ func ensureDeviceTables() error {
"ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS description text DEFAULT ''",
// pb 里 providerid 带 gorm index;建表走 AutoMigrate 会自动建,补列这条路要自己补上
"CREATE INDEX IF NOT EXISTS idx_product_providerid ON " + comm.TableProduct + " (providerid)",
+ // devicetype 已于 2026-09-08 废弃并删列:服务端没有任何逻辑读它,后台表单也去掉了这一项。
+ // pb 里那个 Devicetype 已改成 gorm:"-" 的老客户端兼容字段(恒为 0,只为老 App 包能解析),
+ // 新库根本不会建这一列,这条只负责把存量库里的旧列清掉。幂等。
+ // ⚠️ 不可逆:列里的历史取值执行后即消失。它不参与任何判断,故不做备份。
+ "ALTER TABLE " + comm.TableProduct + " DROP COLUMN IF EXISTS devicetype",
+ // 2026-09 会员与算力:批次未配置时的回退(bindrewardvpitime 复用为 VIP 天数)。
+ "ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS bindviplevel integer DEFAULT 1",
+ "ALTER TABLE " + comm.TableProduct + " ADD COLUMN IF NOT EXISTS bindcompute bigint DEFAULT 0",
+ // scanuuid 已于 2026-09-08 废弃并删列:设备校验早就改成按 MAC 走(device_mac 全局表),
+ // 绑定奖励也是按 MAC 判「这台设备有没有被绑过」,跟这个 UUID 没有任何关系。
+ // pb 里的 Scanuuid 同样改成了 gorm:"-" 的老客户端兼容字段(恒为空串,只为老 App 包能解析)。
+ // ⚠️ 不可逆:列里的历史取值执行后即消失。它不参与任何判断,故不做备份。
+ "ALTER TABLE " + comm.TableProduct + " DROP COLUMN IF EXISTS scanuuid",
} {
if res := adb.Exec(ddl); res.Error != nil {
return res.Error
@@ -271,6 +296,24 @@ func ensureDeviceTables() error {
if err := adb.CreateTable(comm.TableDeviceMac, &pb.DBAuthCode{}); err != nil {
return err
}
+ // 2026-09 会员与算力:设备行上的权益快照(激活时从批次固化)。CreateTable 对已存在的表跳过
+ // AutoMigrate,故显式补列。随后把**已激活**的存量设备按其批次回填一次——设备 VIP 期是
+ // [usedtime, usedtime+vipdays),与首绑用户当年拿到的那段 VIP 基本重合,不算二次赠送;
+ // 但转手后的下一个绑定者从此能接着享有剩余天数与剩余算力。WHERE 里的全 0 条件保证幂等。
+ for _, ddl := range []string{
+ "ALTER TABLE " + comm.TableDeviceMac + " ADD COLUMN IF NOT EXISTS vipdays integer DEFAULT 0",
+ "ALTER TABLE " + comm.TableDeviceMac + " ADD COLUMN IF NOT EXISTS viplevel integer DEFAULT 0",
+ "ALTER TABLE " + comm.TableDeviceMac + " ADD COLUMN IF NOT EXISTS computegrant bigint DEFAULT 0",
+ "ALTER TABLE " + comm.TableDeviceMac + " ADD COLUMN IF NOT EXISTS computeused bigint DEFAULT 0",
+ "UPDATE " + comm.TableDeviceMac + " d SET vipdays = b.rewardvip, viplevel = GREATEST(COALESCE(b.viplevel,1),1), computegrant = COALESCE(b.computegrant,0)" +
+ " FROM " + comm.TableProductionBatch + " b" +
+ " WHERE d.batchno = b.batchno AND d.usedtime > 0 AND d.vipdays = 0 AND d.computegrant = 0" +
+ " AND (b.rewardvip > 0 OR COALESCE(b.computegrant,0) > 0)",
+ } {
+ if res := adb.Exec(ddl); res.Error != nil {
+ return res.Error
+ }
+ }
var products []*pb.DBProduct
if err := adb.Find(comm.TableProduct, &products, ""); err != nil && err != postgres.ErrNoDocuments {
return err
diff --git a/apps/services/modules/console/server.go b/apps/services/modules/console/server.go
index a75eca68..26f6dde6 100644
--- a/apps/services/modules/console/server.go
+++ b/apps/services/modules/console/server.go
@@ -493,6 +493,22 @@ func (this *serverComp) handleWeb(c *gin.Context) {
case "api_giftuserresource":
this.giftUserResource(c)
return
+ // 会员与算力(2026-09):换算系数/闸门存选中应用业务库 config 表;余额调整与流水在业务库 user/useruselog。
+ case "api_getcomputeconfig":
+ this.getComputeConfig(c)
+ return
+ case "api_savecomputeconfig":
+ this.saveComputeConfig(c)
+ return
+ case "api_adjustusercompute":
+ this.adjustUserCompute(c)
+ return
+ case "api_getusercomputelogs":
+ this.getUserComputeLogs(c)
+ return
+ case "api_getcomputestats":
+ this.getComputeStats(c)
+ return
}
// 设备管理域:厂家/产品/设备码/公码/出货单都在 console 主库(supabase),无需选应用。
@@ -867,10 +883,11 @@ func (this *serverComp) delBrand(c *gin.Context, sys *appConn) {
func (this *serverComp) getAuthCodes(c *gin.Context, sys *appConn) {
var req pb.ApiGetAuthCodesReq
_ = c.ShouldBindJSON(&req)
- if req.Productid == 0 {
- writeErr(c, pb.ErrorCode_ReqParameterError, "productid 不能为空")
- return
- }
+ // productid == 0 = 「全部产品」视图(后台 MAC查询 的默认状态)。
+ // device_mac 合表(2026-09-04)之前必须带 pid 才知道查哪张分表,现在是全局一张表,
+ // dvAuthCodesPaged → comm.ScopeToProduct(0,…) 会原样放行不拼 productid 条件。
+ // ⚠️ 这里不再拦,意味着调用方能一次拉到全部产品的设备;按产品收敛的责任落在
+ // 前端下拉(产品受限的运营账号不给「全部产品」这一项)与页面权限上。
var (
conds = make([]string, 0, 7)
args []interface{}
@@ -1174,6 +1191,11 @@ func (this *serverComp) getUserInfo(c *gin.Context) {
// 明细查不到不影响主查询,记日志即可。
this.module.Errorln("getUserInfo: 查实名明细失败:", err)
}
+ // 会员与算力(2026-09):user.vipexptime 只记自购;设备赋予的 VIP 与算力挂在 device_mac 行上,
+ // 这里按当前绑定关系跨库拼出「有效 VIP」与各设备的权益快照。
+ for k, v := range userComputeSummary(conn, user) {
+ out[k] = v
+ }
writeOK(c, out)
}
@@ -1253,14 +1275,9 @@ func (this *serverComp) giftUserResource(c *gin.Context) {
user.Vipexptime += addVipSec
}
}
- if addTradeSec > 0 {
- user.Tradeintegral += addTradeSec
- user.Tradetotalintegral += addTradeSec
- }
- if addMeetSec > 0 {
- user.Meetintegral += addMeetSec
- user.Meettotalintegral += addMeetSec
- }
+ // 2026-09 起三个额度桶停用:翻译分 / 会议分按当前换算系数折成算力发到用户余额。
+ addCompute := comm.LegacyBucketsToCompute(comm.LoadComputeRates(), addTradeSec, addMeetSec, 0)
+ user.Computebalance += addCompute
if err := conn.Save(comm.TableUser, user); err != nil {
// 代理:写库失败回滚已扣余额,避免用户没到账却扣了代理。
if identity == pb.Identity_Agent {
@@ -1273,20 +1290,17 @@ func (this *serverComp) giftUserResource(c *gin.Context) {
_ = conn.Insert(comm.TableUserUseLog, &pb.DBUserUseLog{
Uid: uid,
Ts: now,
- Logtype: pb.UserLogType_AdminGive,
- Addvipday: req.Vipday,
- Addtradesecond: addTradeSec,
- Addmeetsecond: addMeetSec,
- Extra: fmt.Sprintf("console gift by identity=%d account=%d", int32(identity), accId),
+ Logtype: pb.UserLogType_AdminGive,
+ Addvipday: req.Vipday,
+ Compute: addCompute,
+ Extra: fmt.Sprintf("console gift by identity=%d account=%d", int32(identity), accId),
})
resp := gin.H{
- "uid": uid,
- "vipexptime": user.Vipexptime,
- "tradeintegral": user.Tradeintegral,
- "meetintegral": user.Meetintegral,
- "tradetotalintegral": user.Tradetotalintegral,
- "meettotalintegral": user.Meettotalintegral,
+ "uid": uid,
+ "vipexptime": user.Vipexptime,
+ "computebalance": user.Computebalance,
+ "addcompute": addCompute,
}
// 代理:回传扣减后的最新余额,前端据此刷新侧边栏。
if identity == pb.Identity_Agent {
diff --git a/apps/services/modules/echomeet/api_starttask.go b/apps/services/modules/echomeet/api_starttask.go
index 4e906edd..8fc126e3 100644
--- a/apps/services/modules/echomeet/api_starttask.go
+++ b/apps/services/modules/echomeet/api_starttask.go
@@ -2,6 +2,7 @@ package echomeet
import (
"yunyan/comm"
+ "yunyan/lego/sys/mysql"
"yunyan/pb"
"encoding/json"
"time"
@@ -46,20 +47,38 @@ func (this *apiComp) StartTask(session comm.IUserSession, req *pb.EchomeetStartT
}
return
}
- if user.Meetintegral < int64(model.Seconds) {
+ // ── 准入:会议纪要需要 VIP(自购 或 当前绑定设备赋予,见 comm.ResolveVip)──
+ if vip, _, verr := comm.ResolveUserVip(user.Uid, user); verr == nil && !vip.Active(time.Now().Unix()) {
errdata = &pb.ErrorData{
- Code: pb.ErrorCode_MeetIntegralNotEnough,
- Message: "积分不足",
+ Code: pb.ErrorCode_VipRequired,
+ Message: "会议纪要需要会员,请续费或绑定带会员的设备",
}
return
}
-
- user.Meetintegral -= int64(model.Seconds)
- err = this.module.model.updateuser(user)
- if err != nil {
+ // ── 计量:会议时长服务端自己知道(model.Seconds),按换算系数折成算力记账。
+ // 先设备赠送、再用户余额;闸门默认关,不够也放行、差额记超额。──
+ statistics, sErr := this.module.model.getStatistics(session.GetUserId())
+ if sErr != nil && sErr != mysql.ErrNoDocuments {
+ errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: sErr.Error()}
+ return
+ }
+ statistics.Meetnum += 1
+ statistics.Meettime += int64(model.Seconds)
+ userlog := &pb.DBUserUseLog{
+ Uid: session.GetUserId(),
+ Logtype: pb.UserLogType_UserConsume,
+ Ts: time.Now().Unix(),
+ Addmeetsecond: -1 * int64(model.Seconds),
+ }
+ res, cerr := comm.ApplyComputeUsage(user.Uid, comm.ComputeUsageMeeting, int64(model.Seconds), userlog, statistics)
+ if cerr != nil {
+ errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: cerr.Error()}
+ return
+ }
+ if res.Rejected {
errdata = &pb.ErrorData{
- Code: pb.ErrorCode_DBError,
- Message: err.Error(),
+ Code: pb.ErrorCode_ComputeNotEnough,
+ Message: "算力不足",
}
return
}
@@ -73,22 +92,11 @@ func (this *apiComp) StartTask(session comm.IUserSession, req *pb.EchomeetStartT
Second: int64(model.Seconds),
})
}
-
- // 同步更新累计统计(排行榜依赖此表)
- if statistics, sErr := this.module.model.getStatistics(session.GetUserId()); sErr == nil {
- statistics.Meetnum += 1
- statistics.Meettime += int64(model.Seconds)
- if uErr := this.module.model.updateStatistics(statistics); uErr != nil {
- this.module.Warnf("StartTask id:%d update userstatistics failed: %v", model.Id, uErr)
- }
+ // 同步更新累计统计(排行榜依赖此表)+ 消费日志(供历史重建扫描)
+ if uErr := this.module.model.updateStatistics(statistics); uErr != nil {
+ this.module.Warnf("StartTask id:%d update userstatistics failed: %v", model.Id, uErr)
}
- // 写消费日志(供历史重建扫描)
- _ = this.module.model.addUseLog(&pb.DBUserUseLog{
- Uid: session.GetUserId(),
- Logtype: pb.UserLogType_UserConsume,
- Ts: time.Now().Unix(),
- Addmeetsecond: -1 * int64(model.Seconds),
- })
+ _ = this.module.model.addUseLog(userlog)
tmpl, err := this.module.cache.ResolveTemplate(req.Tid, req.Templateid, req.Tolanguage)
if err != nil {
diff --git a/apps/services/modules/pay/api.go b/apps/services/modules/pay/api.go
index c63eb9e6..18c67b1f 100644
--- a/apps/services/modules/pay/api.go
+++ b/apps/services/modules/pay/api.go
@@ -40,15 +40,19 @@ func (this *apiComp) Start() (err error) {
// (pb.DBGoods),v2 口径读**管理后台的商品配置**(console 公共库 app_goods,按应用名隔离)——
// 但发放、记账、埋点是同一套,抽出来共用。
type goodsGrant struct {
- Viptime int32
- Ainum int64
- Meetnum int64
- Tradenum int64
+ Viptime int32
+ Compute int64
}
-// grantOfAppGoods 把后台商品配置换算成发放内容(v2 口径)。
+// grantOfAppGoods 把后台商品配置换算成发放内容。
+// 2026-09 起商品只发「VIP 天数 + 算力」;老商品没配 compute(=0)而配了旧三桶时,按当前换算系数折成算力,
+// 保证历史商品照常可卖、买到的东西不缩水。
func grantOfAppGoods(g *comm.AppGoods) *goodsGrant {
- return &goodsGrant{Viptime: g.Viptime, Ainum: g.Ainum, Meetnum: g.Meetnum, Tradenum: g.Tradenum}
+ c := g.Compute
+ if c == 0 {
+ c = comm.LegacyBucketsToCompute(comm.LoadComputeRates(), g.Tradenum, g.Meetnum, g.Ainum)
+ }
+ return &goodsGrant{Viptime: g.Viptime, Compute: c}
}
// deliverResourceByOrder 按订单给用户发货:商品取自**业务库 goods 表**(历史口径,
@@ -69,7 +73,11 @@ func (this *apiComp) deliverResourceByOrder(user *pb.DBUser, order *pb.DBPayOrde
var grant *goodsGrant
for _, v := range goodss {
if v.Id == order.ProductId {
- grant = &goodsGrant{Viptime: v.Viptime, Ainum: v.Ainum, Meetnum: v.Meetnum, Tradenum: v.Tradenum}
+ c := v.Compute
+ if c == 0 {
+ c = comm.LegacyBucketsToCompute(comm.LoadComputeRates(), v.Tradenum, v.Meetnum, v.Ainum)
+ }
+ grant = &goodsGrant{Viptime: v.Viptime, Compute: c}
}
}
return this.deliverGrant(user, order, grant)
@@ -109,27 +117,14 @@ func (this *apiComp) deliverGrant(user *pb.DBUser, order *pb.DBPayOrder, grant *
log.Field{Key: "mode", Value: mode},
log.Field{Key: "Vipexptime", Value: user.Vipexptime})
}
- if v.Ainum > 0 {
- user.Aichatintegral += v.Ainum
- user.Aichattotalintegral = user.Aichatintegral
- userlog.Addagentintegral = v.Ainum
- this.module.Debug("支付发货", log.Field{Key: "Aichatintegral", Value: user.Aichatintegral})
- }
- if v.Meetnum > 0 {
- user.Meetintegral += v.Meetnum
- user.Meettotalintegral = user.Meetintegral
- userlog.Addmeetsecond = int64(v.Meetnum)
- this.module.Debug("支付发货", log.Field{Key: "Meetintegral", Value: user.Meetintegral})
- }
- if v.Tradenum > 0 {
- user.Tradeintegral += v.Tradenum
- user.Tradetotalintegral = user.Tradeintegral
- userlog.Addtradesecond = int64(v.Tradenum)
- this.module.Debug("支付发货", log.Field{Key: "Tradeintegral", Value: user.Tradeintegral})
+ if v.Compute > 0 {
+ user.Computebalance += v.Compute
+ userlog.Compute = v.Compute
+ this.module.Debug("支付发货", log.Field{Key: "Computebalance", Value: user.Computebalance})
}
- userlog.Extra = fmt.Sprintf(`{"order_id":"%s","product_id":"%s","product_name":"%s","amount":%d,"payment_method":%d,"add_vip_days":%d,"add_ai_integral":%d,"add_trade_second":%d,"add_meet_second":%d}`,
+ userlog.Extra = fmt.Sprintf(`{"order_id":"%s","product_id":"%s","product_name":"%s","amount":%d,"payment_method":%d,"add_vip_days":%d,"add_compute":%d}`,
order.OrderId, order.ProductId, order.ProductName, order.Amount, order.PaymentMethod,
- userlog.Addvipday, userlog.Addagentintegral, userlog.Addtradesecond, userlog.Addmeetsecond)
+ userlog.Addvipday, userlog.Compute)
}
// 2. 更新用户信息
if err = this.module.model.updateUser(user); err != nil {
diff --git a/apps/services/modules/user/api_bindauthcode.go b/apps/services/modules/user/api_bindauthcode.go
index 5208d89f..e810b2ec 100644
--- a/apps/services/modules/user/api_bindauthcode.go
+++ b/apps/services/modules/user/api_bindauthcode.go
@@ -121,10 +121,12 @@ func (this *apiComp) BindAuthCode(session comm.IUserSession, req *pb.UserBindAut
return
}
- // 5) 计算奖励(单位:vip=天,translate/meeting=分钟;存储统一按秒)
+ // 5) 计算奖励:VIP 按天累加到自购到期时间(公码是发给**账号**的,归属用户,与设备无关);
+ // 公码上配的翻译/会议分钟数(旧字段)按当前换算系数折成算力发到用户余额。
addVipDay := int64(code.Bindrewardvpitime)
addTradeSec := int64(code.Bindrewardtranslate) * 60
addMeetSec := int64(code.Bindrewardmeeting) * 60
+ addCompute := comm.LegacyBucketsToCompute(comm.LoadComputeRates(), addTradeSec, addMeetSec, 0)
if addVipDay > 0 {
addVipSec := addVipDay * 24 * 60 * 60
if user.Vipexptime == 0 || user.Vipexptime < now {
@@ -133,14 +135,7 @@ func (this *apiComp) BindAuthCode(session comm.IUserSession, req *pb.UserBindAut
user.Vipexptime += addVipSec
}
}
- if addTradeSec > 0 {
- user.Tradeintegral += addTradeSec
- user.Tradetotalintegral += addTradeSec
- }
- if addMeetSec > 0 {
- user.Meetintegral += addMeetSec
- user.Meettotalintegral += addMeetSec
- }
+ user.Computebalance += addCompute
// 6) 保存用户余额(isactivatecode 已经在步骤 3 提前落库,这里 Save 会覆盖一并写回,是幂等的)
if err = this.module.model.save(user); err != nil {
@@ -151,15 +146,14 @@ func (this *apiComp) BindAuthCode(session comm.IUserSession, req *pb.UserBindAut
return
}
- if addVipDay > 0 || addTradeSec > 0 || addMeetSec > 0 {
+ if addVipDay > 0 || addCompute > 0 {
_ = this.module.model.addIntegralLog(&pb.DBUserUseLog{
- Uid: user.Uid,
- Logtype: pb.UserLogType_ActivityReward,
- Ts: now,
- Addvipday: addVipDay,
- Addtradesecond: addTradeSec,
- Addmeetsecond: addMeetSec,
- Extra: code.Code,
+ Uid: user.Uid,
+ Logtype: pb.UserLogType_ActivityReward,
+ Ts: now,
+ Addvipday: addVipDay,
+ Compute: addCompute,
+ Extra: code.Code,
})
}
diff --git a/apps/services/modules/user/api_binddevice.go b/apps/services/modules/user/api_binddevice.go
index 972de5d4..cce7ae9d 100644
--- a/apps/services/modules/user/api_binddevice.go
+++ b/apps/services/modules/user/api_binddevice.go
@@ -184,7 +184,10 @@ func (this *apiComp) BindDevice(session comm.IUserSession, req *pb.UserBindDevic
// 渠道商归属来自授权码(生产时定下),绑定即固化到用户设备上,
// 后续该用户的充值/消耗埋点都按它归到对应渠道商名下(统计与月结算的依据)。
Channelid: model.Channelid,
- Devicetype: product.Devicetype,
+ // devicetype 不再从产品上抄:产品的「设备类型」已于 2026-09-08 废弃
+ // (pb 里降级成 gorm:"-" 的老客户端兼容字段,恒为 0,见 db.proto)。
+ // userdevice 上这一列暂时保留,存量行的值原样不动,新行为 0(客户端「我的设备」
+ // 页会显示成「未知设备」)。等客户端那边一并改掉分类展示后再删列。
Devicename: req.Devicename,
Devicemac: req.Devicemac,
// 存授权码行的主键(model.Code),不是客户端传上来的 req.Code:
@@ -204,6 +207,11 @@ func (this *apiComp) BindDevice(session comm.IUserSession, req *pb.UserBindDevic
// 历史设备漏记 usedtime 是失误,从此补上;仅在 0→1 首激时写,换绑/接管不覆盖原始激活日。
if codestate == 0 {
model.Usedtime = time.Now().Unix()
+ // 设备权益快照:VIP 天数 / 等级 / 赠送算力,激活这一刻从批次(回退产品)固化到设备行。
+ // 此后改批次配置只影响尚未激活的库存设备。**不写用户表**——设备 VIP 由 usedtime+vipdays 算出,
+ // 设备算力记在 computegrant/computeused 上,谁绑定谁享有剩余部分,解绑无需回收。
+ model.Vipdays, model.Viplevel, model.Computegrant = this.resolveBindRewards(model, product)
+ model.Computeused = 0
}
if err = this.module.model.saveFactoryDevice(model); err != nil {
errdata = &pb.ErrorData{
@@ -222,7 +230,6 @@ func (this *apiComp) BindDevice(session comm.IUserSession, req *pb.UserBindDevic
// 记录最后绑定的产品ID与渠道商:后续充值/消耗埋点按它们归属统计与结算
user.Lastbindproductid = product.Id
user.Lastbindchannelid = model.Channelid
- userSaved := false
// 设备激活 + 绑定埋点。
// 激活是「设备一生一次」的事件:只在 codestate==0 首激时记,换绑/接管不再计——
// 与上面 model.Usedtime 只在 0→1 时写保持同一口径,否则激活数被换绑次数放大。
@@ -236,139 +243,103 @@ func (this *apiComp) BindDevice(session comm.IUserSession, req *pb.UserBindDevic
this.module.Debug("BindDevice",
log.Field{Key: "Isgiveaway", Value: user.Isgiveaway},
log.Field{Key: "Iscanbindagain", Value: product.Iscanbindagain},
- log.Field{Key: "codestate", Value: codestate})
- // 设备权益只发给**第一个绑定这台设备的人**,判据是 codestate==0(这台设备
- // 此前从未被绑过)。
+ log.Field{Key: "codestate", Value: codestate},
+ log.Field{Key: "vipdays", Value: model.Vipdays},
+ log.Field{Key: "computegrant", Value: model.Computegrant})
+ // 2026-09 起绑定**不再往用户表累加**任何权益:设备赠送的 VIP 与算力都挂在设备行上(见上面快照),
+ // 判定时按「当前绑定着且未到期」算。原来把天数加进 user.vipexptime 的做法一旦累加就与自购
+ // 混成同一个数字,解绑时拆不开;三个额度桶也已停用。
//
- // ⚠️ 原条件是 `!user.Isgiveaway || codestate == 0`,多出来的
- // `!user.Isgiveaway` 那一支是个口子:只要这个**用户**没领过绑定礼,哪怕设备
- // 是二手的(codestate!=0,已经被别人绑过、权益也已经发出去过一次),照样再发
- // 一整份。等于一台设备转手几次就能送出去几份 VIP/翻译/会议时长。
- //
- // codestate 是可靠判据:解绑处 `model.Status = 0` 是**注释掉的**
- // (api_unbinddevice.go,只清 Uid),status 一旦 0→1 就回不去,
- // 所以「绑了解、解了绑」刷不出第二份。
- //
- // `user.Isgiveaway = true` 仍然要写:厂家公码(api_bindauthcode.go)拿它当
- // 防重领标记,见 model_user.go 的说明。把它一起删掉会让绑过设备的用户
- // 还能再领一次公码。
+ // `user.Isgiveaway = true` 仍然要写:厂家公码(api_bindauthcode.go)拿它当防重领标记。
if codestate == 0 {
Isgiveaway = true
user.Isgiveaway = true
- // 绑定赠送:优先按【设备所属生产批次】发(后台「MAC生成管理 → 生成MAC」里一批一配),
- // 批次没配(三项全 0 / 取不到批次 / 存量码没有批次号)才回退到产品档案上的旧字段。
- vipDays, transMin, meetMin := this.resolveBindRewards(model, product)
- var addVipDay, addTrade, addMeet int64
- if vipDays > 0 {
- // ⚠️ 这里以前写死 90 天,配多少都发 90——product.Bindrewardvpitime 实际只当了开关用。
- // 现在按配置的天数发,批次里填几天就是几天。
- addVipDay = int64(vipDays)
- addSec := addVipDay * 24 * 60 * 60
- if user.Vipexptime == 0 || user.Vipexptime < time.Now().Unix() { //重置
- user.Vipexptime = time.Now().Unix() + addSec
- } else { //累加
- user.Vipexptime = user.Vipexptime + addSec
- }
- }
- // translate/meeting 配置为分钟,积分按秒存储
- if transMin > 0 {
- addTrade = int64(transMin) * 60
- user.Tradeintegral += addTrade
- user.Tradetotalintegral += addTrade
- }
- if meetMin > 0 {
- addMeet = int64(meetMin) * 60
- user.Meetintegral += addMeet
- user.Meettotalintegral += addMeet
- }
- err = this.module.model.save(user)
- if err != nil {
- errdata = &pb.ErrorData{
- Code: pb.ErrorCode_DBError,
- Message: err.Error(),
- }
- return
- }
- userSaved = true
- // 记录绑定设备奖励流水
- if addVipDay > 0 || addTrade > 0 || addMeet > 0 {
- _ = this.module.model.addIntegralLog(&pb.DBUserUseLog{
- Uid: session.GetUserId(),
- Logtype: pb.UserLogType_ActivityReward,
- Ts: time.Now().Unix(),
- Addvipday: addVipDay,
- Addtradesecond: addTrade,
- Addmeetsecond: addMeet,
- Extra: req.Code, // 绑定时使用的设备/授权码
- })
- // 绑定赠送资源埋点
- if this.module.analyze != nil {
- this.module.analyze.Report(&comm.StatEvent{
- Type: comm.StatEventResourceGrant,
- ProductId: product.Id,
- ChannelId: model.Channelid,
- Uid: session.GetUserId(),
- GrantVipDay: addVipDay,
- GrantTradeSecond: addTrade,
- GrantMeetSecond: addMeet,
- })
- }
+ }
+ if err = this.module.model.save(user); err != nil {
+ errdata = &pb.ErrorData{
+ Code: pb.ErrorCode_DBError,
+ Message: err.Error(),
}
+ return
}
- // 首绑送资源分支未触发时,仍需持久化 lastbindproductid / lastbindchannelid
- if !userSaved {
- if err = this.module.model.save(user); err != nil {
- errdata = &pb.ErrorData{
- Code: pb.ErrorCode_DBError,
- Message: err.Error(),
- }
- return
+ if codestate == 0 && (model.Vipdays > 0 || model.Computegrant > 0) {
+ // 首激流水:记录这台设备带了什么(不是发给用户的,是设备自己的权益)
+ _ = this.module.model.addIntegralLog(&pb.DBUserUseLog{
+ Uid: session.GetUserId(),
+ Logtype: pb.UserLogType_ActivityReward,
+ Ts: time.Now().Unix(),
+ Addvipday: int64(model.Vipdays),
+ Compute: model.Computegrant,
+ Devicemac: model.Devicemac,
+ Extra: "device activate grant",
+ })
+ if this.module.analyze != nil {
+ this.module.analyze.Report(&comm.StatEvent{
+ Type: comm.StatEventResourceGrant,
+ ProductId: product.Id,
+ ChannelId: model.Channelid,
+ Uid: session.GetUserId(),
+ GrantVipDay: int64(model.Vipdays),
+ })
}
}
+ // 老客户端仍按 viplv/vipexptime 判会员,这里回**合并后的有效值**(自购 或 设备赋予),
+ // 让它们不改代码也能看到设备带来的 VIP;三个积分字段回 0(已停用)。
+ vip, _, verr := comm.ResolveUserVip(session.GetUserId(), user)
+ if verr != nil {
+ this.module.Warn("BindDevice: VIP 合并判定失败", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "err", Value: verr.Error()})
+ vip = comm.ResolveVip(user, []*pb.DBAuthCode{model}, time.Now().Unix())
+ }
resp = &pb.UserBindDeviceResp{
- Device: divice,
- Isgiveaway: Isgiveaway,
- Viplv: user.Viplv,
- Vipexptime: user.Vipexptime,
- Tradeintegral: user.Tradeintegral,
- Tradetotalintegral: user.Tradetotalintegral,
- Meetintegral: user.Meetintegral,
- Meettotalintegral: user.Meettotalintegral,
+ Device: divice,
+ Isgiveaway: Isgiveaway,
+ Viplv: vip.Level,
+ Vipexptime: vip.ExpTime,
+ Devicevipexptime: comm.DeviceVipExpire(model),
+ Deviceviplevel: comm.DeviceVipLevel(model),
+ Devicecomputeleft: comm.DeviceComputeLeft(model),
}
return
}
-// resolveBindRewards 决定这台设备绑定时送什么:VIP天数 / 翻译分钟 / 会议分钟。
+// resolveBindRewards 决定这台设备激活时带什么:VIP 天数 / VIP 等级 / 赠送算力。
//
// 取值顺序:
-// 1. 设备所属生产批次的 rewardvip/rewardtranslate/rewardmeeting——同一款产品不同批次
-// (不同渠道、不同活动)送的东西经常不一样,配置点在后台「生成MAC」表单上;
-// 2. 批次三项全为 0,或批次取不到(存量授权码没有 batchno、批次被撤销)→ 回退产品档案上的旧字段。
+// 1. 设备所属生产批次(后台「MAC生成管理 → 生成MAC」里一批一配);
+// 2. 批次 VIP 天数与算力**都为 0**,或批次取不到(存量码没有批次号、批次被撤销)→ 回退产品档案上的字段。
//
-// ⚠️ 判「批次是否配过」用的是三项全 0,不是「批次存在」:新批次默认三项都是 0,
-// 若按「批次存在就用批次的值」,所有新批次都会变成不送任何东西,而产品上原本配好的赠送
-// 会在不改任何配置的情况下静默失效。
+// ⚠️ 判「批次是否配过」用的是两项全 0,不是「批次存在」:新批次默认全 0,若按存在与否判定,
+// 所有新批次都会静默变成不送任何东西。
//
// ⚠️ 取批次失败只回退、不报错:赠送是绑定的附带动作,为了它把绑定整个失败掉不划算。
-func (this *apiComp) resolveBindRewards(code *pb.DBAuthCode, product *pb.DBProduct) (vipDays, translateMin, meetingMin uint32) {
+//
+// 产品档案上的 bindrewardvpitime 历史上是**开关**(后台勾选写 1,绑定时发 90 天)。为了不让存量
+// 产品在升级后从 90 天变成 1 天,这里保留「1 → 90」的映射;后台改成按天数填之后,填 90 就是 90。
+func (this *apiComp) resolveBindRewards(code *pb.DBAuthCode, product *pb.DBProduct) (vipDays, vipLevel int32, compute int64) {
if code != nil {
if batch, err := this.module.model.getProductionBatch(code.Batchno); err == nil && batch != nil {
- if batch.Rewardvip > 0 || batch.Rewardtranslate > 0 || batch.Rewardmeeting > 0 {
- return batch.Rewardvip, batch.Rewardtranslate, batch.Rewardmeeting
+ if batch.Rewardvip > 0 || batch.Computegrant > 0 {
+ lv := batch.Viplevel
+ if lv <= 0 {
+ lv = 1
+ }
+ return int32(batch.Rewardvip), lv, batch.Computegrant
}
}
}
if product == nil {
return 0, 0, 0
}
- // 产品档案上的旧字段:Bindrewardvpitime 历史上只当开关用(非 0 就发 90 天),
- // 这里保持那个语义,免得存量产品的赠送天数在升级后变样。
- vipDays = product.Bindrewardvpitime
- if vipDays > 0 {
- vipDays = 90
+ vipDays = int32(product.Bindrewardvpitime)
+ if vipDays == 1 {
+ vipDays = 90 // 存量「开关」语义
+ }
+ vipLevel = product.Bindviplevel
+ if vipDays > 0 && vipLevel <= 0 {
+ vipLevel = 1
}
- return vipDays, product.Bindrewardtranslate, product.Bindrewardmeeting
+ return vipDays, vipLevel, product.Bindcompute
}
// alreadyBound 判断用户已有的这条绑定是不是本次要绑的同一台设备。
diff --git a/apps/services/modules/user/api_getcompute.go b/apps/services/modules/user/api_getcompute.go
new file mode 100644
index 00000000..027c40a1
--- /dev/null
+++ b/apps/services/modules/user/api_getcompute.go
@@ -0,0 +1,93 @@
+package user
+
+import (
+ "time"
+ "yunyan/comm"
+ "yunyan/lego/sys/log"
+ "yunyan/lego/sys/mysql"
+ "yunyan/pb"
+)
+
+// @Summary 获取会员与算力状态
+// @Description 客户端「用量」页一次拿齐:VIP(自购 / 设备赋予合并后)、算力(设备剩余 + 账户余额 + 累计/本月消耗)、换算系数。客户端不做任何换算。
+// @Tags User
+// @Accept json
+// @Produce json
+// @Security BearerAuth
+// @Param user body pb.UserGetComputeReq true "无参数"
+// @Success 200 {object} comm.HttpResult{data=pb.UserGetComputeResp} "成功返回"
+// @Router /api/home/user_getcompute [post]
+func (this *apiComp) GetCompute(session comm.IUserSession, req *pb.UserGetComputeReq) (resp *pb.UserGetComputeResp, errdata *pb.ErrorData) {
+ uid := session.GetUserId()
+ user, err := this.module.model.findforid(uid)
+ if err != nil {
+ errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
+ return
+ }
+ vip, devices, err := comm.ResolveUserVip(uid, user)
+ if err != nil {
+ errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
+ return
+ }
+ // 设备名从绑定记录上取(device_mac 行上没有名字)
+ names := map[string]string{}
+ if uds, e := this.module.model.getDevices(uid); e == nil {
+ for _, ud := range uds {
+ names[ud.Devicemac] = ud.Devicename
+ }
+ }
+ vipInfo := &pb.UserComputeVipInfo{
+ Level: vip.Level,
+ Exptime: vip.ExpTime,
+ Selfexptime: vip.SelfExpTime,
+ Devices: make([]*pb.UserDeviceVipInfo, 0, len(devices)),
+ }
+ var deviceLeft int64
+ for _, d := range devices {
+ deviceLeft += comm.DeviceComputeLeft(d)
+ vipInfo.Devices = append(vipInfo.Devices, &pb.UserDeviceVipInfo{
+ Devicemac: d.Devicemac,
+ Devicename: names[d.Devicemac],
+ Level: comm.DeviceVipLevel(d),
+ Exptime: comm.DeviceVipExpire(d),
+ Computegrant: d.Computegrant,
+ Computeused: d.Computeused,
+ })
+ }
+ stats, err := this.module.model.getStatistics(uid)
+ if err != nil && err != mysql.ErrNoDocuments {
+ errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
+ return
+ }
+ monthUsed, merr := this.module.model.sumComputeUsedSince(uid, monthStart(time.Now()))
+ if merr != nil {
+ // 本月消耗只是展示项,算不出来不该让整个接口失败
+ this.module.Warn("GetCompute: 本月消耗汇总失败", log.Field{Key: "uid", Value: uid}, log.Field{Key: "err", Value: merr.Error()})
+ }
+ rates := comm.LoadComputeRates()
+ ops := comm.LoadOpsParams()
+ resp = &pb.UserGetComputeResp{
+ Vip: vipInfo,
+ Compute: &pb.UserComputeInfo{
+ Deviceleft: deviceLeft,
+ Userbalance: user.Computebalance,
+ Totalused: stats.Computetotal,
+ Monthused: monthUsed,
+ },
+ Rates: &pb.UserComputeRates{
+ Translate: rates.Translate,
+ Meeting: rates.Meeting,
+ Aichat: rates.AIChat,
+ Gate: rates.Gate,
+ Vipwarndays: ops.VipWarnDays,
+ Computewarn: ops.ComputeWarn,
+ },
+ }
+ return
+}
+
+// monthStart 本地时区当月 1 日 0 点。
+func monthStart(t time.Time) int64 {
+ y, m, _ := t.Date()
+ return time.Date(y, m, 1, 0, 0, 0, 0, t.Location()).Unix()
+}
diff --git a/apps/services/modules/user/api_getinfo.go b/apps/services/modules/user/api_getinfo.go
index 464ce744..67173e14 100644
--- a/apps/services/modules/user/api_getinfo.go
+++ b/apps/services/modules/user/api_getinfo.go
@@ -73,6 +73,13 @@ func (this *apiComp) GetInfo(session comm.IUserSession, req *pb.UserGetInfoReq)
// if user.Avatar != "" {
// user.Avatar = aes.AesDecryptCBC(user.Avatar, this.options.CBCKey)
// }
+ // 老客户端只认 user.viplv / user.vipexptime,这里把**合并后的有效值**(自购 或 设备赋予)
+ // 覆盖到响应对象上——上面的 save 已经落库完,这次改动只进响应、不再写库。
+ // 库里 vipexptime 始终只记自购部分(见 comm.ResolveVip)。
+ if vip, _, verr := comm.ResolveUserVip(user.Uid, user); verr == nil {
+ user.Viplv = vip.Level
+ user.Vipexptime = vip.ExpTime
+ }
resp = &pb.UserGetInfoResp{
User: user,
Devices: divices,
diff --git a/apps/services/modules/user/api_sgin.go b/apps/services/modules/user/api_sgin.go
index 295ece94..1392e62f 100644
--- a/apps/services/modules/user/api_sgin.go
+++ b/apps/services/modules/user/api_sgin.go
@@ -241,10 +241,12 @@ func (this *apiComp) Sgin(session comm.IUserSession, req *pb.UserSginReq) (resp
// if user.Avatar != "" {
// user.Avatar = aes.AesEncryptCBC(user.Avatar, this.options.CBCKey)
// }
- // 新用户开户礼:30 天 VIP + 翻译/会议各 100 分钟 + 智能体 100 次。
+ // 新用户开户礼:VIP 天数 + 算力,两个数额由后台「会员与算力」页配置。
// 在 add 之前赋值,随用户一起落库,不需要额外一次写。见 newuser_gift.go。
+ // 参数取一次传给发放与流水两处,保证两者用同一份快照。
giftNow := time.Now().Unix()
- applyNewUserGift(user, giftNow)
+ giftParams := comm.LoadOpsParams()
+ applyNewUserGift(user, giftNow, giftParams)
if err = this.module.model.add(user); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
@@ -252,7 +254,7 @@ func (this *apiComp) Sgin(session comm.IUserSession, req *pb.UserSginReq) (resp
}
return
}
- logNewUserGift(user.Uid, giftNow)
+ logNewUserGift(user.Uid, giftNow, giftParams)
// 新增用户埋点
if this.module.analyze != nil {
this.module.analyze.Report(&comm.StatEvent{Type: comm.StatEventRegister, Uid: user.Uid})
diff --git a/apps/services/modules/user/api_translate.go b/apps/services/modules/user/api_translate.go
index f3649f0b..d127aab8 100644
--- a/apps/services/modules/user/api_translate.go
+++ b/apps/services/modules/user/api_translate.go
@@ -36,6 +36,13 @@ func (this *apiComp) Translate(session comm.IUserSession, req *pb.UserTranslateR
resp = &pb.UserTranslateResp{Text: req.Text}
return
}
+ // VIP 是翻译的准入闸门(自购 或 当前绑定设备赋予,见 comm.ResolveVip)。
+ // 文本/图片翻译与三段式(同传/面对面)的机器翻译那一跳都经过这里,拦住即整体不可用;
+ // 通话翻译走端到端直连服务商,拦不到——它的服务端手段是不下发凭据(svcresolve.go)。
+ if vip, _, verr := comm.ResolveUserVip(session.GetUserId(), nil); verr == nil && !vip.Active(time.Now().Unix()) {
+ errdata = &pb.ErrorData{Code: pb.ErrorCode_VipRequired, Message: "翻译功能需要会员,请续费或绑定带会员的设备"}
+ return
+ }
svcId, provider, tr, err := this.module.translatemodel.resolveTranslator()
if err != nil {
diff --git a/apps/services/modules/user/api_usages.go b/apps/services/modules/user/api_usages.go
index 44a77eb8..27cb4277 100644
--- a/apps/services/modules/user/api_usages.go
+++ b/apps/services/modules/user/api_usages.go
@@ -1,14 +1,15 @@
package user
import (
+ "time"
"yunyan/comm"
+ "yunyan/lego/sys/log"
"yunyan/lego/sys/mysql"
"yunyan/pb"
- "time"
)
// @Summary 使用资源请求
-// @Description 使用资源请求
+// @Description 上报一次用量(翻译秒数 / 会议秒数 / AI 对话字数)。服务端记原始统计,并按后台换算系数折算成算力做记账。
// @Tags User
// @Accept json
// @Produce json
@@ -16,28 +17,30 @@ import (
// @Param user body pb.UserUsagesReq true "使用资源请求"
// @Success 200 {object} comm.HttpResult{data=pb.UserUsagesResp} "成功返回"
// @Router /api/home/user_usages [post]
+//
+// 2026-09 起本接口**不再扣三个额度桶、不再因余额不足拒绝**——功能准入只看 VIP(见 comm.ResolveVip),
+// 用量统一折算成算力(见 comm.ApplyComputeUsage):先扣设备赠送、再扣用户余额;
+// 算力闸门(COMPUTE_GATE)默认关,余额不够也放行、差额记为超额。
+//
+// ⚠️ 客户端只上报**原始量**(秒 / 字符数),不上报算力:换算系数在后台可改,客户端自己算的话
+// 改一次系数就要发一次版。客户端拿到的算力是本接口**返回**的。
func (this *apiComp) Usages(session comm.IUserSession, req *pb.UserUsagesReq) (resp *pb.UserUsagesResp, errdata *pb.ErrorData) {
var (
user *pb.DBUser
statistics *pb.DBUserStatistics
- usages map[int32]int64
userlog *pb.DBUserUseLog
+ kind comm.ComputeUsageKind
+ raw int64
err error
)
user, err = this.module.model.findforid(session.GetUserId())
if err != nil {
- errdata = &pb.ErrorData{
- Code: pb.ErrorCode_DBError,
- Message: err.Error(),
- }
+ errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
return
}
statistics, err = this.module.model.getStatistics(session.GetUserId())
if err != nil && err != mysql.ErrNoDocuments {
- errdata = &pb.ErrorData{
- Code: pb.ErrorCode_DBError,
- Message: err.Error(),
- }
+ errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
return
}
userlog = &pb.DBUserUseLog{
@@ -45,118 +48,97 @@ func (this *apiComp) Usages(session comm.IUserSession, req *pb.UserUsagesReq) (r
Logtype: pb.UserLogType_UserConsume,
Ts: time.Now().Unix(),
}
- usages = make(map[int32]int64)
+ // ── 原始统计照记(现有字段不动,看板与结算还在用) ──
switch req.Usagetype {
case pb.UsageType_Trade:
- var totalCost int64
- for _, v := range req.Usages {
- totalCost += v
- }
- if user.Tradeintegral < totalCost {
- errdata = &pb.ErrorData{
- Code: pb.ErrorCode_TradeIntegralNotEnough,
- Message: "翻译积分不足",
- }
- return
- }
- if v, ok := req.Usages["TRANSLATION_MODE_simultaneous"]; ok {
- user.Tradeintegral -= v
+ kind = comm.ComputeUsageTranslate
+ if v, ok := req.Usages["TRANSLATION_MODE_simultaneous"]; ok && v > 0 {
statistics.Trademodel1Num += 1
statistics.Trademodel1Time += v
- userlog.Addagentintegral += -1 * v
+ raw += v
}
- if v, ok := req.Usages["TRANSLATION_MODE_faceToFace"]; ok {
- user.Tradeintegral -= v
+ if v, ok := req.Usages["TRANSLATION_MODE_faceToFace"]; ok && v > 0 {
statistics.Trademodel2Num += 1
statistics.Trademodel2Time += v
- userlog.Addagentintegral += -1 * v
+ raw += v
}
- if v, ok := req.Usages["TRANSLATION_MODE_call"]; ok {
- user.Tradeintegral -= v
+ if v, ok := req.Usages["TRANSLATION_MODE_call"]; ok && v > 0 {
statistics.Trademodel3Num += 1
statistics.Trademodel3Time += v
- userlog.Addagentintegral += -1 * v
+ raw += v
}
- if v, ok := req.Usages["TRANSLATION_MODE_audioVideo"]; ok {
- user.Tradeintegral -= v
+ if v, ok := req.Usages["TRANSLATION_MODE_audioVideo"]; ok && v > 0 {
statistics.Trademodel4Num += 1
statistics.Trademodel4Time += v
- userlog.Addagentintegral += -1 * v
+ raw += v
}
- usages[int32(req.Usagetype)] = user.Tradeintegral
+ // 原实现把翻译秒数写进了 Addagentintegral(AI 那一列),这里改回正确的列。
+ userlog.Addtradesecond = -raw
case pb.UsageType_Meet:
- if v, ok := req.Usages["MEETING_ASR_AUDIO_SECONDS"]; ok {
- if user.Meetintegral < v {
- errdata = &pb.ErrorData{
- Code: pb.ErrorCode_MeetIntegralNotEnough,
- Message: "会议积分不足",
- }
- return
- }
- user.Meetintegral -= v
+ kind = comm.ComputeUsageMeeting
+ if v, ok := req.Usages["MEETING_ASR_AUDIO_SECONDS"]; ok && v > 0 {
statistics.Meetnum += 1
statistics.Meettime += v
- userlog.Addmeetsecond += -1 * v
+ raw = v
+ userlog.Addmeetsecond = -v
}
- usages[int32(req.Usagetype)] = user.Meetintegral
case pb.UsageType_AI:
- if user.Aichatintegral < 1 {
- errdata = &pb.ErrorData{
- Code: pb.ErrorCode_AIIntegralNotEnough,
- Message: "AI积分不足",
- }
- return
- }
- user.Aichatintegral -= 1
- usages[int32(req.Usagetype)] = user.Aichatintegral
+ kind = comm.ComputeUsageAIChat
statistics.Aichatnum += 1
- usages[int32(req.Usagetype)] = user.Aichatintegral
- userlog.Addagentintegral += -1
+ // 这两列名字里带 token,但存的是**字符数**(Unicode 码点),与大模型自己的 tokenizer 无关。
+ // 字段早就有、一直没人写;老客户端不传字数时 raw=0 → 本次不扣算力、只记一次。
+ statistics.Aichatuptoken += req.Upchars
+ statistics.Aichatdowntoken += req.Downchars
+ raw = req.Upchars + req.Downchars
+ userlog.Addagentintegral = -1
+ default:
+ errdata = &pb.ErrorData{Code: pb.ErrorCode_ReqParameterError, Message: "未知的用量类型"}
+ return
}
- err = this.module.model.save(user)
+ // ── 算力记账 ──
+ res, err := comm.ApplyComputeUsage(session.GetUserId(), kind, raw, userlog, statistics)
if err != nil {
- errdata = &pb.ErrorData{
- Code: pb.ErrorCode_DBError,
- Message: err.Error(),
- }
+ this.module.Error("Usages: 算力记账失败",
+ log.Field{Key: "uid", Value: session.GetUserId()},
+ log.Field{Key: "kind", Value: int(kind)},
+ log.Field{Key: "raw", Value: raw},
+ log.Field{Key: "err", Value: err.Error()})
+ errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
return
}
- err = this.module.model.updateStatistics(statistics)
- if err != nil {
- errdata = &pb.ErrorData{
- Code: pb.ErrorCode_DBError,
- Message: err.Error(),
- }
+ if res.Rejected {
+ errdata = &pb.ErrorData{Code: pb.ErrorCode_ComputeNotEnough, Message: "算力不足"}
+ return
+ }
+ if err = this.module.model.updateStatistics(statistics); err != nil {
+ errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
return
}
-
if err = this.module.model.addIntegralLog(userlog); err != nil {
- errdata = &pb.ErrorData{
- Code: pb.ErrorCode_DBError,
- Message: err.Error(),
- }
+ errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
return
}
- // 资源消耗埋点
+ // 资源消耗埋点(口径不变)
if this.module.analyze != nil {
switch req.Usagetype {
case pb.UsageType_Trade:
- var sec int64
- for _, v := range req.Usages {
- sec += v
- }
- this.module.analyze.Report(&comm.StatEvent{Type: comm.StatEventTranslate, ProductId: user.Lastbindproductid, ChannelId: user.Lastbindchannelid, Uid: session.GetUserId(), Second: sec})
+ this.module.analyze.Report(&comm.StatEvent{Type: comm.StatEventTranslate, ProductId: user.Lastbindproductid, ChannelId: user.Lastbindchannelid, Uid: session.GetUserId(), Second: raw})
case pb.UsageType_Meet:
- if v, ok := req.Usages["MEETING_ASR_AUDIO_SECONDS"]; ok {
- this.module.analyze.Report(&comm.StatEvent{Type: comm.StatEventMeeting, ProductId: user.Lastbindproductid, ChannelId: user.Lastbindchannelid, Uid: session.GetUserId(), Second: v})
- }
+ this.module.analyze.Report(&comm.StatEvent{Type: comm.StatEventMeeting, ProductId: user.Lastbindproductid, ChannelId: user.Lastbindchannelid, Uid: session.GetUserId(), Second: raw})
case pb.UsageType_AI:
this.module.analyze.Report(&comm.StatEvent{Type: comm.StatEventAiChat, ProductId: user.Lastbindproductid, ChannelId: user.Lastbindchannelid, Uid: session.GetUserId()})
}
}
+ // 兼容:老客户端按 usages[usagetype] 读「剩余量」。三桶已停用,这里回算力剩余合计而不是 0——
+ // 老包若在本地拿剩余量做闸门,回 0 会把它锁死。单位对不上是过渡期的已知代价。
+ left := res.DeviceLeft + res.UserLeft
resp = &pb.UserUsagesResp{
- Usages: usages,
+ Usages: map[int32]int64{int32(req.Usagetype): left},
+ Compute: res.Compute,
+ Computedeviceleft: res.DeviceLeft,
+ Computeuserleft: res.UserLeft,
+ Over: res.Over,
}
return
}
diff --git a/apps/services/modules/user/api_v2_getthirdsvcs.go b/apps/services/modules/user/api_v2_getthirdsvcs.go
index b30c1db7..d9090424 100644
--- a/apps/services/modules/user/api_v2_getthirdsvcs.go
+++ b/apps/services/modules/user/api_v2_getthirdsvcs.go
@@ -29,6 +29,6 @@ func (this *apiV2Comp) GetThirdSvcs(session comm.IUserSession, req *pb.UserGetTh
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: err.Error()}
return
}
- resp = &pb.UserGetThirdSvcsResp{Svcs: items}
+ resp = &pb.UserGetThirdSvcsResp{Svcs: filterSvcsByVip(session.GetUserId(), items)}
return
}
diff --git a/apps/services/modules/user/api_v3_getappconfig.go b/apps/services/modules/user/api_v3_getappconfig.go
index 1a0f1582..fa07a79e 100644
--- a/apps/services/modules/user/api_v3_getappconfig.go
+++ b/apps/services/modules/user/api_v3_getappconfig.go
@@ -68,7 +68,7 @@ func (this *apiV3Comp) GetAppConfig(session comm.IUserSession, req *pb.UserGetAp
Env: env,
Agents: agents,
Products: products,
- Thirdsvcs: thirdsvcs,
+ Thirdsvcs: filterSvcsByVip(session.GetUserId(), thirdsvcs),
Meet: this.meetCapabilities(),
// 通话翻译:按上面解析出的区域筛好的选路规则 + 规则引用的翻译 agent(见 api_v3_calltranslate.go)。
CallTranslate: this.callTranslateV3(region),
diff --git a/apps/services/modules/user/model_config.go b/apps/services/modules/user/model_config.go
index a67867d9..4cce14a2 100644
--- a/apps/services/modules/user/model_config.go
+++ b/apps/services/modules/user/model_config.go
@@ -121,6 +121,11 @@ func (this *modelConfigComp) loaddb() (err error) {
this.agents = agents
this.mcps = mcps
this.lock.Unlock()
+ // 算力换算系数与运营参数也存 config 表,但走 comm 的独立缓存(业务侧各处直接调 comm.LoadXxx,
+ // 不经过这里的 this.config)。后台改完会广播到这里,顺手让那两份缓存失效,
+ // 否则要等它们自己的 30s TTL 过期才生效。
+ comm.InvalidateComputeRates()
+ comm.InvalidateOpsParams()
return
}
diff --git a/apps/services/modules/user/model_user.go b/apps/services/modules/user/model_user.go
index 47846ff4..6a7fbee5 100644
--- a/apps/services/modules/user/model_user.go
+++ b/apps/services/modules/user/model_user.go
@@ -1,6 +1,7 @@
package user
import (
+ "yunyan/lego/sys/log"
"context"
"fmt"
"strings"
@@ -40,10 +41,49 @@ func (this *modelUserComp) Init(service core.IService, module core.IModule, comp
if err = mysql.CreateTable(comm.TableUserUseLog, &pb.DBUserUseLog{}); err != nil {
this.module.Errorln(err)
}
+ this.migrateLegacyBucketsToCompute()
return
}
+// legacyComputeMigratedKey config 表里的一次性迁移标记;存在即不再跑。
+const legacyComputeMigratedKey = "COMPUTE_LEGACY_MIGRATED"
+
+// migrateLegacyBucketsToCompute 把存量用户的三个旧额度桶(翻译秒 / 会议秒 / AI 次数)按换算系数
+// 折成算力并入 computebalance。只跑一次(config 表打标记),旧列**保留不清**——
+// 它们已不参与任何判定,清掉是不可逆操作,没必要冒险;老客户端还会把它们当「剩余分钟」显示一阵子。
+//
+// 一条 SQL 完成,避免逐行读改写在大表上拖慢启动。系数取此刻生效值(首次多半是默认 1)。
+func (this *modelUserComp) migrateLegacyBucketsToCompute() {
+ marker := &pb.DBAppConfigItem{}
+ if err := mysql.FindOne(comm.TableAppConfig, marker, "`key`=?", legacyComputeMigratedKey); err == nil {
+ return
+ } else if err != mysql.ErrNoDocuments {
+ this.module.Warn("算力迁移:读标记失败,本次跳过", log.Field{Key: "err", Value: err.Error()})
+ return
+ }
+ r := comm.LoadComputeRates()
+ res := mysql.Exec(
+ "UPDATE "+comm.TableUser+" SET computebalance = computebalance"+
+ " + CEIL(GREATEST(tradeintegral,0)/60)*?"+
+ " + CEIL(GREATEST(meetintegral,0)/60)*?"+
+ " + GREATEST(aichatintegral,0)"+
+ " WHERE tradeintegral > 0 OR meetintegral > 0 OR aichatintegral > 0",
+ r.Translate, r.Meeting)
+ if res.Error != nil {
+ this.module.Error("算力迁移:折算存量额度失败", log.Field{Key: "err", Value: res.Error.Error()})
+ return
+ }
+ if err := mysql.Insert(comm.TableAppConfig, &pb.DBAppConfigItem{
+ Group: "compute", Key: legacyComputeMigratedKey, Value: "1",
+ Description: "存量三桶已折算进算力(自动迁移标记,勿删)",
+ }); err != nil {
+ this.module.Warn("算力迁移:写标记失败,下次启动会重复折算!", log.Field{Key: "err", Value: err.Error()})
+ return
+ }
+ this.module.Infof("算力迁移:已把 %d 个用户的存量额度折算进 computebalance", res.RowsAffected)
+}
+
func (this *modelUserComp) findforid(uid string) (user *pb.DBUser, err error) {
user = &pb.DBUser{}
err = mysql.FindOne(comm.TableUser, user, "uid=?", uid)
@@ -488,3 +528,14 @@ func (this *modelUserComp) rollbackActivateCode(uid string) (affected int64, err
err = tx.Error
return
}
+
+// sumComputeUsedSince 从 since 起的算力消耗合计(流水里消耗记为负数,这里取绝对值)。
+func (this *modelUserComp) sumComputeUsedSince(uid string, since int64) (used int64, err error) {
+ var total struct{ Total int64 }
+ err = mysql.Table(comm.TableUserUseLog).
+ Select("COALESCE(SUM(-compute),0) AS total").
+ Where("uid=? AND ts>=? AND logtype=? AND compute<0", uid, since, int32(pb.UserLogType_UserConsume)).
+ Scan(&total).Error
+ used = total.Total
+ return
+}
diff --git a/apps/services/modules/user/module_test.go b/apps/services/modules/user/module_test.go
index 7af83743..6a9358b7 100644
--- a/apps/services/modules/user/module_test.go
+++ b/apps/services/modules/user/module_test.go
@@ -450,6 +450,71 @@ func TestFactoryidNotPersisted(t *testing.T) {
}
}
+// devicetype 同样是纯下发字段(2026-09-08 废弃):产品上的「设备类型」已删除,
+// pb 里只留一个恒为 0 的兼容字段给老 App 包解析用。gorm 必须完全忽略它,
+// 否则 AutoMigrate 会把刚 DROP 掉的列又建回来。
+func TestDevicetypeNotPersisted(t *testing.T) {
+ s, err := schema.Parse(&pb.DBProduct{}, &sync.Map{}, schema.NamingStrategy{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if f := s.LookUpField("devicetype"); f != nil {
+ t.Fatalf("devicetype 被 gorm 当成了库字段(列 %s),应由 gorm:\"-\" 忽略", f.DBName)
+ }
+}
+
+// 老客户端按「非空 int」解析 devicetype,字段不下发即 TypeError、整个响应解析失败。
+// 所以哪怕它已废弃,JSON 里也必须始终有这个键(值恒为 0)。
+func TestDevicetypeStillEmittedInJSON(t *testing.T) {
+ b, err := json.Marshal(&pb.DBProduct{Id: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m map[string]any
+ if err := json.Unmarshal(b, &m); err != nil {
+ t.Fatal(err)
+ }
+ v, ok := m["devicetype"]
+ if !ok {
+ t.Fatal("下发的产品 JSON 里缺少 devicetype 键——已发布的 App 包会整个解析失败")
+ }
+ if v != float64(0) {
+ t.Fatalf("devicetype 应恒为 0,实际 %v", v)
+ }
+}
+
+// scanuuid 与 devicetype 同一处理(2026-09-08 废弃):设备校验与绑定奖励都改成按 MAC 判定,
+// 这个 UUID 不再有任何用途,库列已删,pb 里只留一个 gorm:"-" 的下发兼容字段。
+func TestScanuuidNotPersisted(t *testing.T) {
+ s, err := schema.Parse(&pb.DBProduct{}, &sync.Map{}, schema.NamingStrategy{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if f := s.LookUpField("scanuuid"); f != nil {
+ t.Fatalf("scanuuid 被 gorm 当成了库字段(列 %s),应由 gorm:\"-\" 忽略", f.DBName)
+ }
+}
+
+// 老客户端按「非空 String」解析 scanuuid(`json['scanuuid'] as String`),
+// 字段不下发即 TypeError、整个 user_getappconfig 解析失败。所以键必须始终在,值恒为空串。
+func TestScanuuidStillEmittedInJSON(t *testing.T) {
+ b, err := json.Marshal(&pb.DBProduct{Id: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m map[string]any
+ if err := json.Unmarshal(b, &m); err != nil {
+ t.Fatal(err)
+ }
+ v, ok := m["scanuuid"]
+ if !ok {
+ t.Fatal("下发的产品 JSON 里缺少 scanuuid 键——已发布的 App 包会整个解析失败")
+ }
+ if v != "" {
+ t.Fatalf("scanuuid 应恒为空串,实际 %v", v)
+ }
+}
+
// ---------------------------------------------------------------------------
// ⑥ 杂项(token / 加解密工具)
// ---------------------------------------------------------------------------
diff --git a/apps/services/modules/user/newuser_gift.go b/apps/services/modules/user/newuser_gift.go
index 6267186e..0adfcc00 100644
--- a/apps/services/modules/user/newuser_gift.go
+++ b/apps/services/modules/user/newuser_gift.go
@@ -11,54 +11,45 @@ import (
// ⚠️ 这是**临时**的拉新政策(2026-08-28 起)。要停掉就把 newUserGiftEnabled 置 false,
// 或直接删掉 applyNewUserGift 的调用点(api_sgin.go 里创建用户那一处)。
//
-// 单位坑(三个额度桶并不同构,照抄会送错量级):
-// - Tradeintegral / Meetintegral 是**秒**,所以「100 分钟」= 100*60;
-// - Aichatintegral 是**次数**,每次 AI 对话扣 1(见 api_usages.go 的 UsageType_AI 分支),
-// 「分钟」在这个桶里没有意义,按 100 次给。
-const (
- newUserGiftEnabled = true
-
- newUserGiftVipDays = 30 // 赠送 VIP 天数
- newUserGiftTradeMin = 100 // 翻译额度(分钟)——同传/面对面/通话/影音共用这一个桶
- newUserGiftMeetMin = 100 // 会议额度(分钟)
- newUserGiftAiTimes = 100 // 智能体额度(次数,不是分钟)
-)
+// 2026-09 起三个额度桶停用,开户礼改成「VIP 天数 + 算力」(算力归账号、不过期,见 comm/compute.go)。
+// 两个数额由**后台「会员与算力」页配置**(comm.LoadOpsParams),改了不用发版;
+// 配置表里没有这两行时回落到 comm 的默认值(30 天 + 100 算力)。
+//
+// ⚠️ 两项都配 0 = 注册不送任何东西,这是合法配置而不是「没配」——所以 ParseOpsParams
+// 区分「键不存在」与「值为 0」,别改成 `if v > 0` 那种写法。
+const newUserGiftEnabled = true
// applyNewUserGift 把开户礼写进尚未落库的新用户对象。
// 只在「用户不存在 → 新建」的分支调用,老用户不受影响。
//
// 直接赋值而非累加:这是刚 new 出来的对象,几个额度字段都还是零值。
-func applyNewUserGift(user *pb.DBUser, now int64) {
+// p 由调用方传入(comm.LoadOpsParams())而不是在函数里各读一次:发放与流水必须用**同一份快照**,
+// 否则两次读之间后台改了配置,就会出现「送的是 A、流水记的是 B」的对不上账。
+func applyNewUserGift(user *pb.DBUser, now int64, p comm.OpsParams) {
if !newUserGiftEnabled || user == nil {
return
}
- user.Vipexptime = now + int64(newUserGiftVipDays)*24*60*60
-
- tradeSec := int64(newUserGiftTradeMin) * 60
- meetSec := int64(newUserGiftMeetMin) * 60
-
- user.Tradeintegral = tradeSec
- user.Tradetotalintegral = tradeSec
- user.Meetintegral = meetSec
- user.Meettotalintegral = meetSec
- user.Aichatintegral = int64(newUserGiftAiTimes)
- user.Aichattotalintegral = int64(newUserGiftAiTimes)
+ if p.NewUserGiftVipDays > 0 {
+ user.Vipexptime = now + int64(p.NewUserGiftVipDays)*24*60*60
+ }
+ user.Computebalance = p.NewUserGiftCompute
}
// logNewUserGift 记一条活动奖励流水,便于后台对账「送出去多少」。
// 礼包已经随用户一起落库了,这条流水写失败不回滚、只忽略。
-func logNewUserGift(uid string, now int64) {
+func logNewUserGift(uid string, now int64, p comm.OpsParams) {
if !newUserGiftEnabled {
return
}
+ if p.NewUserGiftVipDays == 0 && p.NewUserGiftCompute == 0 {
+ return // 后台配成不送,就别记一条全 0 的流水
+ }
_ = mysql.Insert(comm.TableUserUseLog, &pb.DBUserUseLog{
- Uid: uid,
- Ts: now,
- Logtype: pb.UserLogType_ActivityReward,
- Addvipday: int64(newUserGiftVipDays),
- Addtradesecond: int64(newUserGiftTradeMin) * 60,
- Addmeetsecond: int64(newUserGiftMeetMin) * 60,
- Addagentintegral: int64(newUserGiftAiTimes),
- Extra: "new user register gift",
+ Uid: uid,
+ Ts: now,
+ Logtype: pb.UserLogType_ActivityReward,
+ Addvipday: int64(p.NewUserGiftVipDays),
+ Compute: p.NewUserGiftCompute,
+ Extra: "new user register gift",
})
}
diff --git a/apps/services/modules/user/newuser_gift_test.go b/apps/services/modules/user/newuser_gift_test.go
index 58bd1b1e..60d5c38e 100644
--- a/apps/services/modules/user/newuser_gift_test.go
+++ b/apps/services/modules/user/newuser_gift_test.go
@@ -3,33 +3,49 @@ package user
import (
"testing"
+ "yunyan/comm"
"yunyan/pb"
)
-// 守住单位口径:翻译/会议是**秒**,智能体是**次**。
-// 三个桶不同构,照抄隔壁桶的写法就会送错量级(比如给 AI 送 6000 次)。
+// 开户礼 = VIP 天数 + 算力,两个数额由后台配置(comm.OpsParams)。
func TestApplyNewUserGift(t *testing.T) {
const now int64 = 1_700_000_000
+ p := comm.DefaultOpsParams()
u := &pb.DBUser{}
- applyNewUserGift(u, now)
+ applyNewUserGift(u, now, p)
- if got, want := u.Vipexptime, now+30*24*60*60; got != want {
- t.Errorf("Vipexptime = %d, want %d(30 天)", got, want)
+ if got, want := u.Vipexptime, now+int64(p.NewUserGiftVipDays)*24*60*60; got != want {
+ t.Errorf("Vipexptime = %d, want %d(%d 天)", got, want, p.NewUserGiftVipDays)
}
- if got, want := u.Tradeintegral, int64(6000); got != want {
- t.Errorf("Tradeintegral = %d, want %d(100 分钟 = 6000 秒)", got, want)
+ if got, want := u.Computebalance, p.NewUserGiftCompute; got != want {
+ t.Errorf("Computebalance = %d, want %d", got, want)
}
- if got, want := u.Meetintegral, int64(6000); got != want {
- t.Errorf("Meetintegral = %d, want %d(100 分钟 = 6000 秒)", got, want)
+ // 旧三桶必须保持 0:它们已不是闸门,往里写值只会让老后台页面显示出「有余额」的假象
+ if u.Tradeintegral != 0 || u.Meetintegral != 0 || u.Aichatintegral != 0 {
+ t.Errorf("旧额度桶不应再发放: Trade=%d Meet=%d Ai=%d", u.Tradeintegral, u.Meetintegral, u.Aichatintegral)
}
- if got, want := u.Aichatintegral, int64(100); got != want {
- t.Errorf("Aichatintegral = %d, want %d(100 **次**,不是秒)", got, want)
+}
+
+// 后台把两项都配成 0 = 注册不送任何东西。这是合法配置,不能被当成「没配」而回落到默认值。
+func TestApplyNewUserGiftZeroMeansNoGift(t *testing.T) {
+ const now int64 = 1_700_000_000
+ u := &pb.DBUser{}
+ applyNewUserGift(u, now, comm.OpsParams{NewUserGiftVipDays: 0, NewUserGiftCompute: 0})
+ if u.Vipexptime != 0 || u.Computebalance != 0 {
+ t.Errorf("配 0 应该什么都不送,得到 Vip=%d Compute=%d", u.Vipexptime, u.Computebalance)
+ }
+}
+
+// 只送算力、不送 VIP 也是合法配置。
+func TestApplyNewUserGiftComputeOnly(t *testing.T) {
+ const now int64 = 1_700_000_000
+ u := &pb.DBUser{}
+ applyNewUserGift(u, now, comm.OpsParams{NewUserGiftVipDays: 0, NewUserGiftCompute: 500})
+ if u.Vipexptime != 0 {
+ t.Errorf("VIP 天数为 0 时不该设到期时间,得到 %d", u.Vipexptime)
}
- // total 系列是「累计获得」,前端拿它算进度条,必须同步给上
- if u.Tradetotalintegral != u.Tradeintegral ||
- u.Meettotalintegral != u.Meetintegral ||
- u.Aichattotalintegral != u.Aichatintegral {
- t.Error("total 系列未与余额同步,前端进度条会算错")
+ if u.Computebalance != 500 {
+ t.Errorf("Computebalance = %d, want 500", u.Computebalance)
}
}
@@ -38,12 +54,13 @@ func TestApplyNewUserGift(t *testing.T) {
// 万一将来有人挪去老用户路径,这个测试会提醒他先想清楚语义)。
func TestApplyNewUserGiftIsAssignNotAccumulate(t *testing.T) {
const now int64 = 1_700_000_000
- u := &pb.DBUser{Tradeintegral: 999, Aichatintegral: 7, Vipexptime: now + 12345}
- applyNewUserGift(u, now)
- if u.Tradeintegral != 6000 || u.Aichatintegral != 100 {
- t.Errorf("应为赋值语义,得到 Trade=%d Ai=%d", u.Tradeintegral, u.Aichatintegral)
+ p := comm.DefaultOpsParams()
+ u := &pb.DBUser{Computebalance: 999, Vipexptime: now + 12345}
+ applyNewUserGift(u, now, p)
+ if u.Computebalance != p.NewUserGiftCompute {
+ t.Errorf("应为赋值语义,得到 Compute=%d", u.Computebalance)
}
- if u.Vipexptime != now+30*24*60*60 {
+ if u.Vipexptime != now+int64(p.NewUserGiftVipDays)*24*60*60 {
t.Errorf("VIP 应为赋值语义,得到 %d", u.Vipexptime)
}
}
diff --git a/apps/services/modules/user/svcresolve.go b/apps/services/modules/user/svcresolve.go
index 02195ab4..a683fbb9 100644
--- a/apps/services/modules/user/svcresolve.go
+++ b/apps/services/modules/user/svcresolve.go
@@ -1,6 +1,7 @@
package user
import (
+ "time"
"os"
"sort"
@@ -96,3 +97,26 @@ func resolveThirdSvcs(app string, region pb.Region, ids []string) ([]*pb.ThirdSv
sort.Slice(items, func(i, j int) bool { return items[i].Id < items[j].Id })
return items, nil
}
+
+
+// filterSvcsByVip VIP 过期时把端到端翻译类(SvcCatAST)服务从下发列表里去掉。
+//
+// 通话翻译走阿里端到端,客户端拿着凭据直连服务商 WebSocket,整个过程一个请求都不经过我们的服务器,
+// 事后也拦不住——这里是它**唯一**的服务端闸门。凭据是启动时拉一次的,VIP 在使用期间过期时客户端
+// 手里的凭据仍然有效,直到下次重新拉配置;这个窗口(最长一个启动周期)已在设计上接受。
+//
+// VIP 判定失败(库抖动)按**有 VIP**放行:宁可多发一次凭据,也不能让付费用户因为一次查询失败用不了功能。
+func filterSvcsByVip(uid string, items []*pb.ThirdSvcItem) []*pb.ThirdSvcItem {
+ vip, _, err := comm.ResolveUserVip(uid, nil)
+ if err != nil || vip.Active(time.Now().Unix()) {
+ return items
+ }
+ out := items[:0]
+ for _, it := range items {
+ if comm.CategoriesHas(it.Categories, comm.SvcCatAST) {
+ continue
+ }
+ out = append(out, it)
+ }
+ return out
+}
diff --git a/apps/services/pb/api_msg.pb.go b/apps/services/pb/api_msg.pb.go
index 6e5e2fb4..35e9d41c 100644
--- a/apps/services/pb/api_msg.pb.go
+++ b/apps/services/pb/api_msg.pb.go
@@ -4471,7 +4471,9 @@ type ApiCreateAuthCodesReq struct {
Sharerate int32 `protobuf:"varint,11,opt,name=sharerate,proto3" json:"sharerate"` //@go_tags(`json:"sharerate"`) 本批次渠道分成比例(万分比 0~10000),生产时冻结进批次台账
Rewardvip uint32 `protobuf:"varint,12,opt,name=rewardvip,proto3" json:"rewardvip"` //@go_tags(`json:"rewardvip"`) 本批次绑定赠送VIP天数(0=不送)
Rewardtranslate uint32 `protobuf:"varint,13,opt,name=rewardtranslate,proto3" json:"rewardtranslate"` //@go_tags(`json:"rewardtranslate"`) 本批次绑定赠送翻译时长(分钟,0=不送)
- Rewardmeeting uint32 `protobuf:"varint,14,opt,name=rewardmeeting,proto3" json:"rewardmeeting"` //@go_tags(`json:"rewardmeeting"`) 本批次绑定赠送会议时长(分钟,0=不送)
+ Rewardmeeting uint32 `protobuf:"varint,14,opt,name=rewardmeeting,proto3" json:"rewardmeeting"` //@go_tags(`json:"rewardmeeting"`) 【已停用】本批次绑定赠送会议时长(分钟),2026-09 起不再读写
+ Viplevel int32 `protobuf:"varint,15,opt,name=viplevel,proto3" json:"viplevel"` //@go_tags(`json:"viplevel"`) 本批次绑定赠送VIP等级(1=VIP 2=VIP-Pro,默认1)
+ Computegrant int64 `protobuf:"varint,16,opt,name=computegrant,proto3" json:"computegrant"` //@go_tags(`json:"computegrant"`) 本批次绑定赠送算力(0=不送)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -4604,6 +4606,20 @@ func (x *ApiCreateAuthCodesReq) GetRewardmeeting() uint32 {
return 0
}
+func (x *ApiCreateAuthCodesReq) GetViplevel() int32 {
+ if x != nil {
+ return x.Viplevel
+ }
+ return 0
+}
+
+func (x *ApiCreateAuthCodesReq) GetComputegrant() int64 {
+ if x != nil {
+ return x.Computegrant
+ }
+ return 0
+}
+
type ApiCreateAuthCodesResp struct {
state protoimpl.MessageState `protogen:"open.v1"`
Brandid uint32 `protobuf:"varint,1,opt,name=brandid,proto3" json:"brandid"` //@go_tags(`json:"brandid"`) 品牌商id
@@ -11062,7 +11078,7 @@ const file_api_api_msg_proto_rawDesc = "" +
"\x17ApiUploadChannelAppResp\"/\n" +
"\x13ApiDelChannelAppReq\x12\x18\n" +
"\achannel\x18\x01 \x01(\x05R\achannel\"\x16\n" +
- "\x14ApiDelChannelAppResp\"\xc3\x03\n" +
+ "\x14ApiDelChannelAppResp\"\x83\x04\n" +
"\x15ApiCreateAuthCodesReq\x12\x18\n" +
"\abrandid\x18\x01 \x01(\rR\abrandid\x12\x1c\n" +
"\tproductid\x18\x02 \x01(\rR\tproductid\x12\x1e\n" +
@@ -11080,7 +11096,9 @@ const file_api_api_msg_proto_rawDesc = "" +
"\tsharerate\x18\v \x01(\x05R\tsharerate\x12\x1c\n" +
"\trewardvip\x18\f \x01(\rR\trewardvip\x12(\n" +
"\x0frewardtranslate\x18\r \x01(\rR\x0frewardtranslate\x12$\n" +
- "\rrewardmeeting\x18\x0e \x01(\rR\rrewardmeeting\"\xbb\x02\n" +
+ "\rrewardmeeting\x18\x0e \x01(\rR\rrewardmeeting\x12\x1a\n" +
+ "\bviplevel\x18\x0f \x01(\x05R\bviplevel\x12\"\n" +
+ "\fcomputegrant\x18\x10 \x01(\x03R\fcomputegrant\"\xbb\x02\n" +
"\x16ApiCreateAuthCodesResp\x12\x18\n" +
"\abrandid\x18\x01 \x01(\rR\abrandid\x12\x1c\n" +
"\tproductid\x18\x02 \x01(\rR\tproductid\x12\x18\n" +
diff --git a/apps/services/pb/db.pb.go b/apps/services/pb/db.pb.go
index 3382b6a4..145ff642 100644
--- a/apps/services/pb/db.pb.go
+++ b/apps/services/pb/db.pb.go
@@ -239,8 +239,12 @@ type DBUser struct {
Lastbindchannelid string `protobuf:"bytes,50,opt,name=lastbindchannelid,proto3" json:"lastbindchannelid" gorm:"lastbindchannelid;size:16;index"` //@go_tags(`gorm:"lastbindchannelid;size:16;index" json:"lastbindchannelid"`) 最后绑定设备的渠道商ID(充值/消耗按此渠道商归属统计与结算)
Idverified bool `protobuf:"varint,51,opt,name=idverified,proto3" json:"idverified" gorm:"idverified;index"` //@go_tags(`gorm:"idverified;index" json:"idverified"`) 是否已通过身份证实名校验(明细见 useridverify 表)
Idverifiedtime int64 `protobuf:"varint,52,opt,name=idverifiedtime,proto3" json:"idverifiedtime" gorm:"idverifiedtime"` //@go_tags(`gorm:"idverifiedtime" json:"idverifiedtime"`) 实名校验通过时间(0=未通过)
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ // 用户算力余额(充值/后台调整获得,归属账号,不过期)。
+ // 三个旧额度桶(aichatintegral/tradeintegral/meetintegral)自 2026-09 起停用、不再作为闸门,
+ // 存量值已按换算系数折进本字段(见 docs/会员权益与用量统计-设计与开发文档.md §10 存量迁移)。
+ Computebalance int64 `protobuf:"varint,53,opt,name=computebalance,proto3" json:"computebalance" gorm:"computebalance"` //@go_tags(`gorm:"computebalance" json:"computebalance"`) 算力余额(账号自有,不过期)
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *DBUser) Reset() {
@@ -539,6 +543,13 @@ func (x *DBUser) GetIdverifiedtime() int64 {
return 0
}
+func (x *DBUser) GetComputebalance() int64 {
+ if x != nil {
+ return x.Computebalance
+ }
+ return 0
+}
+
// 用户实名认证记录(表 useridverify)。一个用户一行。
//
// 合规口径:身份证号**全文不落库**——只存掩码(展示用)与 SHA-256 指纹(排查同证多号用)。
@@ -1874,8 +1885,12 @@ type DBProductionBatch struct {
Rewardvip uint32 `protobuf:"varint,15,opt,name=rewardvip,proto3" json:"rewardvip" gorm:"rewardvip"` //@go_tags(`gorm:"rewardvip" json:"rewardvip"`) 绑定赠送VIP天数(0=不送)
Rewardtranslate uint32 `protobuf:"varint,16,opt,name=rewardtranslate,proto3" json:"rewardtranslate" gorm:"rewardtranslate"` //@go_tags(`gorm:"rewardtranslate" json:"rewardtranslate"`) 绑定赠送翻译时长(分钟,0=不送)
Rewardmeeting uint32 `protobuf:"varint,17,opt,name=rewardmeeting,proto3" json:"rewardmeeting" gorm:"rewardmeeting"` //@go_tags(`gorm:"rewardmeeting" json:"rewardmeeting"`) 绑定赠送会议时长(分钟,0=不送)
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ // 2026-09 起绑定赠送只剩「VIP 天数 + VIP 等级 + 算力」三项:rewardvip 复用为天数,
+ // rewardtranslate/rewardmeeting **不再读写**(列保留,避免存量批次表结构变动)。
+ Viplevel int32 `protobuf:"varint,18,opt,name=viplevel,proto3" json:"viplevel" gorm:"viplevel"` //@go_tags(`gorm:"viplevel" json:"viplevel"`) 绑定赠送VIP等级(1=VIP 2=VIP-Pro,默认1)
+ Computegrant int64 `protobuf:"varint,19,opt,name=computegrant,proto3" json:"computegrant" gorm:"computegrant"` //@go_tags(`gorm:"computegrant" json:"computegrant"`) 绑定赠送算力(0=不送)
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *DBProductionBatch) Reset() {
@@ -2027,41 +2042,70 @@ func (x *DBProductionBatch) GetRewardmeeting() uint32 {
return 0
}
+func (x *DBProductionBatch) GetViplevel() int32 {
+ if x != nil {
+ return x.Viplevel
+ }
+ return 0
+}
+
+func (x *DBProductionBatch) GetComputegrant() int64 {
+ if x != nil {
+ return x.Computegrant
+ }
+ return 0
+}
+
// 工厂产品
type DBProduct struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id" gorm:"id;primaryKey;autoIncrement"` //@go_tags(`gorm:"id;primaryKey;autoIncrement" json:"id"`) 设备编码
- Brandid uint32 `protobuf:"varint,2,opt,name=brandid,proto3" json:"brandid" gorm:"brandid;index"` //@go_tags(`gorm:"brandid;index" json:"brandid"`) 品牌商id
- Devicetype DeviceType `protobuf:"varint,3,opt,name=devicetype,proto3,enum=DeviceType" json:"devicetype" gorm:"devicetype"` //@go_tags(`gorm:"devicetype" json:"devicetype"`) 设备类型
- Devicename string `protobuf:"bytes,4,opt,name=devicename,proto3" json:"devicename" gorm:"devicename"` //@go_tags(`gorm:"devicename" json:"devicename"`) 设备名称
- Productimage string `protobuf:"bytes,5,opt,name=productimage,proto3" json:"productimage" gorm:"productimage"` //@go_tags(`gorm:"productimage" json:"productimage"`) 产品图片
- Scanuuid string `protobuf:"bytes,6,opt,name=scanuuid,proto3" json:"scanuuid" gorm:"scanuuid"` //@go_tags(`gorm:"scanuuid" json:"scanuuid"`) 设备扫描唯一id
- Version string `protobuf:"bytes,7,opt,name=version,proto3" json:"version" gorm:"version"` //@go_tags(`gorm:"version" json:"version"`) 版本
- Updatedescription string `protobuf:"bytes,9,opt,name=updatedescription,proto3" json:"updatedescription" gorm:"updatedescription"` //@go_tags(`gorm:"updatedescription" json:"updatedescription"`) 描述
- Updatepackageaddress string `protobuf:"bytes,10,opt,name=updatepackageaddress,proto3" json:"updatepackageaddress" gorm:"updatepackageaddress"` //@go_tags(`gorm:"updatepackageaddress" json:"updatepackageaddress"`) 更新包地址=url
- Versionid uint32 `protobuf:"varint,11,opt,name=versionid,proto3" json:"versionid" gorm:"versionid"` //@go_tags(`gorm:"versionid" json:"versionid"`) 版本id
- Iscanbindagain bool `protobuf:"varint,12,opt,name=iscanbindagain,proto3" json:"iscanbindagain" gorm:"iscanbindagain"` //@go_tags(`gorm:"iscanbindagain" json:"iscanbindagain"`) 是否可以反复下发奖励
- Bindrewardvpitime uint32 `protobuf:"varint,13,opt,name=bindrewardvpitime,proto3" json:"bindrewardvpitime" gorm:"bindrewardvpitime"` //@go_tags(`gorm:"bindrewardvpitime" json:"bindrewardvpitime"`) 是否奖励vip时间
- Bindrewardtranslate uint32 `protobuf:"varint,14,opt,name=bindrewardtranslate,proto3" json:"bindrewardtranslate" gorm:"bindrewardtranslate"` //@go_tags(`gorm:"bindrewardtranslate" json:"bindrewardtranslate"`) 绑定奖励翻译次数
- Bindrewardmeeting uint32 `protobuf:"varint,15,opt,name=bindrewardmeeting,proto3" json:"bindrewardmeeting" gorm:"bindrewardmeeting"` //@go_tags(`gorm:"bindrewardmeeting" json:"bindrewardmeeting"`) 绑定奖励会议次数
- Iscontinuouschat bool `protobuf:"varint,16,opt,name=iscontinuouschat,proto3" json:"iscontinuouschat" gorm:"iscontinuouschat"` //@go_tags(`gorm:"iscontinuouschat" json:"iscontinuouschat"`) 是否连续对话
- Eqenable bool `protobuf:"varint,17,opt,name=eqenable,proto3" json:"eqenable" gorm:"eqenable"` //@go_tags(`gorm:"eqenable" json:"eqenable"`) EQ是否启用
- Keyenable bool `protobuf:"varint,18,opt,name=keyenable,proto3" json:"keyenable" gorm:"keyenable"` //@go_tags(`gorm:"keyenable" json:"keyenable"`) 按键是否启用
- Workmodeenable bool `protobuf:"varint,19,opt,name=workmodeenable,proto3" json:"workmodeenable" gorm:"workmodeenable"` //@go_tags(`gorm:"workmodeenable" json:"workmodeenable"`) 工作模式是否启用
- Callmodeenable bool `protobuf:"varint,20,opt,name=callmodeenable,proto3" json:"callmodeenable" gorm:"callmodeenable"` //@go_tags(`gorm:"callmodeenable" json:"callmodeenable"`) 通话模式是否启用
- Ledenable bool `protobuf:"varint,21,opt,name=ledenable,proto3" json:"ledenable" gorm:"ledenable"` //@go_tags(`gorm:"ledenable" json:"ledenable"`) 闪灯开关是否启用
- Finddeviceenable bool `protobuf:"varint,22,opt,name=finddeviceenable,proto3" json:"finddeviceenable" gorm:"finddeviceenable"` //@go_tags(`gorm:"finddeviceenable" json:"finddeviceenable"`) 查找设备开关是否启用
- Noisereductionenable bool `protobuf:"varint,23,opt,name=noisereductionenable,proto3" json:"noisereductionenable" gorm:"noisereductionenable"` //@go_tags(`gorm:"noisereductionenable" json:"noisereductionenable"`) 降噪开关是否启用
- Shutdownenable bool `protobuf:"varint,24,opt,name=shutdownenable,proto3" json:"shutdownenable" gorm:"shutdownenable"` //@go_tags(`gorm:"shutdownenable" json:"shutdownenable"`) 定时关机开关是否启用
- Showcasebattery bool `protobuf:"varint,25,opt,name=showcasebattery,proto3" json:"showcasebattery" gorm:"showcasebattery"` //@go_tags(`gorm:"showcasebattery" json:"showcasebattery"`) 是否显示仓盒电量
- Wakeupenable bool `protobuf:"varint,26,opt,name=wakeupenable,proto3" json:"wakeupenable" gorm:"wakeupenable"` //@go_tags(`gorm:"wakeupenable" json:"wakeupenable"`) 唤醒控制开关是否启用
- Broadcastpeertranslate bool `protobuf:"varint,27,opt,name=broadcastpeertranslate,proto3" json:"broadcastpeertranslate" gorm:"broadcastpeertranslate"` //@go_tags(`gorm:"broadcastpeertranslate" json:"broadcastpeertranslate"`) 是否播报对方翻译内容
- Channel string `protobuf:"bytes,28,opt,name=channel,proto3" json:"channel" gorm:"channel"` //@go_tags(`gorm:"channel" json:"channel"`) 产品渠道方 official/mobile
- Channelextra string `protobuf:"bytes,29,opt,name=channelextra,proto3" json:"channelextra" gorm:"channelextra"` //@go_tags(`gorm:"channelextra" json:"channelextra"`) 渠道附加数据(JSON字符串)
- Hideaiassistant bool `protobuf:"varint,30,opt,name=hideaiassistant,proto3" json:"hideaiassistant" gorm:"hideaiassistant"` //@go_tags(`gorm:"hideaiassistant" json:"hideaiassistant"`) 是否隐藏AI助手功能
- Devicecodecount uint32 `protobuf:"varint,31,opt,name=devicecodecount,proto3" json:"devicecodecount" gorm:"devicecodecount"` //@go_tags(`gorm:"devicecodecount" json:"devicecodecount"`) 每副产品生产的设备码数量(0或1=单份,2=双份)
- Bassfilter uint32 `protobuf:"varint,32,opt,name=bassfilter,proto3" json:"bassfilter" gorm:"bassfilter"` //@go_tags(`gorm:"bassfilter" json:"bassfilter"`) 过滤低音音频参数(0-1000)
- Serialcursor int64 `protobuf:"varint,33,opt,name=serialcursor,proto3" json:"serialcursor" gorm:"serialcursor"` //@go_tags(`gorm:"serialcursor" json:"serialcursor"`) 产品级授权码序号游标(累加分配)
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id" gorm:"id;primaryKey;autoIncrement"` //@go_tags(`gorm:"id;primaryKey;autoIncrement" json:"id"`) 设备编码
+ Brandid uint32 `protobuf:"varint,2,opt,name=brandid,proto3" json:"brandid" gorm:"brandid;index"` //@go_tags(`gorm:"brandid;index" json:"brandid"`) 品牌商id
+ // 【已废弃 2026-09-08】产品上的「设备类型」已删除:服务端没有任何逻辑读它,
+ // 后台表单也已去掉这一项。枚举把「连接方式」(经典/BLE单双端)和「产品形态」(录音笔/眼镜/卡片)
+ // 混在一根轴上,客户端早已另建 DeviceCategories 做品类,这里只剩误配来源。
+ // ⚠️ 字段保留为**纯下发兼容字段**,理由同下面的 factoryid:已发布的 App 包按「非空 int」解析
+ // devicetype,字段一旦不下发,整个 user_getappconfig 响应就抛 TypeError(产品表、设备列表全废)。
+ // gorm:"-" 不建列、不参与读写,恒为 0 下发。老包淘汰干净后可整条删除。
+ // ⚠️ 字段号 3 不要复用。
+ Devicetype DeviceType `protobuf:"varint,3,opt,name=devicetype,proto3,enum=DeviceType" json:"devicetype" gorm:"-"` //@go_tags(`gorm:"-" json:"devicetype"`) [兼容]已废弃,恒为0
+ Devicename string `protobuf:"bytes,4,opt,name=devicename,proto3" json:"devicename" gorm:"devicename"` //@go_tags(`gorm:"devicename" json:"devicename"`) 设备名称
+ Productimage string `protobuf:"bytes,5,opt,name=productimage,proto3" json:"productimage" gorm:"productimage"` //@go_tags(`gorm:"productimage" json:"productimage"`) 产品图片
+ // 【已废弃 2026-09-08】设备扫描UUID 已删除:设备校验早就换成按 MAC 走(device_mac 全局表,
+ // 见 comm.TableDeviceMac),绑定奖励也是按 MAC 判「这台设备有没有被绑过」(codestate),
+ // 跟这个 UUID 没有任何关系。后台产品表单里那一栏也已去掉。
+ // ⚠️ 字段保留为**纯下发兼容字段**,理由同 devicetype / factoryid:已发布的 App 包按
+ // 「非空 String」解析 scanuuid(`json['scanuuid'] as String`),字段一旦不下发,
+ // 整个 user_getappconfig 响应就抛 TypeError(产品表、设备列表全废)。
+ // gorm:"-" 不建列、不参与读写,恒为空串下发。老包淘汰干净后可整条删除。
+ // ⚠️ 字段号 6 不要复用。
+ Scanuuid string `protobuf:"bytes,6,opt,name=scanuuid,proto3" json:"scanuuid" gorm:"-"` //@go_tags(`gorm:"-" json:"scanuuid"`) [兼容]已废弃,恒为空
+ Version string `protobuf:"bytes,7,opt,name=version,proto3" json:"version" gorm:"version"` //@go_tags(`gorm:"version" json:"version"`) 版本
+ Updatedescription string `protobuf:"bytes,9,opt,name=updatedescription,proto3" json:"updatedescription" gorm:"updatedescription"` //@go_tags(`gorm:"updatedescription" json:"updatedescription"`) 描述
+ Updatepackageaddress string `protobuf:"bytes,10,opt,name=updatepackageaddress,proto3" json:"updatepackageaddress" gorm:"updatepackageaddress"` //@go_tags(`gorm:"updatepackageaddress" json:"updatepackageaddress"`) 更新包地址=url
+ Versionid uint32 `protobuf:"varint,11,opt,name=versionid,proto3" json:"versionid" gorm:"versionid"` //@go_tags(`gorm:"versionid" json:"versionid"`) 版本id
+ Iscanbindagain bool `protobuf:"varint,12,opt,name=iscanbindagain,proto3" json:"iscanbindagain" gorm:"iscanbindagain"` //@go_tags(`gorm:"iscanbindagain" json:"iscanbindagain"`) 是否可以反复下发奖励
+ Bindrewardvpitime uint32 `protobuf:"varint,13,opt,name=bindrewardvpitime,proto3" json:"bindrewardvpitime" gorm:"bindrewardvpitime"` //@go_tags(`gorm:"bindrewardvpitime" json:"bindrewardvpitime"`) 是否奖励vip时间
+ Bindrewardtranslate uint32 `protobuf:"varint,14,opt,name=bindrewardtranslate,proto3" json:"bindrewardtranslate" gorm:"bindrewardtranslate"` //@go_tags(`gorm:"bindrewardtranslate" json:"bindrewardtranslate"`) 绑定奖励翻译次数
+ Bindrewardmeeting uint32 `protobuf:"varint,15,opt,name=bindrewardmeeting,proto3" json:"bindrewardmeeting" gorm:"bindrewardmeeting"` //@go_tags(`gorm:"bindrewardmeeting" json:"bindrewardmeeting"`) 绑定奖励会议次数
+ Iscontinuouschat bool `protobuf:"varint,16,opt,name=iscontinuouschat,proto3" json:"iscontinuouschat" gorm:"iscontinuouschat"` //@go_tags(`gorm:"iscontinuouschat" json:"iscontinuouschat"`) 是否连续对话
+ Eqenable bool `protobuf:"varint,17,opt,name=eqenable,proto3" json:"eqenable" gorm:"eqenable"` //@go_tags(`gorm:"eqenable" json:"eqenable"`) EQ是否启用
+ Keyenable bool `protobuf:"varint,18,opt,name=keyenable,proto3" json:"keyenable" gorm:"keyenable"` //@go_tags(`gorm:"keyenable" json:"keyenable"`) 按键是否启用
+ Workmodeenable bool `protobuf:"varint,19,opt,name=workmodeenable,proto3" json:"workmodeenable" gorm:"workmodeenable"` //@go_tags(`gorm:"workmodeenable" json:"workmodeenable"`) 工作模式是否启用
+ Callmodeenable bool `protobuf:"varint,20,opt,name=callmodeenable,proto3" json:"callmodeenable" gorm:"callmodeenable"` //@go_tags(`gorm:"callmodeenable" json:"callmodeenable"`) 通话模式是否启用
+ Ledenable bool `protobuf:"varint,21,opt,name=ledenable,proto3" json:"ledenable" gorm:"ledenable"` //@go_tags(`gorm:"ledenable" json:"ledenable"`) 闪灯开关是否启用
+ Finddeviceenable bool `protobuf:"varint,22,opt,name=finddeviceenable,proto3" json:"finddeviceenable" gorm:"finddeviceenable"` //@go_tags(`gorm:"finddeviceenable" json:"finddeviceenable"`) 查找设备开关是否启用
+ Noisereductionenable bool `protobuf:"varint,23,opt,name=noisereductionenable,proto3" json:"noisereductionenable" gorm:"noisereductionenable"` //@go_tags(`gorm:"noisereductionenable" json:"noisereductionenable"`) 降噪开关是否启用
+ Shutdownenable bool `protobuf:"varint,24,opt,name=shutdownenable,proto3" json:"shutdownenable" gorm:"shutdownenable"` //@go_tags(`gorm:"shutdownenable" json:"shutdownenable"`) 定时关机开关是否启用
+ Showcasebattery bool `protobuf:"varint,25,opt,name=showcasebattery,proto3" json:"showcasebattery" gorm:"showcasebattery"` //@go_tags(`gorm:"showcasebattery" json:"showcasebattery"`) 是否显示仓盒电量
+ Wakeupenable bool `protobuf:"varint,26,opt,name=wakeupenable,proto3" json:"wakeupenable" gorm:"wakeupenable"` //@go_tags(`gorm:"wakeupenable" json:"wakeupenable"`) 唤醒控制开关是否启用
+ Broadcastpeertranslate bool `protobuf:"varint,27,opt,name=broadcastpeertranslate,proto3" json:"broadcastpeertranslate" gorm:"broadcastpeertranslate"` //@go_tags(`gorm:"broadcastpeertranslate" json:"broadcastpeertranslate"`) 是否播报对方翻译内容
+ Channel string `protobuf:"bytes,28,opt,name=channel,proto3" json:"channel" gorm:"channel"` //@go_tags(`gorm:"channel" json:"channel"`) 产品渠道方 official/mobile
+ Channelextra string `protobuf:"bytes,29,opt,name=channelextra,proto3" json:"channelextra" gorm:"channelextra"` //@go_tags(`gorm:"channelextra" json:"channelextra"`) 渠道附加数据(JSON字符串)
+ Hideaiassistant bool `protobuf:"varint,30,opt,name=hideaiassistant,proto3" json:"hideaiassistant" gorm:"hideaiassistant"` //@go_tags(`gorm:"hideaiassistant" json:"hideaiassistant"`) 是否隐藏AI助手功能
+ Devicecodecount uint32 `protobuf:"varint,31,opt,name=devicecodecount,proto3" json:"devicecodecount" gorm:"devicecodecount"` //@go_tags(`gorm:"devicecodecount" json:"devicecodecount"`) 每副产品生产的设备码数量(0或1=单份,2=双份)
+ Bassfilter uint32 `protobuf:"varint,32,opt,name=bassfilter,proto3" json:"bassfilter" gorm:"bassfilter"` //@go_tags(`gorm:"bassfilter" json:"bassfilter"`) 过滤低音音频参数(0-1000)
+ Serialcursor int64 `protobuf:"varint,33,opt,name=serialcursor,proto3" json:"serialcursor" gorm:"serialcursor"` //@go_tags(`gorm:"serialcursor" json:"serialcursor"`) 产品级授权码序号游标(累加分配)
// 【老客户端兼容】厂家→品牌商改名前发布的 App 包按「非空 int」解析 factoryid,
// 字段一旦缺失,整个 user_getappconfig / user_getproduct 响应解析就抛 TypeError(设备列表、应用配置全废)。
// 此字段只出不入:gorm:"-" 不建列、不参与读写,由 user 模块读取层统一填成 brandid 的值下发。
@@ -2091,8 +2135,12 @@ type DBProduct struct {
// 也没法把耳机刷回旧固件反复验证 OTA 流程。
// ⚠️ 正式产品别开:一次配置失误(比如后台挂了个旧版固件)就会把全量用户降级。
Updateonmismatch bool `protobuf:"varint,40,opt,name=updateonmismatch,proto3" json:"updateonmismatch" gorm:"updateonmismatch"` //@go_tags(`gorm:"updateonmismatch" json:"updateonmismatch"`) 版本不一致即可升级(开发用,默认关)
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ // 批次未配置赠送时的回退(与 production_batch 同语义)。bindrewardvpitime 复用为 VIP 天数,
+ // bindrewardtranslate/bindrewardmeeting 不再读写。
+ Bindviplevel int32 `protobuf:"varint,60,opt,name=bindviplevel,proto3" json:"bindviplevel" gorm:"bindviplevel"` //@go_tags(`gorm:"bindviplevel" json:"bindviplevel"`) 绑定赠送VIP等级回退值(默认1)
+ Bindcompute int64 `protobuf:"varint,61,opt,name=bindcompute,proto3" json:"bindcompute" gorm:"bindcompute"` //@go_tags(`gorm:"bindcompute" json:"bindcompute"`) 绑定赠送算力回退值(0=不送)
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *DBProduct) Reset() {
@@ -2398,6 +2446,20 @@ func (x *DBProduct) GetUpdateonmismatch() bool {
return false
}
+func (x *DBProduct) GetBindviplevel() int32 {
+ if x != nil {
+ return x.Bindviplevel
+ }
+ return 0
+}
+
+func (x *DBProduct) GetBindcompute() int64 {
+ if x != nil {
+ return x.Bindcompute
+ }
+ return 0
+}
+
type DBProductVersion struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id" gorm:"id;primaryKey;autoIncrement"` //@go_tags(`gorm:"id;primaryKey;autoIncrement" json:"id"`) 版本id
@@ -2618,20 +2680,29 @@ func (x *DBChannelApp) GetTouristsversion() string {
// 授权码数据(按产品分表 license_;核心=productid+number 产品级全局序号)
type DBAuthCode struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code" gorm:"code;primaryKey"` //@go_tags(`gorm:"code;primaryKey" json:"code"`) 授权码ID
- Codetype Codetype `protobuf:"varint,2,opt,name=codetype,proto3,enum=Codetype" json:"codetype" gorm:"codetype"` //@go_tags(`gorm:"codetype" json:"codetype"`) 授权码类型 0:认证码 1:设备码
- Brandid uint32 `protobuf:"varint,3,opt,name=brandid,proto3" json:"brandid" gorm:"brandid;index"` //@go_tags(`gorm:"brandid;index" json:"brandid"`) 品牌商id
- Productid uint32 `protobuf:"varint,4,opt,name=productid,proto3" json:"productid" gorm:"productid;index:idx_product_mac"` //@go_tags(`gorm:"productid;index:idx_product_mac" json:"productid"`) 产品id
- Number int64 `protobuf:"varint,5,opt,name=number,proto3" json:"number" gorm:"number;index"` //@go_tags(`gorm:"number;index" json:"number"`) 产品级全局序号
- Batchno string `protobuf:"bytes,6,opt,name=batchno,proto3" json:"batchno" gorm:"batchno;size:16;index"` //@go_tags(`gorm:"batchno;size:16;index" json:"batchno"`) 生产批次号(短码,引用 production_batch.batchno)
- Createtime int64 `protobuf:"varint,7,opt,name=createtime,proto3" json:"createtime" gorm:"createtime"` //@go_tags(`gorm:"createtime" json:"createtime"`) 创建时间
- Usedtime int64 `protobuf:"varint,9,opt,name=usedtime,proto3" json:"usedtime" gorm:"usedtime"` //@go_tags(`gorm:"usedtime" json:"usedtime"`) 使用时间
- Status int32 `protobuf:"varint,10,opt,name=status,proto3" json:"status" gorm:"status;index"` //@go_tags(`gorm:"status;index" json:"status"`) 状态 0:未使用 1:已使用 2:已过期
- Uid string `protobuf:"bytes,11,opt,name=uid,proto3" json:"uid" gorm:"uid:50;index"` //@go_tags(`gorm:"uid:50;index" json:"uid"`) 用户ID
- Devicemac string `protobuf:"bytes,12,opt,name=devicemac,proto3" json:"devicemac" gorm:"devicemac;index:idx_product_mac"` //@go_tags(`gorm:"devicemac;index:idx_product_mac" json:"devicemac"`) 地址
- Disabled int32 `protobuf:"varint,13,opt,name=disabled,proto3" json:"disabled" gorm:"disabled;index"` //@go_tags(`gorm:"disabled;index" json:"disabled"`) 封禁状态 0:正常 -1:禁用
- Channelid string `protobuf:"bytes,14,opt,name=channelid,proto3" json:"channelid" gorm:"channelid;size:16;index"` //@go_tags(`gorm:"channelid;size:16;index" json:"channelid"`) 渠道商id(短码,生产时随批次落库;空=未分配渠道)
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code" gorm:"code;primaryKey"` //@go_tags(`gorm:"code;primaryKey" json:"code"`) 授权码ID
+ Codetype Codetype `protobuf:"varint,2,opt,name=codetype,proto3,enum=Codetype" json:"codetype" gorm:"codetype"` //@go_tags(`gorm:"codetype" json:"codetype"`) 授权码类型 0:认证码 1:设备码
+ Brandid uint32 `protobuf:"varint,3,opt,name=brandid,proto3" json:"brandid" gorm:"brandid;index"` //@go_tags(`gorm:"brandid;index" json:"brandid"`) 品牌商id
+ Productid uint32 `protobuf:"varint,4,opt,name=productid,proto3" json:"productid" gorm:"productid;index:idx_product_mac"` //@go_tags(`gorm:"productid;index:idx_product_mac" json:"productid"`) 产品id
+ Number int64 `protobuf:"varint,5,opt,name=number,proto3" json:"number" gorm:"number;index"` //@go_tags(`gorm:"number;index" json:"number"`) 产品级全局序号
+ Batchno string `protobuf:"bytes,6,opt,name=batchno,proto3" json:"batchno" gorm:"batchno;size:16;index"` //@go_tags(`gorm:"batchno;size:16;index" json:"batchno"`) 生产批次号(短码,引用 production_batch.batchno)
+ Createtime int64 `protobuf:"varint,7,opt,name=createtime,proto3" json:"createtime" gorm:"createtime"` //@go_tags(`gorm:"createtime" json:"createtime"`) 创建时间
+ Usedtime int64 `protobuf:"varint,9,opt,name=usedtime,proto3" json:"usedtime" gorm:"usedtime"` //@go_tags(`gorm:"usedtime" json:"usedtime"`) 使用时间
+ Status int32 `protobuf:"varint,10,opt,name=status,proto3" json:"status" gorm:"status;index"` //@go_tags(`gorm:"status;index" json:"status"`) 状态 0:未使用 1:已使用 2:已过期
+ Uid string `protobuf:"bytes,11,opt,name=uid,proto3" json:"uid" gorm:"uid:50;index"` //@go_tags(`gorm:"uid:50;index" json:"uid"`) 用户ID
+ Devicemac string `protobuf:"bytes,12,opt,name=devicemac,proto3" json:"devicemac" gorm:"devicemac;index:idx_product_mac"` //@go_tags(`gorm:"devicemac;index:idx_product_mac" json:"devicemac"`) 地址
+ Disabled int32 `protobuf:"varint,13,opt,name=disabled,proto3" json:"disabled" gorm:"disabled;index"` //@go_tags(`gorm:"disabled;index" json:"disabled"`) 封禁状态 0:正常 -1:禁用
+ Channelid string `protobuf:"bytes,14,opt,name=channelid,proto3" json:"channelid" gorm:"channelid;size:16;index"` //@go_tags(`gorm:"channelid;size:16;index" json:"channelid"`) 渠道商id(短码,生产时随批次落库;空=未分配渠道)
+ // ── 设备权益快照(首次绑定=激活时从生产批次固化,此后改批次不影响已激活设备)──
+ // 设备 VIP 期 = [usedtime, usedtime + vipdays*86400),按日历天连续走、解绑不暂停;
+ // 判定时按「当前绑定着且未到期」算,**不写用户表**,解绑无需回收。
+ Vipdays int32 `protobuf:"varint,15,opt,name=vipdays,proto3" json:"vipdays" gorm:"vipdays"` //@go_tags(`gorm:"vipdays" json:"vipdays"`) 设备赠送VIP天数(激活时快照,0=不送)
+ Viplevel int32 `protobuf:"varint,16,opt,name=viplevel,proto3" json:"viplevel" gorm:"viplevel"` //@go_tags(`gorm:"viplevel" json:"viplevel"`) 设备赠送VIP等级(1=VIP 2=VIP-Pro,激活时快照)
+ // 设备赠送算力:总量快照 + 已消耗。剩余 = computegrant - computeused。
+ // computeused 解绑**不重置**:设备算力是共用一份、随设备流转,重置等于换个账号就能无限刷。
+ Computegrant int64 `protobuf:"varint,17,opt,name=computegrant,proto3" json:"computegrant" gorm:"computegrant"` //@go_tags(`gorm:"computegrant" json:"computegrant"`) 设备赠送算力总量(激活时快照,0=不送)
+ Computeused int64 `protobuf:"varint,18,opt,name=computeused,proto3" json:"computeused" gorm:"computeused"` //@go_tags(`gorm:"computeused" json:"computeused"`) 设备算力已消耗(解绑不重置)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -2757,6 +2828,34 @@ func (x *DBAuthCode) GetChannelid() string {
return ""
}
+func (x *DBAuthCode) GetVipdays() int32 {
+ if x != nil {
+ return x.Vipdays
+ }
+ return 0
+}
+
+func (x *DBAuthCode) GetViplevel() int32 {
+ if x != nil {
+ return x.Viplevel
+ }
+ return 0
+}
+
+func (x *DBAuthCode) GetComputegrant() int64 {
+ if x != nil {
+ return x.Computegrant
+ }
+ return 0
+}
+
+func (x *DBAuthCode) GetComputeused() int64 {
+ if x != nil {
+ return x.Computeused
+ }
+ return 0
+}
+
// 厂家公码(一厂家可有多码,每码独立启停且独立配置奖励;新用户扫码领奖励)
type DBFactoryPublicCode struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -2980,6 +3079,7 @@ type DBGoods struct {
Meetnum int64 `protobuf:"varint,9,opt,name=meetnum,proto3" json:"meetnum" gorm:"meetnum"` //@go_tags(`gorm:"meetnum" json:"meetnum"`) 会议使用量资源点
Tradenum int64 `protobuf:"varint,10,opt,name=tradenum,proto3" json:"tradenum" gorm:"tradenum"` //@go_tags(`gorm:"tradenum" json:"tradenum"`) 翻译资源点
Viptime int32 `protobuf:"varint,11,opt,name=viptime,proto3" json:"viptime" gorm:"viptime"` //@go_tags(`gorm:"viptime" json:"viptime"`) 会员时间
+ Compute int64 `protobuf:"varint,12,opt,name=compute,proto3" json:"compute" gorm:"compute"` //@go_tags(`gorm:"compute" json:"compute"`) 发放算力(2026-09 起 ainum/meetnum/tradenum 不再发放)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -3084,6 +3184,13 @@ func (x *DBGoods) GetViptime() int32 {
return 0
}
+func (x *DBGoods) GetCompute() int64 {
+ if x != nil {
+ return x.Compute
+ }
+ return 0
+}
+
// 语音包管理
type DBWakeupVoice struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -3181,8 +3288,14 @@ type DBUserUseLog struct {
Addtradesecond int64 `protobuf:"varint,7,opt,name=addtradesecond,proto3" json:"addtradesecond" gorm:"addtradesecond"` //@go_tags(`gorm:"addtradesecond" json:"addtradesecond"`) 翻译资源点使用时常
Addmeetsecond int64 `protobuf:"varint,8,opt,name=addmeetsecond,proto3" json:"addmeetsecond" gorm:"addmeetsecond"` //@go_tags(`gorm:"addmeetsecond" json:"addmeetsecond"`) 会议使用时常
Extra string `protobuf:"bytes,9,opt,name=extra,proto3" json:"extra" gorm:"extra"` //@go_tags(`gorm:"extra" json:"extra"`) 附加数据
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ // ── 算力记账(2026-09 起)。消耗为负、发放/调整为正;来源拆分三列之和 = compute 的绝对值 ──
+ Compute int64 `protobuf:"varint,10,opt,name=compute,proto3" json:"compute" gorm:"compute"` //@go_tags(`gorm:"compute" json:"compute"`) 本次算力变动(消耗为负)
+ Computedevice int64 `protobuf:"varint,11,opt,name=computedevice,proto3" json:"computedevice" gorm:"computedevice"` //@go_tags(`gorm:"computedevice" json:"computedevice"`) 从设备赠送额度扣的部分
+ Computeuser int64 `protobuf:"varint,12,opt,name=computeuser,proto3" json:"computeuser" gorm:"computeuser"` //@go_tags(`gorm:"computeuser" json:"computeuser"`) 从用户余额扣的部分
+ Computeover int64 `protobuf:"varint,13,opt,name=computeover,proto3" json:"computeover" gorm:"computeover"` //@go_tags(`gorm:"computeover" json:"computeover"`) 超额部分(闸门关时余额不足仍放行的量)
+ Devicemac string `protobuf:"bytes,14,opt,name=devicemac,proto3" json:"devicemac" gorm:"devicemac;size:50"` //@go_tags(`gorm:"devicemac;size:50" json:"devicemac"`) 扣了哪台设备(为空=未动设备额度)
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *DBUserUseLog) Reset() {
@@ -3278,6 +3391,41 @@ func (x *DBUserUseLog) GetExtra() string {
return ""
}
+func (x *DBUserUseLog) GetCompute() int64 {
+ if x != nil {
+ return x.Compute
+ }
+ return 0
+}
+
+func (x *DBUserUseLog) GetComputedevice() int64 {
+ if x != nil {
+ return x.Computedevice
+ }
+ return 0
+}
+
+func (x *DBUserUseLog) GetComputeuser() int64 {
+ if x != nil {
+ return x.Computeuser
+ }
+ return 0
+}
+
+func (x *DBUserUseLog) GetComputeover() int64 {
+ if x != nil {
+ return x.Computeover
+ }
+ return 0
+}
+
+func (x *DBUserUseLog) GetDevicemac() string {
+ if x != nil {
+ return x.Devicemac
+ }
+ return ""
+}
+
// 用户统计数据
type DBUserStatistics struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -3297,6 +3445,7 @@ type DBUserStatistics struct {
Trademodel4Time int64 `protobuf:"varint,14,opt,name=trademodel4time,proto3" json:"trademodel4time" gorm:"trademodel4time"` //@go_tags(`gorm:"trademodel4time" json:"trademodel4time"`) 翻译模式4时常
Trademodel5Num int32 `protobuf:"varint,15,opt,name=trademodel5num,proto3" json:"trademodel5num" gorm:"trademodel5num"` //@go_tags(`gorm:"trademodel5num" json:"trademodel5num"`) 翻译模式5次数
Tradewordcount int32 `protobuf:"varint,16,opt,name=tradewordcount,proto3" json:"tradewordcount" gorm:"tradewordcount"` //@go_tags(`gorm:"tradewordcount" json:"tradewordcount"`) 翻译文本长度
+ Computetotal int64 `protobuf:"varint,17,opt,name=computetotal,proto3" json:"computetotal" gorm:"computetotal"` //@go_tags(`gorm:"computetotal" json:"computetotal"`) 累计消耗算力
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -3443,6 +3592,13 @@ func (x *DBUserStatistics) GetTradewordcount() int32 {
return 0
}
+func (x *DBUserStatistics) GetComputetotal() int64 {
+ if x != nil {
+ return x.Computetotal
+ }
+ return 0
+}
+
// App综合统计(日/月/年三张表共用同一结构)
type DBAppStat struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -3708,7 +3864,8 @@ var File_db_proto protoreflect.FileDescriptor
const file_db_proto_rawDesc = "" +
"\n" +
"\bdb.proto\x1a\n" +
- "enum.proto\"\xf6\t\n" +
+ "enum.proto\"\x9e\n" +
+ "\n" +
"\x06DBUser\x12\x10\n" +
"\x03uid\x18\x01 \x01(\tR\x03uid\x12\x12\n" +
"\x04mail\x18\x02 \x01(\tR\x04mail\x12\x14\n" +
@@ -3763,7 +3920,8 @@ const file_db_proto_rawDesc = "" +
"\n" +
"idverified\x183 \x01(\bR\n" +
"idverified\x12&\n" +
- "\x0eidverifiedtime\x184 \x01(\x03R\x0eidverifiedtime\"\xec\x02\n" +
+ "\x0eidverifiedtime\x184 \x01(\x03R\x0eidverifiedtime\x12&\n" +
+ "\x0ecomputebalance\x185 \x01(\x03R\x0ecomputebalance\"\xec\x02\n" +
"\x0eDBUserIdVerify\x12\x10\n" +
"\x03uid\x18\x01 \x01(\tR\x03uid\x12\x1a\n" +
"\brealname\x18\x02 \x01(\tR\brealname\x12\x1e\n" +
@@ -3913,7 +4071,7 @@ const file_db_proto_rawDesc = "" +
"\x0fconfirm_account\x18\x0e \x01(\tR\x0econfirmAccount\x12!\n" +
"\fconfirm_time\x18\x0f \x01(\x03R\vconfirmTime\x12\x16\n" +
"\x06remark\x18\x10 \x01(\tR\x06remarkJ\x04\b\x02\x10\x03J\x04\b\x06\x10\aJ\x04\b\a\x10\bJ\x04\b\n" +
- "\x10\v\"\xf7\x03\n" +
+ "\x10\v\"\xb7\x04\n" +
"\x11DBProductionBatch\x12\x0e\n" +
"\x02id\x18\x01 \x01(\rR\x02id\x12\x0e\n" +
"\x02ts\x18\x02 \x01(\x03R\x02ts\x12\x18\n" +
@@ -3932,7 +4090,9 @@ const file_db_proto_rawDesc = "" +
"\tsharerate\x18\x0e \x01(\x05R\tsharerate\x12\x1c\n" +
"\trewardvip\x18\x0f \x01(\rR\trewardvip\x12(\n" +
"\x0frewardtranslate\x18\x10 \x01(\rR\x0frewardtranslate\x12$\n" +
- "\rrewardmeeting\x18\x11 \x01(\rR\rrewardmeeting\"\xb6\v\n" +
+ "\rrewardmeeting\x18\x11 \x01(\rR\rrewardmeeting\x12\x1a\n" +
+ "\bviplevel\x18\x12 \x01(\x05R\bviplevel\x12\"\n" +
+ "\fcomputegrant\x18\x13 \x01(\x03R\fcomputegrant\"\xfc\v\n" +
"\tDBProduct\x12\x0e\n" +
"\x02id\x18\x01 \x01(\rR\x02id\x12\x18\n" +
"\abrandid\x18\x02 \x01(\rR\abrandid\x12+\n" +
@@ -3983,7 +4143,9 @@ const file_db_proto_rawDesc = "" +
"createtime\x12 \n" +
"\vdescription\x18& \x01(\tR\vdescription\x12\"\n" +
"\foemfactoryid\x18' \x01(\rR\foemfactoryid\x12*\n" +
- "\x10updateonmismatch\x18( \x01(\bR\x10updateonmismatch\"\xb0\x01\n" +
+ "\x10updateonmismatch\x18( \x01(\bR\x10updateonmismatch\x12\"\n" +
+ "\fbindviplevel\x18< \x01(\x05R\fbindviplevel\x12 \n" +
+ "\vbindcompute\x18= \x01(\x03R\vbindcompute\"\xb0\x01\n" +
"\x10DBProductVersion\x12\x0e\n" +
"\x02id\x18\x01 \x01(\rR\x02id\x12\x1c\n" +
"\tproductid\x18\x02 \x01(\rR\tproductid\x12\x18\n" +
@@ -4006,7 +4168,7 @@ const file_db_proto_rawDesc = "" +
" \x01(\tR\tchangelog\x12$\n" +
"\rreviewversion\x18\v \x01(\tR\rreviewversion\x12\x16\n" +
"\x06status\x18\f \x01(\x05R\x06status\x12(\n" +
- "\x0ftouristsversion\x18\r \x01(\tR\x0ftouristsversion\"\xef\x02\n" +
+ "\x0ftouristsversion\x18\r \x01(\tR\x0ftouristsversion\"\xeb\x03\n" +
"\n" +
"DBAuthCode\x12\x12\n" +
"\x04code\x18\x01 \x01(\tR\x04code\x12%\n" +
@@ -4024,7 +4186,11 @@ const file_db_proto_rawDesc = "" +
"\x03uid\x18\v \x01(\tR\x03uid\x12\x1c\n" +
"\tdevicemac\x18\f \x01(\tR\tdevicemac\x12\x1a\n" +
"\bdisabled\x18\r \x01(\x05R\bdisabled\x12\x1c\n" +
- "\tchannelid\x18\x0e \x01(\tR\tchannelid\"\xb5\x03\n" +
+ "\tchannelid\x18\x0e \x01(\tR\tchannelid\x12\x18\n" +
+ "\avipdays\x18\x0f \x01(\x05R\avipdays\x12\x1a\n" +
+ "\bviplevel\x18\x10 \x01(\x05R\bviplevel\x12\"\n" +
+ "\fcomputegrant\x18\x11 \x01(\x03R\fcomputegrant\x12 \n" +
+ "\vcomputeused\x18\x12 \x01(\x03R\vcomputeused\"\xb5\x03\n" +
"\x13DBFactoryPublicCode\x12\x12\n" +
"\x04code\x18\x01 \x01(\tR\x04code\x12\x18\n" +
"\abrandid\x18\x02 \x01(\rR\abrandid\x12\x1c\n" +
@@ -4050,7 +4216,7 @@ const file_db_proto_rawDesc = "" +
"\x04lang\x18\x01 \x01(\tR\x04lang\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x1a\n" +
"\bcurrency\x18\x03 \x01(\tR\bcurrency\x12\x14\n" +
- "\x05price\x18\x06 \x01(\x01R\x05price\"\x89\x02\n" +
+ "\x05price\x18\x06 \x01(\x01R\x05price\"\xa3\x02\n" +
"\aDBGoods\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x16\n" +
"\x06enable\x18\x02 \x01(\bR\x06enable\x12\x12\n" +
@@ -4063,14 +4229,15 @@ const file_db_proto_rawDesc = "" +
"\ameetnum\x18\t \x01(\x03R\ameetnum\x12\x1a\n" +
"\btradenum\x18\n" +
" \x01(\x03R\btradenum\x12\x18\n" +
- "\aviptime\x18\v \x01(\x05R\aviptime\"\x9f\x01\n" +
+ "\aviptime\x18\v \x01(\x05R\aviptime\x12\x18\n" +
+ "\acompute\x18\f \x01(\x03R\acompute\"\x9f\x01\n" +
"\rDBWakeupVoice\x12\x0e\n" +
"\x02id\x18\x01 \x01(\rR\x02id\x12\x16\n" +
"\x06enable\x18\x02 \x01(\bR\x06enable\x12\x12\n" +
"\x04name\x18\x03 \x01(\tR\x04name\x12\x12\n" +
"\x04desc\x18\x04 \x01(\tR\x04desc\x12\x18\n" +
"\aversion\x18\x05 \x01(\tR\aversion\x12$\n" +
- "\rpackagaddress\x18\x06 \x01(\tR\rpackagaddress\"\x96\x02\n" +
+ "\rpackagaddress\x18\x06 \x01(\tR\rpackagaddress\"\xb8\x03\n" +
"\fDBUserUseLog\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x04R\x02id\x12\x10\n" +
"\x03uid\x18\x02 \x01(\tR\x03uid\x12\x0e\n" +
@@ -4080,7 +4247,13 @@ const file_db_proto_rawDesc = "" +
"\x10addagentintegral\x18\x06 \x01(\x03R\x10addagentintegral\x12&\n" +
"\x0eaddtradesecond\x18\a \x01(\x03R\x0eaddtradesecond\x12$\n" +
"\raddmeetsecond\x18\b \x01(\x03R\raddmeetsecond\x12\x14\n" +
- "\x05extra\x18\t \x01(\tR\x05extra\"\xe0\x04\n" +
+ "\x05extra\x18\t \x01(\tR\x05extra\x12\x18\n" +
+ "\acompute\x18\n" +
+ " \x01(\x03R\acompute\x12$\n" +
+ "\rcomputedevice\x18\v \x01(\x03R\rcomputedevice\x12 \n" +
+ "\vcomputeuser\x18\f \x01(\x03R\vcomputeuser\x12 \n" +
+ "\vcomputeover\x18\r \x01(\x03R\vcomputeover\x12\x1c\n" +
+ "\tdevicemac\x18\x0e \x01(\tR\tdevicemac\"\x84\x05\n" +
"\x10DBUserStatistics\x12\x10\n" +
"\x03uid\x18\x01 \x01(\tR\x03uid\x12\x1c\n" +
"\taichatnum\x18\x02 \x01(\x03R\taichatnum\x12$\n" +
@@ -4098,7 +4271,8 @@ const file_db_proto_rawDesc = "" +
"\x0etrademodel4num\x18\r \x01(\x05R\x0etrademodel4num\x12(\n" +
"\x0ftrademodel4time\x18\x0e \x01(\x03R\x0ftrademodel4time\x12&\n" +
"\x0etrademodel5num\x18\x0f \x01(\x05R\x0etrademodel5num\x12&\n" +
- "\x0etradewordcount\x18\x10 \x01(\x05R\x0etradewordcount\"\xca\a\n" +
+ "\x0etradewordcount\x18\x10 \x01(\x05R\x0etradewordcount\x12\"\n" +
+ "\fcomputetotal\x18\x11 \x01(\x03R\fcomputetotal\"\xca\a\n" +
"\tDBAppStat\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x04R\x02id\x12\x1b\n" +
"\tstat_date\x18\x02 \x01(\tR\bstatDate\x12\x1d\n" +
diff --git a/apps/services/pb/enum.pb.go b/apps/services/pb/enum.pb.go
index 9412fc8f..66f750b2 100644
--- a/apps/services/pb/enum.pb.go
+++ b/apps/services/pb/enum.pb.go
@@ -266,6 +266,10 @@ const (
// @Description 购买VIP时长
// @example
UsageType_VIP UsageType = 4 //购买VIP时长
+ // 购买算力
+ // @Description 购买算力包(2026-09 起;商品的 compute 字段即本次发放量,归账号、不过期)
+ // @example
+ UsageType_Compute UsageType = 5 //购买算力
)
// Enum value maps for UsageType.
@@ -276,6 +280,7 @@ var (
2: "Meet",
3: "AI",
4: "VIP",
+ 5: "Compute",
}
UsageType_value = map[string]int32{
"UsageNull": 0,
@@ -283,6 +288,7 @@ var (
"Meet": 2,
"AI": 3,
"VIP": 4,
+ "Compute": 5,
}
)
@@ -336,6 +342,10 @@ const (
// @Description 用户消耗
// @example
UserLogType_UserConsume UserLogType = 4 //用户消耗
+ // 后台调整算力
+ // @Description 后台调整用户算力余额(加减)
+ // @example
+ UserLogType_AdminAdjust UserLogType = 5 //后台调整算力
)
// Enum value maps for UserLogType.
@@ -346,6 +356,7 @@ var (
2: "ActivityReward",
3: "AdminGive",
4: "UserConsume",
+ 5: "AdminAdjust",
}
UserLogType_value = map[string]int32{
"UserLogTypeUnknown": 0,
@@ -353,6 +364,7 @@ var (
"ActivityReward": 2,
"AdminGive": 3,
"UserConsume": 4,
+ "AdminAdjust": 5,
}
)
@@ -659,19 +671,21 @@ const file_enum_proto_rawDesc = "" +
"\x1cBLE_Bluetooth_Single_Headset\x10\x03\x12\x18\n" +
"\x14Smart_Voice_Recorder\x10\x04\x12\f\n" +
"\bSmartEye\x10\x05\x12\x12\n" +
- "\x0eActivationCard\x10\x06*@\n" +
+ "\x0eActivationCard\x10\x06*M\n" +
"\tUsageType\x12\r\n" +
"\tUsageNull\x10\x00\x12\t\n" +
"\x05Trade\x10\x01\x12\b\n" +
"\x04Meet\x10\x02\x12\x06\n" +
"\x02AI\x10\x03\x12\a\n" +
- "\x03VIP\x10\x04*b\n" +
+ "\x03VIP\x10\x04\x12\v\n" +
+ "\aCompute\x10\x05*s\n" +
"\vUserLogType\x12\x16\n" +
"\x12UserLogTypeUnknown\x10\x00\x12\a\n" +
"\x03Pay\x10\x01\x12\x12\n" +
"\x0eActivityReward\x10\x02\x12\r\n" +
"\tAdminGive\x10\x03\x12\x0f\n" +
- "\vUserConsume\x10\x04*\"\n" +
+ "\vUserConsume\x10\x04\x12\x0f\n" +
+ "\vAdminAdjust\x10\x05*\"\n" +
"\rMCPServerType\x12\b\n" +
"\x04Http\x10\x00\x12\a\n" +
"\x03SEE\x10\x01*m\n" +
diff --git a/apps/services/pb/errorcode.pb.go b/apps/services/pb/errorcode.pb.go
index 68f100b1..0491cc87 100644
--- a/apps/services/pb/errorcode.pb.go
+++ b/apps/services/pb/errorcode.pb.go
@@ -116,6 +116,10 @@ const (
ErrorCode_TranslateNotConfigured ErrorCode = 5101 //未配置机器翻译服务(后台第三方服务配置里没有启用的 MT 服务)
ErrorCode_TranslateParamInvalid ErrorCode = 5102 //翻译参数不合法(原文为空 / 目标语言为空)
ErrorCode_TranslateProviderError ErrorCode = 5103 //翻译服务商调用失败
+ // 会员与算力相关错误码 5301-5303
+ ErrorCode_ComputeNotEnough ErrorCode = 5301 //算力不足(仅算力闸门开启时返回)
+ ErrorCode_VipRequired ErrorCode = 5302 //需要会员(翻译/会议纪要在 VIP 过期后受限;提示里要同时给出「续费」与「绑定带会员的设备」两条路)
+ ErrorCode_ComputeBalanceNegative ErrorCode = 5303 //后台调整算力后余额将为负,拒绝
)
// Enum value maps for ErrorCode.
@@ -200,6 +204,9 @@ var (
5101: "TranslateNotConfigured",
5102: "TranslateParamInvalid",
5103: "TranslateProviderError",
+ 5301: "ComputeNotEnough",
+ 5302: "VipRequired",
+ 5303: "ComputeBalanceNegative",
}
ErrorCode_value = map[string]int32{
"Success": 0,
@@ -281,6 +288,9 @@ var (
"TranslateNotConfigured": 5101,
"TranslateParamInvalid": 5102,
"TranslateProviderError": 5103,
+ "ComputeNotEnough": 5301,
+ "VipRequired": 5302,
+ "ComputeBalanceNegative": 5303,
}
)
@@ -315,7 +325,7 @@ var File_errorcode_proto protoreflect.FileDescriptor
const file_errorcode_proto_rawDesc = "" +
"\n" +
- "\x0ferrorcode.proto*\x88\x0e\n" +
+ "\x0ferrorcode.proto*\xce\x0e\n" +
"\tErrorCode\x12\v\n" +
"\aSuccess\x10\x00\x12\x14\n" +
"\x10GatewayException\x10\x01\x12\x11\n" +
@@ -398,7 +408,10 @@ const file_errorcode_proto_rawDesc = "" +
"\x12MemoryReportFailed\x10\xd6(\x12\x1b\n" +
"\x16TranslateNotConfigured\x10\xed'\x12\x1a\n" +
"\x15TranslateParamInvalid\x10\xee'\x12\x1b\n" +
- "\x16TranslateProviderError\x10\xef'B\x06Z\x04.;pbb\x06proto3"
+ "\x16TranslateProviderError\x10\xef'\x12\x15\n" +
+ "\x10ComputeNotEnough\x10\xb5)\x12\x10\n" +
+ "\vVipRequired\x10\xb6)\x12\x1b\n" +
+ "\x16ComputeBalanceNegative\x10\xb7)B\x06Z\x04.;pbb\x06proto3"
var (
file_errorcode_proto_rawDescOnce sync.Once
diff --git a/apps/services/pb/user_msg.pb.go b/apps/services/pb/user_msg.pb.go
index 728ceb2d..ba503690 100644
--- a/apps/services/pb/user_msg.pb.go
+++ b/apps/services/pb/user_msg.pb.go
@@ -653,6 +653,9 @@ type UserBindDeviceResp struct {
Tradetotalintegral int64 `protobuf:"varint,8,opt,name=tradetotalintegral,proto3" json:"tradetotalintegral" gorm:"tradetotalintegral"` //@go_tags(`gorm:"tradetotalintegral" json:"tradetotalintegral"`) 翻译总积分
Meetintegral int64 `protobuf:"varint,9,opt,name=meetintegral,proto3" json:"meetintegral" gorm:"meetintegral"` //@go_tags(`gorm:"meetintegral" json:"meetintegral"`) 会议积分
Meettotalintegral int64 `protobuf:"varint,10,opt,name=meettotalintegral,proto3" json:"meettotalintegral" gorm:"meettotalintegral"` //@go_tags(`gorm:"meettotalintegral" json:"meettotalintegral"`) 会议总积分
+ Devicevipexptime int64 `protobuf:"varint,11,opt,name=devicevipexptime,proto3" json:"devicevipexptime,omitempty"` //这台设备赋予的 VIP 到期时间(0=不送)
+ Deviceviplevel int32 `protobuf:"varint,12,opt,name=deviceviplevel,proto3" json:"deviceviplevel,omitempty"` //这台设备赋予的 VIP 等级
+ Devicecomputeleft int64 `protobuf:"varint,13,opt,name=devicecomputeleft,proto3" json:"devicecomputeleft,omitempty"` //这台设备剩余算力
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -757,6 +760,27 @@ func (x *UserBindDeviceResp) GetMeettotalintegral() int64 {
return 0
}
+func (x *UserBindDeviceResp) GetDevicevipexptime() int64 {
+ if x != nil {
+ return x.Devicevipexptime
+ }
+ return 0
+}
+
+func (x *UserBindDeviceResp) GetDeviceviplevel() int32 {
+ if x != nil {
+ return x.Deviceviplevel
+ }
+ return 0
+}
+
+func (x *UserBindDeviceResp) GetDevicecomputeleft() int64 {
+ if x != nil {
+ return x.Devicecomputeleft
+ }
+ return 0
+}
+
// 解绑定耳机请求
type UserUnBindDeviceReq struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -2029,7 +2053,9 @@ func (x *UserGetDevicesResp) GetDevices() []*DBUserDivice {
type UserUsagesReq struct {
state protoimpl.MessageState `protogen:"open.v1"`
Usagetype UsageType `protobuf:"varint,1,opt,name=usagetype,proto3,enum=UsageType" json:"usagetype,omitempty"` //使用类型
- Usages map[string]int64 `protobuf:"bytes,2,rep,name=usages,proto3" json:"usages,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` //用量统计数据
+ Usages map[string]int64 `protobuf:"bytes,2,rep,name=usages,proto3" json:"usages,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` //用量统计数据(翻译:各模式秒数;会议:MEETING_ASR_AUDIO_SECONDS;AI:可不传,改用下面两项)
+ Upchars int64 `protobuf:"varint,3,opt,name=upchars,proto3" json:"upchars,omitempty"` //AI 对话本次输入字符数(Unicode 码点,仅 usagetype=AI 时有效)
+ Downchars int64 `protobuf:"varint,4,opt,name=downchars,proto3" json:"downchars,omitempty"` //AI 对话本次输出字符数(同上)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -2078,11 +2104,29 @@ func (x *UserUsagesReq) GetUsages() map[string]int64 {
return nil
}
+func (x *UserUsagesReq) GetUpchars() int64 {
+ if x != nil {
+ return x.Upchars
+ }
+ return 0
+}
+
+func (x *UserUsagesReq) GetDownchars() int64 {
+ if x != nil {
+ return x.Downchars
+ }
+ return 0
+}
+
type UserUsagesResp struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- Usages map[int32]int64 `protobuf:"bytes,1,rep,name=usages,proto3" json:"usages,omitempty" protobuf_key:"varint,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` //资源剩余使用量
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Usages map[int32]int64 `protobuf:"bytes,1,rep,name=usages,proto3" json:"usages,omitempty" protobuf_key:"varint,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` //【兼容】旧三桶剩余量,2026-09 起恒为 0,老客户端仍按此字段解析
+ Compute int64 `protobuf:"varint,2,opt,name=compute,proto3" json:"compute,omitempty"` //本次扣减的算力
+ Computedeviceleft int64 `protobuf:"varint,3,opt,name=computedeviceleft,proto3" json:"computedeviceleft,omitempty"` //当前绑定设备的剩余算力合计
+ Computeuserleft int64 `protobuf:"varint,4,opt,name=computeuserleft,proto3" json:"computeuserleft,omitempty"` //用户自有算力余额
+ Over int64 `protobuf:"varint,5,opt,name=over,proto3" json:"over,omitempty"` //超额(闸门关时余额不足仍放行的部分;闸门开时恒为 0)
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *UserUsagesResp) Reset() {
@@ -2122,6 +2166,436 @@ func (x *UserUsagesResp) GetUsages() map[int32]int64 {
return nil
}
+func (x *UserUsagesResp) GetCompute() int64 {
+ if x != nil {
+ return x.Compute
+ }
+ return 0
+}
+
+func (x *UserUsagesResp) GetComputedeviceleft() int64 {
+ if x != nil {
+ return x.Computedeviceleft
+ }
+ return 0
+}
+
+func (x *UserUsagesResp) GetComputeuserleft() int64 {
+ if x != nil {
+ return x.Computeuserleft
+ }
+ return 0
+}
+
+func (x *UserUsagesResp) GetOver() int64 {
+ if x != nil {
+ return x.Over
+ }
+ return 0
+}
+
+// 获取会员与算力状态(客户端「用量」页一次拿齐,客户端不做任何换算)
+type UserGetComputeReq struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *UserGetComputeReq) Reset() {
+ *x = UserGetComputeReq{}
+ mi := &file_user_user_msg_proto_msgTypes[37]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *UserGetComputeReq) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UserGetComputeReq) ProtoMessage() {}
+
+func (x *UserGetComputeReq) ProtoReflect() protoreflect.Message {
+ mi := &file_user_user_msg_proto_msgTypes[37]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use UserGetComputeReq.ProtoReflect.Descriptor instead.
+func (*UserGetComputeReq) Descriptor() ([]byte, []int) {
+ return file_user_user_msg_proto_rawDescGZIP(), []int{37}
+}
+
+type UserComputeVipInfo struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Level int32 `protobuf:"varint,1,opt,name=level,proto3" json:"level,omitempty"` //实际 VIP 等级 = max(自购, 设备赋予);0 = 无 VIP
+ Exptime int64 `protobuf:"varint,2,opt,name=exptime,proto3" json:"exptime,omitempty"` //实际到期时间(自购与各设备取最晚)
+ Selfexptime int64 `protobuf:"varint,3,opt,name=selfexptime,proto3" json:"selfexptime,omitempty"` //自购 VIP 到期时间
+ Devices []*UserDeviceVipInfo `protobuf:"bytes,4,rep,name=devices,proto3" json:"devices,omitempty"` //各绑定设备赋予的 VIP
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *UserComputeVipInfo) Reset() {
+ *x = UserComputeVipInfo{}
+ mi := &file_user_user_msg_proto_msgTypes[38]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *UserComputeVipInfo) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UserComputeVipInfo) ProtoMessage() {}
+
+func (x *UserComputeVipInfo) ProtoReflect() protoreflect.Message {
+ mi := &file_user_user_msg_proto_msgTypes[38]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use UserComputeVipInfo.ProtoReflect.Descriptor instead.
+func (*UserComputeVipInfo) Descriptor() ([]byte, []int) {
+ return file_user_user_msg_proto_rawDescGZIP(), []int{38}
+}
+
+func (x *UserComputeVipInfo) GetLevel() int32 {
+ if x != nil {
+ return x.Level
+ }
+ return 0
+}
+
+func (x *UserComputeVipInfo) GetExptime() int64 {
+ if x != nil {
+ return x.Exptime
+ }
+ return 0
+}
+
+func (x *UserComputeVipInfo) GetSelfexptime() int64 {
+ if x != nil {
+ return x.Selfexptime
+ }
+ return 0
+}
+
+func (x *UserComputeVipInfo) GetDevices() []*UserDeviceVipInfo {
+ if x != nil {
+ return x.Devices
+ }
+ return nil
+}
+
+type UserDeviceVipInfo struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Devicemac string `protobuf:"bytes,1,opt,name=devicemac,proto3" json:"devicemac,omitempty"`
+ Devicename string `protobuf:"bytes,2,opt,name=devicename,proto3" json:"devicename,omitempty"`
+ Level int32 `protobuf:"varint,3,opt,name=level,proto3" json:"level,omitempty"`
+ Exptime int64 `protobuf:"varint,4,opt,name=exptime,proto3" json:"exptime,omitempty"` //usedtime + vipdays*86400
+ Computegrant int64 `protobuf:"varint,5,opt,name=computegrant,proto3" json:"computegrant,omitempty"` //设备赠送算力总量
+ Computeused int64 `protobuf:"varint,6,opt,name=computeused,proto3" json:"computeused,omitempty"` //已消耗
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *UserDeviceVipInfo) Reset() {
+ *x = UserDeviceVipInfo{}
+ mi := &file_user_user_msg_proto_msgTypes[39]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *UserDeviceVipInfo) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UserDeviceVipInfo) ProtoMessage() {}
+
+func (x *UserDeviceVipInfo) ProtoReflect() protoreflect.Message {
+ mi := &file_user_user_msg_proto_msgTypes[39]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use UserDeviceVipInfo.ProtoReflect.Descriptor instead.
+func (*UserDeviceVipInfo) Descriptor() ([]byte, []int) {
+ return file_user_user_msg_proto_rawDescGZIP(), []int{39}
+}
+
+func (x *UserDeviceVipInfo) GetDevicemac() string {
+ if x != nil {
+ return x.Devicemac
+ }
+ return ""
+}
+
+func (x *UserDeviceVipInfo) GetDevicename() string {
+ if x != nil {
+ return x.Devicename
+ }
+ return ""
+}
+
+func (x *UserDeviceVipInfo) GetLevel() int32 {
+ if x != nil {
+ return x.Level
+ }
+ return 0
+}
+
+func (x *UserDeviceVipInfo) GetExptime() int64 {
+ if x != nil {
+ return x.Exptime
+ }
+ return 0
+}
+
+func (x *UserDeviceVipInfo) GetComputegrant() int64 {
+ if x != nil {
+ return x.Computegrant
+ }
+ return 0
+}
+
+func (x *UserDeviceVipInfo) GetComputeused() int64 {
+ if x != nil {
+ return x.Computeused
+ }
+ return 0
+}
+
+type UserComputeInfo struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Deviceleft int64 `protobuf:"varint,1,opt,name=deviceleft,proto3" json:"deviceleft,omitempty"` //各绑定设备剩余算力合计
+ Userbalance int64 `protobuf:"varint,2,opt,name=userbalance,proto3" json:"userbalance,omitempty"` //用户自有余额
+ Totalused int64 `protobuf:"varint,3,opt,name=totalused,proto3" json:"totalused,omitempty"` //累计消耗
+ Monthused int64 `protobuf:"varint,4,opt,name=monthused,proto3" json:"monthused,omitempty"` //本月消耗
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *UserComputeInfo) Reset() {
+ *x = UserComputeInfo{}
+ mi := &file_user_user_msg_proto_msgTypes[40]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *UserComputeInfo) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UserComputeInfo) ProtoMessage() {}
+
+func (x *UserComputeInfo) ProtoReflect() protoreflect.Message {
+ mi := &file_user_user_msg_proto_msgTypes[40]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use UserComputeInfo.ProtoReflect.Descriptor instead.
+func (*UserComputeInfo) Descriptor() ([]byte, []int) {
+ return file_user_user_msg_proto_rawDescGZIP(), []int{40}
+}
+
+func (x *UserComputeInfo) GetDeviceleft() int64 {
+ if x != nil {
+ return x.Deviceleft
+ }
+ return 0
+}
+
+func (x *UserComputeInfo) GetUserbalance() int64 {
+ if x != nil {
+ return x.Userbalance
+ }
+ return 0
+}
+
+func (x *UserComputeInfo) GetTotalused() int64 {
+ if x != nil {
+ return x.Totalused
+ }
+ return 0
+}
+
+func (x *UserComputeInfo) GetMonthused() int64 {
+ if x != nil {
+ return x.Monthused
+ }
+ return 0
+}
+
+type UserComputeRates struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Translate int64 `protobuf:"varint,1,opt,name=translate,proto3" json:"translate,omitempty"` //算力 / 分钟
+ Meeting int64 `protobuf:"varint,2,opt,name=meeting,proto3" json:"meeting,omitempty"` //算力 / 分钟
+ Aichat int64 `protobuf:"varint,3,opt,name=aichat,proto3" json:"aichat,omitempty"` //算力 / 100 字
+ Gate bool `protobuf:"varint,4,opt,name=gate,proto3" json:"gate,omitempty"` //算力闸门是否开启
+ // 运营阈值(后台可配,客户端据此提前提示;0 = 不提示)
+ Vipwarndays int32 `protobuf:"varint,5,opt,name=vipwarndays,proto3" json:"vipwarndays,omitempty"` //VIP 到期前多少天开始提示
+ Computewarn int64 `protobuf:"varint,6,opt,name=computewarn,proto3" json:"computewarn,omitempty"` //算力低于多少时开始提示
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *UserComputeRates) Reset() {
+ *x = UserComputeRates{}
+ mi := &file_user_user_msg_proto_msgTypes[41]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *UserComputeRates) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UserComputeRates) ProtoMessage() {}
+
+func (x *UserComputeRates) ProtoReflect() protoreflect.Message {
+ mi := &file_user_user_msg_proto_msgTypes[41]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use UserComputeRates.ProtoReflect.Descriptor instead.
+func (*UserComputeRates) Descriptor() ([]byte, []int) {
+ return file_user_user_msg_proto_rawDescGZIP(), []int{41}
+}
+
+func (x *UserComputeRates) GetTranslate() int64 {
+ if x != nil {
+ return x.Translate
+ }
+ return 0
+}
+
+func (x *UserComputeRates) GetMeeting() int64 {
+ if x != nil {
+ return x.Meeting
+ }
+ return 0
+}
+
+func (x *UserComputeRates) GetAichat() int64 {
+ if x != nil {
+ return x.Aichat
+ }
+ return 0
+}
+
+func (x *UserComputeRates) GetGate() bool {
+ if x != nil {
+ return x.Gate
+ }
+ return false
+}
+
+func (x *UserComputeRates) GetVipwarndays() int32 {
+ if x != nil {
+ return x.Vipwarndays
+ }
+ return 0
+}
+
+func (x *UserComputeRates) GetComputewarn() int64 {
+ if x != nil {
+ return x.Computewarn
+ }
+ return 0
+}
+
+type UserGetComputeResp struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Vip *UserComputeVipInfo `protobuf:"bytes,1,opt,name=vip,proto3" json:"vip,omitempty"`
+ Compute *UserComputeInfo `protobuf:"bytes,2,opt,name=compute,proto3" json:"compute,omitempty"`
+ Rates *UserComputeRates `protobuf:"bytes,3,opt,name=rates,proto3" json:"rates,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *UserGetComputeResp) Reset() {
+ *x = UserGetComputeResp{}
+ mi := &file_user_user_msg_proto_msgTypes[42]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *UserGetComputeResp) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UserGetComputeResp) ProtoMessage() {}
+
+func (x *UserGetComputeResp) ProtoReflect() protoreflect.Message {
+ mi := &file_user_user_msg_proto_msgTypes[42]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use UserGetComputeResp.ProtoReflect.Descriptor instead.
+func (*UserGetComputeResp) Descriptor() ([]byte, []int) {
+ return file_user_user_msg_proto_rawDescGZIP(), []int{42}
+}
+
+func (x *UserGetComputeResp) GetVip() *UserComputeVipInfo {
+ if x != nil {
+ return x.Vip
+ }
+ return nil
+}
+
+func (x *UserGetComputeResp) GetCompute() *UserComputeInfo {
+ if x != nil {
+ return x.Compute
+ }
+ return nil
+}
+
+func (x *UserGetComputeResp) GetRates() *UserComputeRates {
+ if x != nil {
+ return x.Rates
+ }
+ return nil
+}
+
// 获取唤醒词列表
type UserGetWakeupVoicesReq struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -2131,7 +2605,7 @@ type UserGetWakeupVoicesReq struct {
func (x *UserGetWakeupVoicesReq) Reset() {
*x = UserGetWakeupVoicesReq{}
- mi := &file_user_user_msg_proto_msgTypes[37]
+ mi := &file_user_user_msg_proto_msgTypes[43]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2143,7 +2617,7 @@ func (x *UserGetWakeupVoicesReq) String() string {
func (*UserGetWakeupVoicesReq) ProtoMessage() {}
func (x *UserGetWakeupVoicesReq) ProtoReflect() protoreflect.Message {
- mi := &file_user_user_msg_proto_msgTypes[37]
+ mi := &file_user_user_msg_proto_msgTypes[43]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2156,7 +2630,7 @@ func (x *UserGetWakeupVoicesReq) ProtoReflect() protoreflect.Message {
// Deprecated: Use UserGetWakeupVoicesReq.ProtoReflect.Descriptor instead.
func (*UserGetWakeupVoicesReq) Descriptor() ([]byte, []int) {
- return file_user_user_msg_proto_rawDescGZIP(), []int{37}
+ return file_user_user_msg_proto_rawDescGZIP(), []int{43}
}
type UserGetWakeupVoicesResp struct {
@@ -2168,7 +2642,7 @@ type UserGetWakeupVoicesResp struct {
func (x *UserGetWakeupVoicesResp) Reset() {
*x = UserGetWakeupVoicesResp{}
- mi := &file_user_user_msg_proto_msgTypes[38]
+ mi := &file_user_user_msg_proto_msgTypes[44]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2180,7 +2654,7 @@ func (x *UserGetWakeupVoicesResp) String() string {
func (*UserGetWakeupVoicesResp) ProtoMessage() {}
func (x *UserGetWakeupVoicesResp) ProtoReflect() protoreflect.Message {
- mi := &file_user_user_msg_proto_msgTypes[38]
+ mi := &file_user_user_msg_proto_msgTypes[44]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2193,7 +2667,7 @@ func (x *UserGetWakeupVoicesResp) ProtoReflect() protoreflect.Message {
// Deprecated: Use UserGetWakeupVoicesResp.ProtoReflect.Descriptor instead.
func (*UserGetWakeupVoicesResp) Descriptor() ([]byte, []int) {
- return file_user_user_msg_proto_rawDescGZIP(), []int{38}
+ return file_user_user_msg_proto_rawDescGZIP(), []int{44}
}
func (x *UserGetWakeupVoicesResp) GetVoices() []*DBWakeupVoice {
@@ -2212,7 +2686,7 @@ type UserGetStatisticsReq struct {
func (x *UserGetStatisticsReq) Reset() {
*x = UserGetStatisticsReq{}
- mi := &file_user_user_msg_proto_msgTypes[39]
+ mi := &file_user_user_msg_proto_msgTypes[45]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2224,7 +2698,7 @@ func (x *UserGetStatisticsReq) String() string {
func (*UserGetStatisticsReq) ProtoMessage() {}
func (x *UserGetStatisticsReq) ProtoReflect() protoreflect.Message {
- mi := &file_user_user_msg_proto_msgTypes[39]
+ mi := &file_user_user_msg_proto_msgTypes[45]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2237,7 +2711,7 @@ func (x *UserGetStatisticsReq) ProtoReflect() protoreflect.Message {
// Deprecated: Use UserGetStatisticsReq.ProtoReflect.Descriptor instead.
func (*UserGetStatisticsReq) Descriptor() ([]byte, []int) {
- return file_user_user_msg_proto_rawDescGZIP(), []int{39}
+ return file_user_user_msg_proto_rawDescGZIP(), []int{45}
}
type UserGetStatisticsResp struct {
@@ -2249,7 +2723,7 @@ type UserGetStatisticsResp struct {
func (x *UserGetStatisticsResp) Reset() {
*x = UserGetStatisticsResp{}
- mi := &file_user_user_msg_proto_msgTypes[40]
+ mi := &file_user_user_msg_proto_msgTypes[46]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2261,7 +2735,7 @@ func (x *UserGetStatisticsResp) String() string {
func (*UserGetStatisticsResp) ProtoMessage() {}
func (x *UserGetStatisticsResp) ProtoReflect() protoreflect.Message {
- mi := &file_user_user_msg_proto_msgTypes[40]
+ mi := &file_user_user_msg_proto_msgTypes[46]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2274,7 +2748,7 @@ func (x *UserGetStatisticsResp) ProtoReflect() protoreflect.Message {
// Deprecated: Use UserGetStatisticsResp.ProtoReflect.Descriptor instead.
func (*UserGetStatisticsResp) Descriptor() ([]byte, []int) {
- return file_user_user_msg_proto_rawDescGZIP(), []int{40}
+ return file_user_user_msg_proto_rawDescGZIP(), []int{46}
}
func (x *UserGetStatisticsResp) GetStatistics() *DBUserStatistics {
@@ -2293,7 +2767,7 @@ type UserGetAppSettingReq struct {
func (x *UserGetAppSettingReq) Reset() {
*x = UserGetAppSettingReq{}
- mi := &file_user_user_msg_proto_msgTypes[41]
+ mi := &file_user_user_msg_proto_msgTypes[47]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2305,7 +2779,7 @@ func (x *UserGetAppSettingReq) String() string {
func (*UserGetAppSettingReq) ProtoMessage() {}
func (x *UserGetAppSettingReq) ProtoReflect() protoreflect.Message {
- mi := &file_user_user_msg_proto_msgTypes[41]
+ mi := &file_user_user_msg_proto_msgTypes[47]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2318,7 +2792,7 @@ func (x *UserGetAppSettingReq) ProtoReflect() protoreflect.Message {
// Deprecated: Use UserGetAppSettingReq.ProtoReflect.Descriptor instead.
func (*UserGetAppSettingReq) Descriptor() ([]byte, []int) {
- return file_user_user_msg_proto_rawDescGZIP(), []int{41}
+ return file_user_user_msg_proto_rawDescGZIP(), []int{47}
}
type UserGetAppSettingResp struct {
@@ -2330,7 +2804,7 @@ type UserGetAppSettingResp struct {
func (x *UserGetAppSettingResp) Reset() {
*x = UserGetAppSettingResp{}
- mi := &file_user_user_msg_proto_msgTypes[42]
+ mi := &file_user_user_msg_proto_msgTypes[48]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2342,7 +2816,7 @@ func (x *UserGetAppSettingResp) String() string {
func (*UserGetAppSettingResp) ProtoMessage() {}
func (x *UserGetAppSettingResp) ProtoReflect() protoreflect.Message {
- mi := &file_user_user_msg_proto_msgTypes[42]
+ mi := &file_user_user_msg_proto_msgTypes[48]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2355,7 +2829,7 @@ func (x *UserGetAppSettingResp) ProtoReflect() protoreflect.Message {
// Deprecated: Use UserGetAppSettingResp.ProtoReflect.Descriptor instead.
func (*UserGetAppSettingResp) Descriptor() ([]byte, []int) {
- return file_user_user_msg_proto_rawDescGZIP(), []int{42}
+ return file_user_user_msg_proto_rawDescGZIP(), []int{48}
}
func (x *UserGetAppSettingResp) GetSetting() *DBUuserAppSetting {
@@ -2375,7 +2849,7 @@ type UserUpdateAppSettingReq struct {
func (x *UserUpdateAppSettingReq) Reset() {
*x = UserUpdateAppSettingReq{}
- mi := &file_user_user_msg_proto_msgTypes[43]
+ mi := &file_user_user_msg_proto_msgTypes[49]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2387,7 +2861,7 @@ func (x *UserUpdateAppSettingReq) String() string {
func (*UserUpdateAppSettingReq) ProtoMessage() {}
func (x *UserUpdateAppSettingReq) ProtoReflect() protoreflect.Message {
- mi := &file_user_user_msg_proto_msgTypes[43]
+ mi := &file_user_user_msg_proto_msgTypes[49]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2400,7 +2874,7 @@ func (x *UserUpdateAppSettingReq) ProtoReflect() protoreflect.Message {
// Deprecated: Use UserUpdateAppSettingReq.ProtoReflect.Descriptor instead.
func (*UserUpdateAppSettingReq) Descriptor() ([]byte, []int) {
- return file_user_user_msg_proto_rawDescGZIP(), []int{43}
+ return file_user_user_msg_proto_rawDescGZIP(), []int{49}
}
func (x *UserUpdateAppSettingReq) GetSetting() *DBUuserAppSetting {
@@ -2418,7 +2892,7 @@ type UserUpdateAppSettingResp struct {
func (x *UserUpdateAppSettingResp) Reset() {
*x = UserUpdateAppSettingResp{}
- mi := &file_user_user_msg_proto_msgTypes[44]
+ mi := &file_user_user_msg_proto_msgTypes[50]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2430,7 +2904,7 @@ func (x *UserUpdateAppSettingResp) String() string {
func (*UserUpdateAppSettingResp) ProtoMessage() {}
func (x *UserUpdateAppSettingResp) ProtoReflect() protoreflect.Message {
- mi := &file_user_user_msg_proto_msgTypes[44]
+ mi := &file_user_user_msg_proto_msgTypes[50]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2443,7 +2917,7 @@ func (x *UserUpdateAppSettingResp) ProtoReflect() protoreflect.Message {
// Deprecated: Use UserUpdateAppSettingResp.ProtoReflect.Descriptor instead.
func (*UserUpdateAppSettingResp) Descriptor() ([]byte, []int) {
- return file_user_user_msg_proto_rawDescGZIP(), []int{44}
+ return file_user_user_msg_proto_rawDescGZIP(), []int{50}
}
// 更新设备设置
@@ -2457,7 +2931,7 @@ type UserUpdateDeviceSettingReq struct {
func (x *UserUpdateDeviceSettingReq) Reset() {
*x = UserUpdateDeviceSettingReq{}
- mi := &file_user_user_msg_proto_msgTypes[45]
+ mi := &file_user_user_msg_proto_msgTypes[51]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2469,7 +2943,7 @@ func (x *UserUpdateDeviceSettingReq) String() string {
func (*UserUpdateDeviceSettingReq) ProtoMessage() {}
func (x *UserUpdateDeviceSettingReq) ProtoReflect() protoreflect.Message {
- mi := &file_user_user_msg_proto_msgTypes[45]
+ mi := &file_user_user_msg_proto_msgTypes[51]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2482,7 +2956,7 @@ func (x *UserUpdateDeviceSettingReq) ProtoReflect() protoreflect.Message {
// Deprecated: Use UserUpdateDeviceSettingReq.ProtoReflect.Descriptor instead.
func (*UserUpdateDeviceSettingReq) Descriptor() ([]byte, []int) {
- return file_user_user_msg_proto_rawDescGZIP(), []int{45}
+ return file_user_user_msg_proto_rawDescGZIP(), []int{51}
}
func (x *UserUpdateDeviceSettingReq) GetId() uint64 {
@@ -2509,7 +2983,7 @@ type UserUpdateDeviceSettingResp struct {
func (x *UserUpdateDeviceSettingResp) Reset() {
*x = UserUpdateDeviceSettingResp{}
- mi := &file_user_user_msg_proto_msgTypes[46]
+ mi := &file_user_user_msg_proto_msgTypes[52]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2521,7 +2995,7 @@ func (x *UserUpdateDeviceSettingResp) String() string {
func (*UserUpdateDeviceSettingResp) ProtoMessage() {}
func (x *UserUpdateDeviceSettingResp) ProtoReflect() protoreflect.Message {
- mi := &file_user_user_msg_proto_msgTypes[46]
+ mi := &file_user_user_msg_proto_msgTypes[52]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2534,7 +3008,7 @@ func (x *UserUpdateDeviceSettingResp) ProtoReflect() protoreflect.Message {
// Deprecated: Use UserUpdateDeviceSettingResp.ProtoReflect.Descriptor instead.
func (*UserUpdateDeviceSettingResp) Descriptor() ([]byte, []int) {
- return file_user_user_msg_proto_rawDescGZIP(), []int{46}
+ return file_user_user_msg_proto_rawDescGZIP(), []int{52}
}
func (x *UserUpdateDeviceSettingResp) GetId() uint64 {
@@ -2563,7 +3037,7 @@ type UserIdVerifyReq struct {
func (x *UserIdVerifyReq) Reset() {
*x = UserIdVerifyReq{}
- mi := &file_user_user_msg_proto_msgTypes[47]
+ mi := &file_user_user_msg_proto_msgTypes[53]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2575,7 +3049,7 @@ func (x *UserIdVerifyReq) String() string {
func (*UserIdVerifyReq) ProtoMessage() {}
func (x *UserIdVerifyReq) ProtoReflect() protoreflect.Message {
- mi := &file_user_user_msg_proto_msgTypes[47]
+ mi := &file_user_user_msg_proto_msgTypes[53]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2588,7 +3062,7 @@ func (x *UserIdVerifyReq) ProtoReflect() protoreflect.Message {
// Deprecated: Use UserIdVerifyReq.ProtoReflect.Descriptor instead.
func (*UserIdVerifyReq) Descriptor() ([]byte, []int) {
- return file_user_user_msg_proto_rawDescGZIP(), []int{47}
+ return file_user_user_msg_proto_rawDescGZIP(), []int{53}
}
func (x *UserIdVerifyReq) GetRealname() string {
@@ -2618,7 +3092,7 @@ type UserIdVerifyResp struct {
func (x *UserIdVerifyResp) Reset() {
*x = UserIdVerifyResp{}
- mi := &file_user_user_msg_proto_msgTypes[48]
+ mi := &file_user_user_msg_proto_msgTypes[54]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2630,7 +3104,7 @@ func (x *UserIdVerifyResp) String() string {
func (*UserIdVerifyResp) ProtoMessage() {}
func (x *UserIdVerifyResp) ProtoReflect() protoreflect.Message {
- mi := &file_user_user_msg_proto_msgTypes[48]
+ mi := &file_user_user_msg_proto_msgTypes[54]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2643,7 +3117,7 @@ func (x *UserIdVerifyResp) ProtoReflect() protoreflect.Message {
// Deprecated: Use UserIdVerifyResp.ProtoReflect.Descriptor instead.
func (*UserIdVerifyResp) Descriptor() ([]byte, []int) {
- return file_user_user_msg_proto_rawDescGZIP(), []int{48}
+ return file_user_user_msg_proto_rawDescGZIP(), []int{54}
}
func (x *UserIdVerifyResp) GetVerified() bool {
@@ -2691,7 +3165,7 @@ type UserGetIdVerifyReq struct {
func (x *UserGetIdVerifyReq) Reset() {
*x = UserGetIdVerifyReq{}
- mi := &file_user_user_msg_proto_msgTypes[49]
+ mi := &file_user_user_msg_proto_msgTypes[55]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2703,7 +3177,7 @@ func (x *UserGetIdVerifyReq) String() string {
func (*UserGetIdVerifyReq) ProtoMessage() {}
func (x *UserGetIdVerifyReq) ProtoReflect() protoreflect.Message {
- mi := &file_user_user_msg_proto_msgTypes[49]
+ mi := &file_user_user_msg_proto_msgTypes[55]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2716,7 +3190,7 @@ func (x *UserGetIdVerifyReq) ProtoReflect() protoreflect.Message {
// Deprecated: Use UserGetIdVerifyReq.ProtoReflect.Descriptor instead.
func (*UserGetIdVerifyReq) Descriptor() ([]byte, []int) {
- return file_user_user_msg_proto_rawDescGZIP(), []int{49}
+ return file_user_user_msg_proto_rawDescGZIP(), []int{55}
}
type UserGetIdVerifyResp struct {
@@ -2731,7 +3205,7 @@ type UserGetIdVerifyResp struct {
func (x *UserGetIdVerifyResp) Reset() {
*x = UserGetIdVerifyResp{}
- mi := &file_user_user_msg_proto_msgTypes[50]
+ mi := &file_user_user_msg_proto_msgTypes[56]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2743,7 +3217,7 @@ func (x *UserGetIdVerifyResp) String() string {
func (*UserGetIdVerifyResp) ProtoMessage() {}
func (x *UserGetIdVerifyResp) ProtoReflect() protoreflect.Message {
- mi := &file_user_user_msg_proto_msgTypes[50]
+ mi := &file_user_user_msg_proto_msgTypes[56]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2756,7 +3230,7 @@ func (x *UserGetIdVerifyResp) ProtoReflect() protoreflect.Message {
// Deprecated: Use UserGetIdVerifyResp.ProtoReflect.Descriptor instead.
func (*UserGetIdVerifyResp) Descriptor() ([]byte, []int) {
- return file_user_user_msg_proto_rawDescGZIP(), []int{50}
+ return file_user_user_msg_proto_rawDescGZIP(), []int{56}
}
func (x *UserGetIdVerifyResp) GetVerified() bool {
@@ -2803,7 +3277,7 @@ type UserTranslateReq struct {
func (x *UserTranslateReq) Reset() {
*x = UserTranslateReq{}
- mi := &file_user_user_msg_proto_msgTypes[51]
+ mi := &file_user_user_msg_proto_msgTypes[57]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2815,7 +3289,7 @@ func (x *UserTranslateReq) String() string {
func (*UserTranslateReq) ProtoMessage() {}
func (x *UserTranslateReq) ProtoReflect() protoreflect.Message {
- mi := &file_user_user_msg_proto_msgTypes[51]
+ mi := &file_user_user_msg_proto_msgTypes[57]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2828,7 +3302,7 @@ func (x *UserTranslateReq) ProtoReflect() protoreflect.Message {
// Deprecated: Use UserTranslateReq.ProtoReflect.Descriptor instead.
func (*UserTranslateReq) Descriptor() ([]byte, []int) {
- return file_user_user_msg_proto_rawDescGZIP(), []int{51}
+ return file_user_user_msg_proto_rawDescGZIP(), []int{57}
}
func (x *UserTranslateReq) GetText() string {
@@ -2864,7 +3338,7 @@ type UserTranslateResp struct {
func (x *UserTranslateResp) Reset() {
*x = UserTranslateResp{}
- mi := &file_user_user_msg_proto_msgTypes[52]
+ mi := &file_user_user_msg_proto_msgTypes[58]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2876,7 +3350,7 @@ func (x *UserTranslateResp) String() string {
func (*UserTranslateResp) ProtoMessage() {}
func (x *UserTranslateResp) ProtoReflect() protoreflect.Message {
- mi := &file_user_user_msg_proto_msgTypes[52]
+ mi := &file_user_user_msg_proto_msgTypes[58]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2889,7 +3363,7 @@ func (x *UserTranslateResp) ProtoReflect() protoreflect.Message {
// Deprecated: Use UserTranslateResp.ProtoReflect.Descriptor instead.
func (*UserTranslateResp) Descriptor() ([]byte, []int) {
- return file_user_user_msg_proto_rawDescGZIP(), []int{52}
+ return file_user_user_msg_proto_rawDescGZIP(), []int{58}
}
func (x *UserTranslateResp) GetText() string {
@@ -2938,7 +3412,7 @@ type UserGetUploadUrlReq struct {
func (x *UserGetUploadUrlReq) Reset() {
*x = UserGetUploadUrlReq{}
- mi := &file_user_user_msg_proto_msgTypes[53]
+ mi := &file_user_user_msg_proto_msgTypes[59]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2950,7 +3424,7 @@ func (x *UserGetUploadUrlReq) String() string {
func (*UserGetUploadUrlReq) ProtoMessage() {}
func (x *UserGetUploadUrlReq) ProtoReflect() protoreflect.Message {
- mi := &file_user_user_msg_proto_msgTypes[53]
+ mi := &file_user_user_msg_proto_msgTypes[59]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2963,7 +3437,7 @@ func (x *UserGetUploadUrlReq) ProtoReflect() protoreflect.Message {
// Deprecated: Use UserGetUploadUrlReq.ProtoReflect.Descriptor instead.
func (*UserGetUploadUrlReq) Descriptor() ([]byte, []int) {
- return file_user_user_msg_proto_rawDescGZIP(), []int{53}
+ return file_user_user_msg_proto_rawDescGZIP(), []int{59}
}
func (x *UserGetUploadUrlReq) GetScene() string {
@@ -3000,7 +3474,7 @@ type UserGetUploadUrlResp struct {
func (x *UserGetUploadUrlResp) Reset() {
*x = UserGetUploadUrlResp{}
- mi := &file_user_user_msg_proto_msgTypes[54]
+ mi := &file_user_user_msg_proto_msgTypes[60]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3012,7 +3486,7 @@ func (x *UserGetUploadUrlResp) String() string {
func (*UserGetUploadUrlResp) ProtoMessage() {}
func (x *UserGetUploadUrlResp) ProtoReflect() protoreflect.Message {
- mi := &file_user_user_msg_proto_msgTypes[54]
+ mi := &file_user_user_msg_proto_msgTypes[60]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3025,7 +3499,7 @@ func (x *UserGetUploadUrlResp) ProtoReflect() protoreflect.Message {
// Deprecated: Use UserGetUploadUrlResp.ProtoReflect.Descriptor instead.
func (*UserGetUploadUrlResp) Descriptor() ([]byte, []int) {
- return file_user_user_msg_proto_rawDescGZIP(), []int{54}
+ return file_user_user_msg_proto_rawDescGZIP(), []int{60}
}
func (x *UserGetUploadUrlResp) GetUploadurl() string {
@@ -3083,7 +3557,7 @@ type UserGetMcpTokenReq struct {
func (x *UserGetMcpTokenReq) Reset() {
*x = UserGetMcpTokenReq{}
- mi := &file_user_user_msg_proto_msgTypes[55]
+ mi := &file_user_user_msg_proto_msgTypes[61]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3095,7 +3569,7 @@ func (x *UserGetMcpTokenReq) String() string {
func (*UserGetMcpTokenReq) ProtoMessage() {}
func (x *UserGetMcpTokenReq) ProtoReflect() protoreflect.Message {
- mi := &file_user_user_msg_proto_msgTypes[55]
+ mi := &file_user_user_msg_proto_msgTypes[61]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3108,7 +3582,7 @@ func (x *UserGetMcpTokenReq) ProtoReflect() protoreflect.Message {
// Deprecated: Use UserGetMcpTokenReq.ProtoReflect.Descriptor instead.
func (*UserGetMcpTokenReq) Descriptor() ([]byte, []int) {
- return file_user_user_msg_proto_rawDescGZIP(), []int{55}
+ return file_user_user_msg_proto_rawDescGZIP(), []int{61}
}
type UserGetMcpTokenResp struct {
@@ -3121,7 +3595,7 @@ type UserGetMcpTokenResp struct {
func (x *UserGetMcpTokenResp) Reset() {
*x = UserGetMcpTokenResp{}
- mi := &file_user_user_msg_proto_msgTypes[56]
+ mi := &file_user_user_msg_proto_msgTypes[62]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -3133,7 +3607,7 @@ func (x *UserGetMcpTokenResp) String() string {
func (*UserGetMcpTokenResp) ProtoMessage() {}
func (x *UserGetMcpTokenResp) ProtoReflect() protoreflect.Message {
- mi := &file_user_user_msg_proto_msgTypes[56]
+ mi := &file_user_user_msg_proto_msgTypes[62]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -3146,7 +3620,7 @@ func (x *UserGetMcpTokenResp) ProtoReflect() protoreflect.Message {
// Deprecated: Use UserGetMcpTokenResp.ProtoReflect.Descriptor instead.
func (*UserGetMcpTokenResp) Descriptor() ([]byte, []int) {
- return file_user_user_msg_proto_rawDescGZIP(), []int{56}
+ return file_user_user_msg_proto_rawDescGZIP(), []int{62}
}
func (x *UserGetMcpTokenResp) GetToken() string {
@@ -3216,7 +3690,7 @@ const file_user_user_msg_proto_rawDesc = "" +
"devicename\x18\x03 \x01(\tR\n" +
"devicename\x12\x1c\n" +
"\tdevicemac\x18\x04 \x01(\tR\tdevicemac\x12\x18\n" +
- "\aversion\x18\x05 \x01(\tR\aversion\"\x93\x03\n" +
+ "\aversion\x18\x05 \x01(\tR\aversion\"\x95\x04\n" +
"\x12UserBindDeviceResp\x12%\n" +
"\x06device\x18\x01 \x01(\v2\r.DBUserDiviceR\x06device\x12\x1e\n" +
"\n" +
@@ -3232,7 +3706,10 @@ const file_user_user_msg_proto_rawDesc = "" +
"\x12tradetotalintegral\x18\b \x01(\x03R\x12tradetotalintegral\x12\"\n" +
"\fmeetintegral\x18\t \x01(\x03R\fmeetintegral\x12,\n" +
"\x11meettotalintegral\x18\n" +
- " \x01(\x03R\x11meettotalintegral\"%\n" +
+ " \x01(\x03R\x11meettotalintegral\x12*\n" +
+ "\x10devicevipexptime\x18\v \x01(\x03R\x10devicevipexptime\x12&\n" +
+ "\x0edeviceviplevel\x18\f \x01(\x05R\x0edeviceviplevel\x12,\n" +
+ "\x11devicecomputeleft\x18\r \x01(\x03R\x11devicecomputeleft\"%\n" +
"\x13UserUnBindDeviceReq\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x04R\x02id\"\x16\n" +
"\x14UserUnBindDeviceResp\"g\n" +
@@ -3327,19 +3804,58 @@ const file_user_user_msg_proto_rawDesc = "" +
"\x12UserPushActionResp\"\x13\n" +
"\x11UserGetDevicesReq\"=\n" +
"\x12UserGetDevicesResp\x12'\n" +
- "\adevices\x18\x01 \x03(\v2\r.DBUserDiviceR\adevices\"\xa8\x01\n" +
+ "\adevices\x18\x01 \x03(\v2\r.DBUserDiviceR\adevices\"\xe0\x01\n" +
"\rUserUsagesReq\x12(\n" +
"\tusagetype\x18\x01 \x01(\x0e2\n" +
".UsageTypeR\tusagetype\x122\n" +
- "\x06usages\x18\x02 \x03(\v2\x1a.UserUsagesReq.UsagesEntryR\x06usages\x1a9\n" +
+ "\x06usages\x18\x02 \x03(\v2\x1a.UserUsagesReq.UsagesEntryR\x06usages\x12\x18\n" +
+ "\aupchars\x18\x03 \x01(\x03R\aupchars\x12\x1c\n" +
+ "\tdownchars\x18\x04 \x01(\x03R\tdownchars\x1a9\n" +
"\vUsagesEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
- "\x05value\x18\x02 \x01(\x03R\x05value:\x028\x01\"\x80\x01\n" +
+ "\x05value\x18\x02 \x01(\x03R\x05value:\x028\x01\"\x86\x02\n" +
"\x0eUserUsagesResp\x123\n" +
- "\x06usages\x18\x01 \x03(\v2\x1b.UserUsagesResp.UsagesEntryR\x06usages\x1a9\n" +
+ "\x06usages\x18\x01 \x03(\v2\x1b.UserUsagesResp.UsagesEntryR\x06usages\x12\x18\n" +
+ "\acompute\x18\x02 \x01(\x03R\acompute\x12,\n" +
+ "\x11computedeviceleft\x18\x03 \x01(\x03R\x11computedeviceleft\x12(\n" +
+ "\x0fcomputeuserleft\x18\x04 \x01(\x03R\x0fcomputeuserleft\x12\x12\n" +
+ "\x04over\x18\x05 \x01(\x03R\x04over\x1a9\n" +
"\vUsagesEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\x05R\x03key\x12\x14\n" +
- "\x05value\x18\x02 \x01(\x03R\x05value:\x028\x01\"\x18\n" +
+ "\x05value\x18\x02 \x01(\x03R\x05value:\x028\x01\"\x13\n" +
+ "\x11UserGetComputeReq\"\x94\x01\n" +
+ "\x12UserComputeVipInfo\x12\x14\n" +
+ "\x05level\x18\x01 \x01(\x05R\x05level\x12\x18\n" +
+ "\aexptime\x18\x02 \x01(\x03R\aexptime\x12 \n" +
+ "\vselfexptime\x18\x03 \x01(\x03R\vselfexptime\x12,\n" +
+ "\adevices\x18\x04 \x03(\v2\x12.UserDeviceVipInfoR\adevices\"\xc7\x01\n" +
+ "\x11UserDeviceVipInfo\x12\x1c\n" +
+ "\tdevicemac\x18\x01 \x01(\tR\tdevicemac\x12\x1e\n" +
+ "\n" +
+ "devicename\x18\x02 \x01(\tR\n" +
+ "devicename\x12\x14\n" +
+ "\x05level\x18\x03 \x01(\x05R\x05level\x12\x18\n" +
+ "\aexptime\x18\x04 \x01(\x03R\aexptime\x12\"\n" +
+ "\fcomputegrant\x18\x05 \x01(\x03R\fcomputegrant\x12 \n" +
+ "\vcomputeused\x18\x06 \x01(\x03R\vcomputeused\"\x8f\x01\n" +
+ "\x0fUserComputeInfo\x12\x1e\n" +
+ "\n" +
+ "deviceleft\x18\x01 \x01(\x03R\n" +
+ "deviceleft\x12 \n" +
+ "\vuserbalance\x18\x02 \x01(\x03R\vuserbalance\x12\x1c\n" +
+ "\ttotalused\x18\x03 \x01(\x03R\ttotalused\x12\x1c\n" +
+ "\tmonthused\x18\x04 \x01(\x03R\tmonthused\"\xba\x01\n" +
+ "\x10UserComputeRates\x12\x1c\n" +
+ "\ttranslate\x18\x01 \x01(\x03R\ttranslate\x12\x18\n" +
+ "\ameeting\x18\x02 \x01(\x03R\ameeting\x12\x16\n" +
+ "\x06aichat\x18\x03 \x01(\x03R\x06aichat\x12\x12\n" +
+ "\x04gate\x18\x04 \x01(\bR\x04gate\x12 \n" +
+ "\vvipwarndays\x18\x05 \x01(\x05R\vvipwarndays\x12 \n" +
+ "\vcomputewarn\x18\x06 \x01(\x03R\vcomputewarn\"\x90\x01\n" +
+ "\x12UserGetComputeResp\x12%\n" +
+ "\x03vip\x18\x01 \x01(\v2\x13.UserComputeVipInfoR\x03vip\x12*\n" +
+ "\acompute\x18\x02 \x01(\v2\x10.UserComputeInfoR\acompute\x12'\n" +
+ "\x05rates\x18\x03 \x01(\v2\x11.UserComputeRatesR\x05rates\"\x18\n" +
"\x16UserGetWakeupVoicesReq\"A\n" +
"\x17UserGetWakeupVoicesResp\x12&\n" +
"\x06voices\x18\x01 \x03(\v2\x0e.DBWakeupVoiceR\x06voices\"\x16\n" +
@@ -3419,7 +3935,7 @@ func file_user_user_msg_proto_rawDescGZIP() []byte {
return file_user_user_msg_proto_rawDescData
}
-var file_user_user_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 62)
+var file_user_user_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 68)
var file_user_user_msg_proto_goTypes = []any{
(*UserSginReq)(nil), // 0: UserSginReq
(*UserSginResp)(nil), // 1: UserSginResp
@@ -3458,79 +3974,89 @@ var file_user_user_msg_proto_goTypes = []any{
(*UserGetDevicesResp)(nil), // 34: UserGetDevicesResp
(*UserUsagesReq)(nil), // 35: UserUsagesReq
(*UserUsagesResp)(nil), // 36: UserUsagesResp
- (*UserGetWakeupVoicesReq)(nil), // 37: UserGetWakeupVoicesReq
- (*UserGetWakeupVoicesResp)(nil), // 38: UserGetWakeupVoicesResp
- (*UserGetStatisticsReq)(nil), // 39: UserGetStatisticsReq
- (*UserGetStatisticsResp)(nil), // 40: UserGetStatisticsResp
- (*UserGetAppSettingReq)(nil), // 41: UserGetAppSettingReq
- (*UserGetAppSettingResp)(nil), // 42: UserGetAppSettingResp
- (*UserUpdateAppSettingReq)(nil), // 43: UserUpdateAppSettingReq
- (*UserUpdateAppSettingResp)(nil), // 44: UserUpdateAppSettingResp
- (*UserUpdateDeviceSettingReq)(nil), // 45: UserUpdateDeviceSettingReq
- (*UserUpdateDeviceSettingResp)(nil), // 46: UserUpdateDeviceSettingResp
- (*UserIdVerifyReq)(nil), // 47: UserIdVerifyReq
- (*UserIdVerifyResp)(nil), // 48: UserIdVerifyResp
- (*UserGetIdVerifyReq)(nil), // 49: UserGetIdVerifyReq
- (*UserGetIdVerifyResp)(nil), // 50: UserGetIdVerifyResp
- (*UserTranslateReq)(nil), // 51: UserTranslateReq
- (*UserTranslateResp)(nil), // 52: UserTranslateResp
- (*UserGetUploadUrlReq)(nil), // 53: UserGetUploadUrlReq
- (*UserGetUploadUrlResp)(nil), // 54: UserGetUploadUrlResp
- (*UserGetMcpTokenReq)(nil), // 55: UserGetMcpTokenReq
- (*UserGetMcpTokenResp)(nil), // 56: UserGetMcpTokenResp
- nil, // 57: UserGetAppConfigResp.EnvEntry
- nil, // 58: UserGetAppConfigResp.McpsEntry
- nil, // 59: UserPushActionReq.ActionsEntry
- nil, // 60: UserUsagesReq.UsagesEntry
- nil, // 61: UserUsagesResp.UsagesEntry
- (SginTyoe)(0), // 62: SginTyoe
- (UserChannel)(0), // 63: UserChannel
- (*DBUser)(nil), // 64: DBUser
- (*DBUserDivice)(nil), // 65: DBUserDivice
- (MCPServerType)(0), // 66: MCPServerType
- (*DBAgent)(nil), // 67: DBAgent
- (*DBProduct)(nil), // 68: DBProduct
- (*DBProductVersion)(nil), // 69: DBProductVersion
- (*DBChannelApp)(nil), // 70: DBChannelApp
- (UsageType)(0), // 71: UsageType
- (*DBWakeupVoice)(nil), // 72: DBWakeupVoice
- (*DBUserStatistics)(nil), // 73: DBUserStatistics
- (*DBUuserAppSetting)(nil), // 74: DBUuserAppSetting
+ (*UserGetComputeReq)(nil), // 37: UserGetComputeReq
+ (*UserComputeVipInfo)(nil), // 38: UserComputeVipInfo
+ (*UserDeviceVipInfo)(nil), // 39: UserDeviceVipInfo
+ (*UserComputeInfo)(nil), // 40: UserComputeInfo
+ (*UserComputeRates)(nil), // 41: UserComputeRates
+ (*UserGetComputeResp)(nil), // 42: UserGetComputeResp
+ (*UserGetWakeupVoicesReq)(nil), // 43: UserGetWakeupVoicesReq
+ (*UserGetWakeupVoicesResp)(nil), // 44: UserGetWakeupVoicesResp
+ (*UserGetStatisticsReq)(nil), // 45: UserGetStatisticsReq
+ (*UserGetStatisticsResp)(nil), // 46: UserGetStatisticsResp
+ (*UserGetAppSettingReq)(nil), // 47: UserGetAppSettingReq
+ (*UserGetAppSettingResp)(nil), // 48: UserGetAppSettingResp
+ (*UserUpdateAppSettingReq)(nil), // 49: UserUpdateAppSettingReq
+ (*UserUpdateAppSettingResp)(nil), // 50: UserUpdateAppSettingResp
+ (*UserUpdateDeviceSettingReq)(nil), // 51: UserUpdateDeviceSettingReq
+ (*UserUpdateDeviceSettingResp)(nil), // 52: UserUpdateDeviceSettingResp
+ (*UserIdVerifyReq)(nil), // 53: UserIdVerifyReq
+ (*UserIdVerifyResp)(nil), // 54: UserIdVerifyResp
+ (*UserGetIdVerifyReq)(nil), // 55: UserGetIdVerifyReq
+ (*UserGetIdVerifyResp)(nil), // 56: UserGetIdVerifyResp
+ (*UserTranslateReq)(nil), // 57: UserTranslateReq
+ (*UserTranslateResp)(nil), // 58: UserTranslateResp
+ (*UserGetUploadUrlReq)(nil), // 59: UserGetUploadUrlReq
+ (*UserGetUploadUrlResp)(nil), // 60: UserGetUploadUrlResp
+ (*UserGetMcpTokenReq)(nil), // 61: UserGetMcpTokenReq
+ (*UserGetMcpTokenResp)(nil), // 62: UserGetMcpTokenResp
+ nil, // 63: UserGetAppConfigResp.EnvEntry
+ nil, // 64: UserGetAppConfigResp.McpsEntry
+ nil, // 65: UserPushActionReq.ActionsEntry
+ nil, // 66: UserUsagesReq.UsagesEntry
+ nil, // 67: UserUsagesResp.UsagesEntry
+ (SginTyoe)(0), // 68: SginTyoe
+ (UserChannel)(0), // 69: UserChannel
+ (*DBUser)(nil), // 70: DBUser
+ (*DBUserDivice)(nil), // 71: DBUserDivice
+ (MCPServerType)(0), // 72: MCPServerType
+ (*DBAgent)(nil), // 73: DBAgent
+ (*DBProduct)(nil), // 74: DBProduct
+ (*DBProductVersion)(nil), // 75: DBProductVersion
+ (*DBChannelApp)(nil), // 76: DBChannelApp
+ (UsageType)(0), // 77: UsageType
+ (*DBWakeupVoice)(nil), // 78: DBWakeupVoice
+ (*DBUserStatistics)(nil), // 79: DBUserStatistics
+ (*DBUuserAppSetting)(nil), // 80: DBUuserAppSetting
}
var file_user_user_msg_proto_depIdxs = []int32{
- 62, // 0: UserSginReq.stype:type_name -> SginTyoe
- 63, // 1: UserSginReq.channel:type_name -> UserChannel
- 64, // 2: UserSginResp.user:type_name -> DBUser
- 65, // 3: UserSginResp.devices:type_name -> DBUserDivice
- 63, // 4: UserGetInfoReq.channel:type_name -> UserChannel
- 64, // 5: UserGetInfoResp.user:type_name -> DBUser
- 65, // 6: UserGetInfoResp.devices:type_name -> DBUserDivice
- 65, // 7: UserBindDeviceResp.device:type_name -> DBUserDivice
- 65, // 8: UserBindAuthCodeResp.device:type_name -> DBUserDivice
- 66, // 9: McpServer.type:type_name -> MCPServerType
- 57, // 10: UserGetAppConfigResp.env:type_name -> UserGetAppConfigResp.EnvEntry
- 67, // 11: UserGetAppConfigResp.agents:type_name -> DBAgent
- 58, // 12: UserGetAppConfigResp.mcps:type_name -> UserGetAppConfigResp.McpsEntry
- 68, // 13: UserGetAppConfigResp.products:type_name -> DBProduct
- 68, // 14: UserGetProductResp.product:type_name -> DBProduct
- 69, // 15: UserGetProductVersionsResp.versions:type_name -> DBProductVersion
- 70, // 16: UserGetChannelAppsResp.apps:type_name -> DBChannelApp
- 70, // 17: UserGetChannelAppResp.app:type_name -> DBChannelApp
- 59, // 18: UserPushActionReq.actions:type_name -> UserPushActionReq.ActionsEntry
- 65, // 19: UserGetDevicesResp.devices:type_name -> DBUserDivice
- 71, // 20: UserUsagesReq.usagetype:type_name -> UsageType
- 60, // 21: UserUsagesReq.usages:type_name -> UserUsagesReq.UsagesEntry
- 61, // 22: UserUsagesResp.usages:type_name -> UserUsagesResp.UsagesEntry
- 72, // 23: UserGetWakeupVoicesResp.voices:type_name -> DBWakeupVoice
- 73, // 24: UserGetStatisticsResp.statistics:type_name -> DBUserStatistics
- 74, // 25: UserGetAppSettingResp.setting:type_name -> DBUuserAppSetting
- 74, // 26: UserUpdateAppSettingReq.setting:type_name -> DBUuserAppSetting
- 19, // 27: UserGetAppConfigResp.McpsEntry.value:type_name -> McpServer
- 28, // [28:28] is the sub-list for method output_type
- 28, // [28:28] is the sub-list for method input_type
- 28, // [28:28] is the sub-list for extension type_name
- 28, // [28:28] is the sub-list for extension extendee
- 0, // [0:28] is the sub-list for field type_name
+ 68, // 0: UserSginReq.stype:type_name -> SginTyoe
+ 69, // 1: UserSginReq.channel:type_name -> UserChannel
+ 70, // 2: UserSginResp.user:type_name -> DBUser
+ 71, // 3: UserSginResp.devices:type_name -> DBUserDivice
+ 69, // 4: UserGetInfoReq.channel:type_name -> UserChannel
+ 70, // 5: UserGetInfoResp.user:type_name -> DBUser
+ 71, // 6: UserGetInfoResp.devices:type_name -> DBUserDivice
+ 71, // 7: UserBindDeviceResp.device:type_name -> DBUserDivice
+ 71, // 8: UserBindAuthCodeResp.device:type_name -> DBUserDivice
+ 72, // 9: McpServer.type:type_name -> MCPServerType
+ 63, // 10: UserGetAppConfigResp.env:type_name -> UserGetAppConfigResp.EnvEntry
+ 73, // 11: UserGetAppConfigResp.agents:type_name -> DBAgent
+ 64, // 12: UserGetAppConfigResp.mcps:type_name -> UserGetAppConfigResp.McpsEntry
+ 74, // 13: UserGetAppConfigResp.products:type_name -> DBProduct
+ 74, // 14: UserGetProductResp.product:type_name -> DBProduct
+ 75, // 15: UserGetProductVersionsResp.versions:type_name -> DBProductVersion
+ 76, // 16: UserGetChannelAppsResp.apps:type_name -> DBChannelApp
+ 76, // 17: UserGetChannelAppResp.app:type_name -> DBChannelApp
+ 65, // 18: UserPushActionReq.actions:type_name -> UserPushActionReq.ActionsEntry
+ 71, // 19: UserGetDevicesResp.devices:type_name -> DBUserDivice
+ 77, // 20: UserUsagesReq.usagetype:type_name -> UsageType
+ 66, // 21: UserUsagesReq.usages:type_name -> UserUsagesReq.UsagesEntry
+ 67, // 22: UserUsagesResp.usages:type_name -> UserUsagesResp.UsagesEntry
+ 39, // 23: UserComputeVipInfo.devices:type_name -> UserDeviceVipInfo
+ 38, // 24: UserGetComputeResp.vip:type_name -> UserComputeVipInfo
+ 40, // 25: UserGetComputeResp.compute:type_name -> UserComputeInfo
+ 41, // 26: UserGetComputeResp.rates:type_name -> UserComputeRates
+ 78, // 27: UserGetWakeupVoicesResp.voices:type_name -> DBWakeupVoice
+ 79, // 28: UserGetStatisticsResp.statistics:type_name -> DBUserStatistics
+ 80, // 29: UserGetAppSettingResp.setting:type_name -> DBUuserAppSetting
+ 80, // 30: UserUpdateAppSettingReq.setting:type_name -> DBUuserAppSetting
+ 19, // 31: UserGetAppConfigResp.McpsEntry.value:type_name -> McpServer
+ 32, // [32:32] is the sub-list for method output_type
+ 32, // [32:32] is the sub-list for method input_type
+ 32, // [32:32] is the sub-list for extension type_name
+ 32, // [32:32] is the sub-list for extension extendee
+ 0, // [0:32] is the sub-list for field type_name
}
func init() { file_user_user_msg_proto_init() }
@@ -3547,7 +4073,7 @@ func file_user_user_msg_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_user_user_msg_proto_rawDesc), len(file_user_user_msg_proto_rawDesc)),
NumEnums: 0,
- NumMessages: 62,
+ NumMessages: 68,
NumExtensions: 0,
NumServices: 0,
},
diff --git a/apps/services/services/.DS_Store b/apps/services/services/.DS_Store
index 3eea383a..1c37ecd7 100644
Binary files a/apps/services/services/.DS_Store and b/apps/services/services/.DS_Store differ
diff --git a/deploy/app/dev-deploy.sh b/deploy/app/dev-deploy.sh
index f26372b7..51f379d5 100755
--- a/deploy/app/dev-deploy.sh
+++ b/deploy/app/dev-deploy.sh
@@ -127,8 +127,19 @@ _ssh "mkdir -p '$DEV_DIR/confs' '$DEV_DIR/log'"
_scp "$SVC_DIR/docker-compose.yml" "$(_dest "$DEV_DIR/docker-compose.yml")"
_scp "$SVC_DIR"/confs/*.yaml.example "$(_dest "$DEV_DIR/confs/")"
_scp "$SVC_DIR/env/env.example" "$(_dest "$DEV_DIR/env.example")"
-# ip2region 离线 IP 库(静态数据非凭据,home 的 sys.ipinfo 启动必需,缺了会 panic)
-_scp "$REPO_ROOT"/apps/services/sys/ipinfo/ip2region_v*.xdb "$(_dest "$DEV_DIR/confs/")"
+# ip2region 离线 IP 库(静态数据非凭据,home 的 sys.ipinfo 启动必需,缺了会 panic)。
+# 约 46MB 且几乎从不变,只在远端缺失或大小对不上时才传 —— 到测试机的上行只有几十 KB/s,
+# 无条件重传会让每次发 app 白白多花十几分钟(2026-09-08 实测 13 分钟,占整轮部署的八成)。
+# 比大小而不是只判存在:上一次传到一半被中断会留下个非空的残文件,`test -s` 认它是好的,
+# 于是永远补不上,而 home 读到截断的库会在启动时 panic。
+XDB_LOCAL_SIZES="$(cd "$REPO_ROOT/apps/services/sys/ipinfo" && wc -c < ip2region_v4.xdb)/$(cd "$REPO_ROOT/apps/services/sys/ipinfo" && wc -c < ip2region_v6.xdb)"
+XDB_REMOTE_SIZES="$(_ssh "cd '$DEV_DIR/confs' 2>/dev/null && echo \"\$(wc -c < ip2region_v4.xdb 2>/dev/null)/\$(wc -c < ip2region_v6.xdb 2>/dev/null)\"" 2>/dev/null || true)"
+if [ "$(echo "$XDB_LOCAL_SIZES" | tr -d ' ')" = "$(echo "$XDB_REMOTE_SIZES" | tr -d ' ')" ]; then
+ echo " ip2region 离线 IP 库已是最新($XDB_LOCAL_SIZES 字节),跳过上传"
+else
+ blue ">>> 同步 ip2region 离线 IP 库(约 46MB,仅在远端缺失/大小不符时传)"
+ _scp "$REPO_ROOT"/apps/services/sys/ipinfo/ip2region_v*.xdb "$(_dest "$DEV_DIR/confs/")"
+fi
# 3) 远端拉取并重启(用你在服务器维护的真实 .env / confs/*.yaml)
# REGISTRY/TAG 通过 inline 环境变量传给 compose(优先级高于 --env-file),确保拉到本次镜像。
diff --git a/docs/2026-09-11-阿龙测试服上线正式服-落地文档.md b/docs/2026-09-11-阿龙测试服上线正式服-落地文档.md
new file mode 100644
index 00000000..e50b1c2a
--- /dev/null
+++ b/docs/2026-09-11-阿龙测试服上线正式服-落地文档.md
@@ -0,0 +1,642 @@
+# 阿龙测试服 → 阿龙正式服 · 服务端上线落地文档
+
+**日期**:2026-09-11
+**方向**:`8.133.166.29`(阿龙测试,跑 `dev-latest`)→ `47.116.104.181`(阿龙正式,跑 `0.1.4`)
+**范围**:`app`(gateway/home/api/mcp/timer 单镜像)、`console`、`admin` 三个服务端。**客户端 App 本轮不发版。**
+**数据口径**:用户数据不迁;只搬「平台能力配置」,产品/设备/批次等业务台账不搬。
+
+---
+
+## 0. 先看结论
+
+本文档的前提全部是 2026-09-11 在两台机上**实测**得到的,不是照着脚本推的:
+
+1. **正式服没有任何用户数据** —— `user=0`、`userdevice=0`、`echomeet_record=0`、`payorder` 空。
+ 所以「用户数据不要部署过去」这条天然满足,**不需要任何排除规则、不需要脱敏**。
+2. **正式服代码停在 8-18 的 `0.1.4`**,这三周的能力一个都没有:记忆中心(拾忆)、会员与算力、
+ 身份证实名核验、设备表合表(`license_` → `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_` 分表 → `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 用的是三段参数形式 `<应用> <区域> `(`ym along 0.2.0`),
+console/admin 是 ` <环境>`(`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`。
diff --git a/docs/会员权益与用量统计-设计与开发文档.md b/docs/会员权益与用量统计-设计与开发文档.md
new file mode 100644
index 00000000..abc66fce
--- /dev/null
+++ b/docs/会员权益与用量统计-设计与开发文档.md
@@ -0,0 +1,587 @@
+# 会员权益与算力 · 设计与开发文档
+
+> 状态:**v5 一期已落地(服务端 + 后台 + 客户端,2026-09-11)**,二期(带存储设备)仅设计|最后更新:2026-09-11
+> 一期改动的落点见 CLAUDE.md「会员与算力」一节;第 10 节的「现在(线上)」列描述的是改造前的旧行为。
+
+---
+
+## 0. 一句话概括
+
+两层权益,各管一件事:
+
+| 层 | 管什么 | 来源 |
+|---|---|---|
+| **VIP** | **功能准入**:没有 VIP,翻译与会议纪要不可用 | 设备赠送(按天)+ 用户自购 |
+| **算力** | **用量计量**:翻译、会议、AI 对话的消耗统一折算成算力 | 设备赠送(有上限)+ 用户充值 |
+
+- 归属规则两层一致:**设备赠送的归设备**(解绑即失去、随设备流转),**用户自购的归账号**
+- 算力的**换算系数后台可配**,客户端只上报原始用量、只展示算力
+- 算力是否作为闸门(用完就拦)由**后台开关**控制,**默认关**——只记账不拦
+
+### 相对 v4 的变化
+
+v4 把权益简化成「只有 VIP」,用量只记原始数据。本版把**算力**加回来,作为统一的用量计量与设备赠送的载体:
+
+| v4 | v5 |
+|---|---|
+| 生产批次只配 VIP 天数 | VIP 天数 + **赠送算力上限** |
+| 用量按翻译秒 / 会议秒 / AI 字数分别记 | 原始数据照记,**另外统一折算成算力** |
+| 无换算 | 换算系数**后台可配**,改价不发版 |
+| 无消耗品 | 用户可**充值算力**,商品加算力发放项 |
+| 无闸门 | 算力闸门做成**开关,默认关** |
+
+---
+
+## 1. 权益模型
+
+### 1.1 VIP:功能准入
+
+```
+翻译 / 会议纪要是否可用 = 自购 VIP 未过期
+ 或(当前绑定着某台设备 且 该设备的 VIP 期未结束)
+```
+
+任一成立即可用。不相减、不收回、不结转。受限范围见 1.5。
+
+### 1.2 算力:用量计量
+
+**算力(compute)是平台自定义的计量单位**,所有产生服务商成本的功能都折算成它:
+
+| 功能 | 原始计量 | 换算 | 后台配置键 |
+|---|---|---|---|
+| 翻译(同传 / 面对面 / 通话 / 影音 / 文本 / 图片) | 秒 | 每分钟 = `N` 算力 | `COMPUTE_RATE_TRANSLATE` |
+| 会议纪要 | 秒 | 每分钟 = `N` 算力 | `COMPUTE_RATE_MEETING` |
+| AI 对话 | 输入 + 输出字符数 | 每 100 字 = `N` 算力 | `COMPUTE_RATE_AICHAT` |
+
+- **系数在服务端换算**,客户端不参与。改系数只影响改动之后的消耗,不追溯
+- 三个系数默认都是 `1`,即 1 分钟翻译 = 1 分钟会议 = 100 字对话 = 1 算力
+- 单次会话内按原始量累计,**结束时换算并向上取整**,单次最低 1 算力
+- 字符按 Unicode 码点计:中文 1 字 = 1,英文 1 字母 = 1
+
+> ⚠️ **算力不是大模型的 token**,与模型侧的 tokenizer 无关。代码里统一用 `compute`,
+> 不要再出现 token 这个词,避免与服务商用量混淆。
+
+### 1.3 算力的两个来源与归属
+
+| | 设备赠送算力 | 用户充值算力 |
+|---|---|---|
+| 来源 | 生产批次配置,激活时快照 | 充值、后台调整 |
+| 归属 | 设备硬件 | 账号 |
+| 存储 | 设备行 `compute_grant`(总量)/ `compute_used`(已用) | 用户行 `compute_balance` |
+| 有效期 | 不过期,用完为止 | 不过期,用完为止 |
+| 解绑 | 立即失去剩余部分 | 不受影响 |
+| 流转 | 剩余部分随设备给下一个绑定者 | 跟着账号走 |
+| 扣减 | **先扣这一份** | 设备剩余为 0 时才动用 |
+
+> ⚠️ **设备算力是共用一份,不是每人一份。** 前一个人用掉 300,下一个人接手只剩 700。
+> `compute_used` 在解绑时**不重置**,否则一台设备在几个账号间轮流绑定就能无限刷。
+
+> 设备算力与设备 VIP 期**相互独立**:VIP 期到了算力还在,算力用完了 VIP 期还在,都是正常状态。
+
+### 1.4 算力闸门:后台开关,默认关
+
+| 开关状态 | 消耗时的行为 |
+|---|---|
+| **关(默认)** | 换算 → 扣减(先设备后用户,扣到 0 为止)→ 超出部分记为「超额」→ **照常放行** |
+| 开 | 换算 → 设备剩余 + 用户余额 < 本次消耗 → **拒绝**,返回算力不足 |
+
+默认关的理由:上一轮已定「取消额度闸门」——用户在 VIP 期内不限量。
+算力先作为**记账与展示**跑起来,数据积累到位、商业策略明确后再决定要不要开。
+
+> ⚠️ **开闸门需要配合预扣。** 现在 `user_usages` 是客户端用完之后上报、服务端才扣,
+> 余额校验发生在扣减那一刻——余额只剩 1 的用户仍可先用满 100 分钟再上报。
+> 开关关着时这不是问题(反正放行);**开关打开前必须先把预扣 + 心跳续扣做了**,
+> 否则闸门形同虚设。预扣逻辑本期不做,列为开关的前置条件。
+
+### 1.5 功能权限边界(VIP 过期后)
+
+| 功能 | VIP 期内 | VIP 过期 | 算力 | 说明 |
+|---|---|---|---|---|
+| 同传 / 面对面 / 通话 / 影音翻译 | ✅ | ❌ 受限 | 扣 | |
+| 文本翻译 / 图片翻译 | ✅ | ❌ 受限 | 扣 | 同属翻译,同样按量计费 |
+| 会议纪要(新建转写 / 总结) | ✅ | ❌ 受限 | 扣 | |
+| **历史翻译记录、历史会议纪要** | ✅ | ✅ **可查看** | 不扣 | 数据是用户的 |
+| EMAI AI 对话 | ✅ | ✅ 不受限 | **扣** | 唯一「不受 VIP 限但扣算力」的功能 |
+| 通话录音 / 现场录音 | ✅ | ✅ | 不扣 | 本地能力 |
+| 设备连接、电量、设备管理、OTA | ✅ | ✅ | 不扣 | 不产生服务商成本 |
+| EQ / 音色 / 音乐 | ✅ | ✅ | 不扣 | 同上 |
+| 商城、支付、实名认证 | ✅ | ✅ | 不扣 | **必须可用**,否则用户无法续费 |
+
+> - **历史数据永远可查看。** 受限的是「新建」,不是「回看」
+> - **VIP 与算力的组合**:VIP 过期但算力还有 → 翻译/会议不可用(准入没过),AI 对话可用并扣算力;
+> VIP 在但算力为 0 → 闸门关时照常可用(记超额),闸门开时拒绝
+
+### 1.6 受限怎么落地:两类拦截点
+
+**不是所有功能都能在服务端拦住**,关键区别是这条链路走不走我们自己的服务器:
+
+| 功能 | 数据流向 | 服务端能否强拦 | 拦截点 |
+|---|---|---|---|
+| 文本 / 图片翻译 | 客户端 → **自家网关** → 服务商 | ✅ | [api_translate.go](../apps/services/modules/user/api_translate.go) 入口 |
+| 同传 / 面对面(三段式) | ASR 直连,**机器翻译走自家网关** | ✅ | 同上,拦住翻译那一跳即整体不可用 |
+| 会议纪要 | 录音传 OSS,**服务端起任务** | ✅ | `echomeet` starttask 入口 |
+| **通话翻译(端到端)** | **客户端直连服务商 WS** | ⚠️ **拦不住** | 只能靠「不下发凭据」+ 客户端自觉 |
+
+> ⚠️ 通话翻译走阿里端到端,客户端拿着服务端下发的凭据直连服务商 WebSocket,
+> **整个过程一个请求都不经过我们的服务器**。唯一的服务端手段是在
+> [svcresolve.go](../apps/services/modules/user/svcresolve.go) 的 `resolveThirdSvcs`
+> 里按 VIP 状态决定**要不要下发翻译类凭据**。凭据是启动时拉一次的,
+> VIP 在使用期间过期时客户端手里的凭据仍有效,直到下次重新拉配置——
+> 这个窗口(最长一个启动周期)已接受,不值得为它引入短 TTL。
+
+**双端都要做,职责不同**:客户端进入功能前判断,负责体验;服务端接口入口判断,负责兜底。
+
+### 1.7 受限时的交互口径
+
+| 项目 | 口径 | 理由 |
+|---|---|---|
+| 入口表现 | **可点击,点击后弹开通引导**;不置灰 | 置灰不说明原因,用户只会以为功能坏了 |
+| 提示内容 | 说明「续费会员」与「绑定带会员的设备」**两条路** | 设备也能带来 VIP |
+| 进行中的会话 | **允许跑完,不中途掐断** | 翻译到一半断掉是事故级体验 |
+| 算力展示 | 「设备剩余 + 账户余额」合并展示,明细可拆 | 数字无声变少用户一律当 bug |
+
+---
+
+## 2. 设备权益:VIP 天数 + 赠送算力
+
+### 2.1 生产批次配置
+
+在后台「MAC 生成管理 → 生成 MAC」为每个批次配置:
+
+| 配置项 | 单位 | 示例 | `0` 的含义 |
+|---|---|---|---|
+| `vipdays` | 天 | 180 | 不送 VIP |
+| `viplevel` | 等级 | 1 | 1 = VIP,2 = VIP-Pro(预留) |
+| `compute_grant` | 算力 | 1000 | 不送算力 |
+
+原先的 `rewardtranslate` / `rewardmeeting`(翻译分钟、会议分钟)**不再使用**,存量列保留不动。
+
+- **生产批次优先,未配置回退产品档案**——判据是批次三项全 0
+- **激活时快照**:首次绑定时把三项固化到设备行,此后改批次配置只影响尚未激活的库存设备
+
+### 2.2 激活日与连续计时
+
+**激活日 = 设备第一次被任意账号成功绑定的服务端时间**,已有字段 `device_mac.usedtime`,直接复用。
+
+设备 VIP 期 = `[激活日, 激活日 + vipdays)`,按日历天连续走,**解绑不暂停**。
+不需要定时任务,任何时刻的状态都能直接算出。
+
+### 2.3 设备 VIP 与自购 VIP 必须分开存
+
+现有做法是把赠送天数**累加到 `user.vipexptime`**,一旦累加就与自购混成同一个数字,解绑时无法拆分。
+
+**正确做法:设备侧不写用户表。**
+
+| 来源 | 存在哪 | 怎么算 |
+|---|---|---|
+| 自购 VIP | `user.vipexptime`(只记自购) | 直接读 |
+| 设备 VIP | 设备行 `usedtime` + `vipdays` | `激活日 + vipdays` |
+
+解绑时**什么都不用做**——不绑了自然就不享有了。
+
+### 2.4 VIP 等级
+
+`user.viplv` 已存在(支付成功时置 1)。实际等级 = max(自购等级, 当前绑定设备的等级)。vip-pro 用等级 2,无需新字段。
+
+---
+
+## 3. 绑定规则 · 一期:无存储设备
+
+| 场景 | 行为 |
+|---|---|
+| 别人直接绑定 | **允许顶掉**(接管),原绑定自动失效。与现有实现一致 |
+| 同账号换手机 | 允许(uid 相同,不算接管) |
+| VIP 期 / 设备算力 | 接着走、接着用,**不重新开始**,不重置激活日 |
+| 原持有者 | 立即失去设备 VIP 与设备剩余算力,**需给提示** |
+
+数据不跟着走:新用户看不到前一个人的录音、纪要、对话——那些按 uid 存在服务端。
+
+---
+
+## 4. 用量与算力记账
+
+### 4.1 谁上报什么
+
+| 数据 | 来源 | 现状 |
+|---|---|---|
+| 翻译时长(按模式) | **App 上报**原始秒数 | ✅ 已有:`Trademodel1~5Time` |
+| AI 对话输入 / 输出字数 | **App 上报**原始字符数 | ⚠️ 字段有但从未写入:`Aichatuptoken` / `Aichatdowntoken` |
+| 会议纪要时长 | **服务端自己统计** | ✅ 已有:`echomeet` 里 `Meettime += model.Seconds` |
+
+> ⚠️ **客户端只上报原始量,不上报算力。** 换算系数在后台可改,客户端如果自己算,
+> 改一次系数就要发一次版,且新老版本算出的数不一致。客户端拿到的算力是服务端**返回**的。
+
+### 4.2 消耗流程(`user_usages` 与 `echomeet` starttask 共用)
+
+1. 收到原始用量(App 上报的秒数 / 字数,或服务端自算的会议秒数)
+2. 写原始统计(现有字段照写,不动)
+3. 按 `COMPUTE_RATE_*` 换算成算力 `c`,向上取整,最低 1
+4. **扣减**:先扣当前绑定设备的剩余(`compute_grant - compute_used`),不够再扣 `user.compute_balance`,扣到 0 为止;仍不够的部分记为**超额**
+5. 闸门**开**且超额 > 0 → 拒绝,返回算力不足;闸门**关** → 放行
+6. 写流水:`user_use_log` 记本次算力 `c` 与来源拆分(设备 / 用户 / 超额)
+7. 累计:`user_statistics.compute_total += c`
+8. 返回当前算力余额(设备剩余 + 用户余额)
+
+绑定多台设备时,先扣**剩余最少**的那台(用完一台再动下一台,避免多台都剩一点)。
+
+### 4.3 展示
+
+客户端「用量」页展示:
+
+- VIP 状态、来源(自购 / 设备)、到期日
+- 算力:**设备剩余 + 账户余额**合并数,明细可拆
+- 累计消耗、本月消耗
+
+数据从服务端 `user_getcompute` 一次拿齐,客户端不做任何换算。
+
+### 4.4 已定取舍:算力闸门默认关
+
+VIP 期内不限量意味着单用户成本上不封顶。这是产品上明确接受的取舍。
+算力记账跑起来之后,后台看板与异常告警自然就有了数据基础;要不要开闸门,看数据再定。
+
+---
+
+## 5. 带存储设备 · 二期
+
+> 本阶段暂不实现,先把设计定下来。现役设备均不带存储。
+
+### 5.1 两个产品开关
+
+| 字段 | 含义 | 影响 |
+|---|---|---|
+| `has_storage` | 带不带本地存储 | 为 0 时下面一项无意义 |
+| `need_erase` | 解绑时需不需要清空 | 驱动**解绑确权弹窗** + **禁止接管** |
+
+| `has_storage` | `need_erase` | 可否被顶掉 | 解绑弹确权 |
+|---|---|---|---|
+| 0 | — | ✅ | 否 |
+| 1 | 0 | ✅ | 否 |
+| 1 | 1 | ❌ **禁止** | **是** |
+
+> 建议「禁止接管」挂在 `need_erase` 上(见 Q1)。`devicetype` 已废弃,不要依赖它。
+
+### 5.2 清空标签
+
+设备行新增 `erasestate`(0 = 无残留,1 = 待清空)与 `lastuid`(最后绑定者,解绑时写入不清空)。
+**解绑时写标签,绑定时随「校验 MAC」返回给客户端。**
+
+| 解绑时 | `erasestate` |
+|---|---|
+| 设备在线,清空成功 | 0 |
+| 设备在线,用户选保留 / 清空失败 | 1 |
+| 设备未连接 | 1,**照常允许解绑** |
+| `need_erase = 0` | 0 |
+
+| 绑定时读到 | 客户端动作 |
+|---|---|
+| 0 | 直接握手 |
+| 1 且 `lastuid ≠ 当前 uid` | 握手后**第一时间下发清空指令**,成功后上报置 0 |
+| 1 且 `lastuid = 当前 uid` | **跳过清空**,直接置 0 |
+
+> ⚠️ `lastuid` 不能省:A 离线解绑后又自己绑回来,无脑清空删的是 A 自己的数据。
+
+清空失败不阻断使用:标签保持 1,每次连接重试,App 顶部提示。
+
+### 5.3 解绑流程(顺序不能反)
+
+1. 点解绑 → 2. 判 `need_erase`,为 0 跳到 6 → 3. 弹确权:清空并解绑 / 保留并解绑 →
+4. 选清空且在线:下发**带回执**的清空指令 → 5. 失败/超时让用户选重试或仍然解绑,**不能卡死** →
+6. 调解绑接口写标签 → 7. 清本地痕迹并断连
+
+> ⚠️ 现有实现是「先调解绑接口 → 清白名单 → 断连」。一旦解绑链路已断,清空指令没通道。
+> **清空必须排在解绑接口之前。**
+
+### 5.4 必须一并解决的两件事
+
+- **解绑时 `uid` 清理**现在是尽力而为的(失败只记日志)。禁止接管的规则下,`uid` 没清掉 = 设备永久锁死。带存储设备的解绑必须**强一致**
+- **设备锁死的兜底**:原用户手机丢了 / 卸载 / 注销 / 忘记解绑就转卖 —— **后台强制解绑必须做**;设备端物理重置推荐做;超时自动释放不推荐
+
+### 5.5 三个前提,目前都不成立
+
+产品档案无存储能力位;固件无清空指令(且命令号随固件版本变,新指令必须带回执);现役耳机不带存储(录音流式推给手机)。
+
+---
+
+## 6. 管理后台功能清单
+
+按页面列,每项标明字段与接口。
+
+### 6.1 系统配置 → 算力设置(新页面)
+
+| 项 | 键(`config` 表,KV) | 类型 | 默认 |
+|---|---|---|---|
+| 翻译换算系数(算力 / 分钟) | `COMPUTE_RATE_TRANSLATE` | int | 1 |
+| 会议换算系数(算力 / 分钟) | `COMPUTE_RATE_MEETING` | int | 1 |
+| AI 对话换算系数(算力 / 100 字) | `COMPUTE_RATE_AICHAT` | int | 1 |
+| 算力闸门 | `COMPUTE_GATE` | 0 / 1 | **0** |
+| 开户礼 · VIP 天数 | `NEWUSER_GIFT_VIPDAYS` | int | 30 |
+| 开户礼 · 算力 | `NEWUSER_GIFT_COMPUTE` | int | 100 |
+| 提示 · VIP 到期前(天) | `VIP_WARN_DAYS` | int | 7 |
+| 提示 · 算力低于 | `COMPUTE_WARN` | int | 100 |
+
+> ⚠️ **后四项与换算系数的校验规则不同:允许配 0**(0 = 不送 / 不提示,是合法表达)。
+> 所以解析时只有「键不存在」或「解析不出数字」才回落默认值,解析出 0 就用 0;
+> 前端读取也必须用 `??` 而不是 `||`,否则后台配的 0 会被当成「没配」显示成默认值。
+
+- 存 `config` 表(`comm.TableAppConfig`),按应用作用域,随 `user_getappconfig` 的 env 下发——客户端可读到系数,**仅用于本地预估展示,不用于上报**
+- 改系数后触发现有的 `Rpc_ModifyAppConifg` 事件刷新缓存
+- 闸门开关旁必须有提示:**「开启前需确认预扣逻辑已上线」**(见 1.4)
+
+### 6.2 MAC 生成管理 → 生成 MAC(改表单)
+
+| 字段 | 变更 |
+|---|---|
+| VIP 天数 | 保留(列 `rewardvip`) |
+| VIP 等级 | **新增**,下拉:1 = VIP / 2 = VIP-Pro,默认 1 |
+| 赠送算力 | **新增**(列 `compute_grant`),0 = 不送 |
+| 翻译分钟 / 会议分钟 | **去掉** |
+
+批次列表页同步展示这三项。
+
+### 6.3 产品管理(改表单)
+
+- 产品档案上的旧奖励字段(`bindrewardvpitime` / `bindrewardtranslate` / `bindrewardmeeting`)改为:VIP 天数、VIP 等级、赠送算力,作为批次未配置时的回退
+- 二期:加 `has_storage` / `need_erase` 两个开关
+
+### 6.4 用户管理(改详情页)
+
+| 项 | 变更 |
+|---|---|
+| VIP 到期时间 | 保留,标注「自购部分」 |
+| 设备赋予的 VIP | **新增只读展示**:来自哪台设备、到期日 |
+| 算力余额 | **新增可调**:`compute_balance`,支持加减,必填备注 |
+| 设备算力 | **新增只读展示**:各绑定设备的 已用 / 总量 |
+| 旧三个额度 | 保留只读,标注「已停用」 |
+| 消耗流水 | **新增**:按时间倒序,列 时间 / 功能 / 原始量 / 算力 / 来源拆分 |
+
+后台调整算力走 `api_adjustusercompute`,写一条 `logtype = AdminAdjust` 的流水。
+
+### 6.5 设备管理(改列表 / 详情)
+
+| 列 | 说明 |
+|---|---|
+| 激活日 | `usedtime` |
+| VIP 到期 | `usedtime + vipdays`,未激活显示「未激活」 |
+| 算力 已用 / 总量 | `compute_used / compute_grant` |
+| 当前绑定者 | `uid` |
+| 二期:清空状态 | `erasestate` |
+| 二期:强制解绑 | 按钮,限超管,写审计日志 |
+
+### 6.6 商品管理(改表单)
+
+| 字段 | 变更 |
+|---|---|
+| VIP 天数 | 保留(`viptime`) |
+| 算力 | **新增**(`compute`) |
+| AI 次数 / 会议分钟 / 翻译分钟 | **去掉**(`ainum` / `meetnum` / `tradenum` 列保留不读) |
+
+### 6.7 统计看板(新页面)
+
+| 维度 | 指标 |
+|---|---|
+| 按日 | 算力消耗总量、活跃消耗用户数 |
+| 按功能 | 翻译 / 会议 / AI 各自的原始量与算力 |
+| 按渠道 / 产品 | 算力消耗(沿用现有 `StatEvent` 的渠道归属) |
+| 按用户 Top N | 单日 / 单月消耗排行——**异常用量的发现入口** |
+| 设备算力 | 已发放总量、已消耗、剩余 |
+
+---
+
+## 7. 服务端接口清单
+
+### 7.1 App 侧(`modules/user/`,反射注册,路由 `user_<方法小写>`)
+
+| 接口 | 变更 | 请求 | 响应 |
+|---|---|---|---|
+| `user_usages` | **改** | 原样:`usagetype` + `usages` 原始量;AI 分支补 `upchars` / `downchars` | 增加 `compute`(本次扣减)、`compute_device_left`、`compute_user_left`、`over`(超额,闸门关时才可能 > 0) |
+| `user_getcompute` | **新增** | 无 | `vip{ level, exptime, source[] }`、`compute{ device_left, user_balance, total_used, month_used }`、`rates{}` |
+| `user_binddevice` | **改** | 原样 | 不再往 `vipexptime` 累加;改为在设备行写 `vipdays` / `viplevel` / `compute_grant` 快照;响应带设备算力剩余 |
+| `user_translate` | **改** | 原样 | 入口加 VIP 校验;成功后走 4.2 扣减流程 |
+| `user_getinfo` 等返回 VIP 的接口 | **改** | — | VIP 状态改为「自购 或 设备赋予」合并判定 |
+| `user_getappconfig` | **改** | — | env 里带出 `COMPUTE_RATE_*` / `COMPUTE_GATE` |
+| `resolveThirdSvcs` | **改** | — | 按 VIP 状态决定是否下发翻译类凭据 |
+| `echomeet` starttask | **改** | — | 入口加 VIP 校验;任务开始时按会议秒数走 4.2 扣减流程 |
+| 二期 `user_binddevice` | 改 | — | 响应加 `erasestate`;带存储被占用时返回明确错误码 |
+| 二期 `user_unbinddevice` | 改 | 加 `erased` | — |
+| 二期 `user_reportdeviceerased` | 新增 | `devicemac` | — |
+
+**VIP 判定统一封装一个函数**(如 `comm.UserHasVip(user, devices) (level, exptime, source)`),
+所有需要判 VIP 的入口都调它,别各写一份。
+
+### 7.2 Console 侧(`modules/console/` 或 `modules/api/`)
+
+| 接口 | 变更 |
+|---|---|
+| `api_getcomputeconfig` / `api_savecomputeconfig` | **新增**,读写 6.1 四个键 |
+| 批次新增 / 编辑 | 加 `viplevel` / `compute_grant` |
+| 产品保存 | 奖励字段改为三项 |
+| `api_adjustusercompute` | **新增**:`uid`、`delta`(可负)、`remark`,写 AdminAdjust 流水 |
+| `api_getusercomputelogs` | **新增**:分页返回消耗流水 |
+| 用户详情 | 加设备 VIP / 设备算力只读展示 |
+| 设备列表 / 详情 | 加激活日、VIP 到期、算力字段 |
+| 商品保存 | 加 `compute` |
+| `api_getcomputestats` | **新增**:按日 / 功能 / 渠道 / 用户 Top N |
+| 二期 `api_forceunbind` | 新增,限超管,写审计 |
+
+### 7.3 支付发货(`modules/pay/`)
+
+`deliverGrant` 里按商品的 `compute` 累加 `user.compute_balance`,写 `logtype = Pay` 流水;
+原来发 `Ainum / Meetnum / Tradenum` 的三段**删除**。
+
+---
+
+## 8. 数据结构变更
+
+### 8.1 设备表 `device_mac`(`pb.DBAuthCode`)
+
+| 列 | 类型 | 阶段 | 说明 |
+|---|---|---|---|
+| `vipdays` | int32 | 一期 | 激活时从批次快照,0 = 不送 |
+| `viplevel` | int32 | 一期 | 1 = VIP,2 = VIP-Pro |
+| `compute_grant` | int64 | 一期 | 赠送算力总量(快照) |
+| `compute_used` | int64 | 一期 | 已消耗,**解绑不重置** |
+| `erasestate` | int32 | 二期 | 0 / 1 |
+| `lastuid` | string(50) | 二期 | 最后绑定者 |
+
+复用:`usedtime`(激活日)、`uid`、`status`、`disabled`。
+
+### 8.2 生产批次表 `production_batch`
+
+| 列 | 变更 |
+|---|---|
+| `rewardvip` | 复用为 VIP 天数 |
+| `viplevel` | **新增**,默认 1 |
+| `compute_grant` | **新增**,默认 0 |
+| `rewardtranslate` / `rewardmeeting` | 不再读写 |
+
+### 8.3 用户表 `user`
+
+| 列 | 变更 |
+|---|---|
+| `vipexptime` | 语义收窄为**只记自购** |
+| `viplv` | 保持 |
+| `compute_balance` | **新增** int64,默认 0 |
+| `tradeintegral` / `meetintegral` / `aichatintegral` | 停用,保留不清(见 Q3) |
+
+### 8.4 用量流水 `user_use_log`
+
+| 列 | 变更 |
+|---|---|
+| `compute` | **新增** int64,本次算力(消耗为负,发放为正) |
+| `compute_device` | **新增** int64,从设备扣的部分 |
+| `compute_user` | **新增** int64,从用户余额扣的部分 |
+| `compute_over` | **新增** int64,超额部分(闸门关时) |
+| `devicemac` | **新增** string,扣了哪台设备 |
+| `logtype` | 枚举加 `AdminAdjust` |
+| 现有 `addvipday` / `addtradesecond` / `addmeetsecond` / `addagentintegral` | 保留:原始量照记 |
+
+### 8.5 统计表 `user_statistics`
+
+| 列 | 变更 |
+|---|---|
+| `compute_total` | **新增** int64,累计消耗算力 |
+| `aichatuptoken` / `aichatdowntoken` | 补写入(存字符数,注释写死口径) |
+| 其余 | 不变 |
+
+### 8.6 商品表 `goods`
+
+`compute` **新增** int64。`ainum` / `meetnum` / `tradenum` 保留不读。
+
+### 8.7 产品表 `product`
+
+一期:奖励三字段语义改为 VIP 天数 / 等级 / 算力(可复用 `bindrewardvpitime`,新增 `bindviplevel` / `bindcompute`)。
+二期:`has_storage` / `need_erase`。
+
+### 8.8 配置表 `config`(KV)
+
+四个键见 6.1。
+
+> ⚠️ `pb/*.pb.go` 由 protoc 生成,**不能手改**。改 `apps/proto/` 下 `.proto` 后
+> `cd apps/proto && python3 pb.py`,生成器须是 `protoc-gen-go v1.36.6`,
+> 之后 `git diff --stat -- apps/services/pb/` 确认只动了该动的文件。
+> 客户端 `appconfig_model.dart` 是手写镜像,新增下发字段后要同步并重跑 `build_runner`。
+
+---
+
+## 9. 边界场景
+
+| 场景 | 系统行为 |
+|---|---|
+| 出厂后长期未激活 | 不计时。激活后仍是完整 `vipdays` 与 `compute_grant` |
+| A 解绑后 B 绑定 | B 继承剩余 VIP 天数与剩余算力,不重新开始 |
+| A 未解绑,B 直接绑定 | 一期允许接管;A 立即失去设备 VIP 与设备算力,需提示 |
+| 同账号换手机 | 允许,不算接管 |
+| 同一用户绑多台设备 | VIP 取期最晚、等级取最高;算力求和,扣减时先扣剩余最少的那台 |
+| 设备算力用完,VIP 还在 | 闸门关:照常用,记超额;闸门开:扣用户余额,也没了则拒绝 |
+| VIP 过期,算力还有 | 翻译 / 会议不可用;AI 对话可用并扣算力 |
+| VIP 在使用过程中过期 | **当前会话允许跑完**,下次开启才拦 |
+| VIP 过期后查看历史 | 照常可看 |
+| 改换算系数 | 只影响之后的消耗,历史流水里的算力值不重算 |
+| 闸门从关切到开 | 已有超额记录不追溯;开启前必须先上线预扣 |
+| 后台调整用户算力为负 | 拒绝,余额不能低于 0 |
+| 账号注销 | 视同解绑;自购 VIP 与用户算力随账号消失 |
+| 设备被后台禁用 / 退货 | 立即不再赋予 VIP 与算力 |
+| 时区 | 全部按服务端秒级时间戳 |
+
+---
+
+## 10. 与线上现状的差异
+
+### 一期
+
+| 维度 | 现在(线上) | 目标 |
+|---|---|---|
+| 功能准入 | 三个额度桶各自拦 | **只看 VIP** |
+| 用量计量 | 翻译秒 / 会议秒 / AI 次数三桶 | 原始量照记,**统一折算算力** |
+| 换算系数 | 无 | **后台可配** |
+| 生产批次配置 | VIP 天 + 翻译分钟 + 会议分钟 | VIP 天 + **等级** + **算力** |
+| VIP 发放 | 绑定时累加天数到用户表 | 设备行存激活日 + 天数,不碰用户表 |
+| 发放判据 | 只有第一个绑定者能拿 | 随绑定流转,剩余部分给下一个人 |
+| 设备算力记账 | 无 | 设备行 `compute_grant / compute_used` |
+| 用户算力 | 无 | `compute_balance`,可充值可后台调 |
+| 消耗时行为 | 扣桶,不足则拒绝 | 换算 → 先设备后用户扣减 → 闸门关时放行 |
+| 商品发放 | AI 次 / 会议分 / 翻译分 / VIP 天 | **算力 + VIP 天** |
+| AI 用量 | 只记次数 | 补记输入 / 输出字数 |
+| 用量看板 | 无 | 新增 |
+
+### 二期
+
+接管禁止、清空标签、产品存储开关、固件清空指令、后台强制解绑——见第 5 节。
+
+### 存量迁移
+
+1. **VIP 天数不动**:存量 `vipexptime` 视同自购
+2. **旧三桶余额折算成算力**写入 `compute_balance`:翻译秒 ÷ 60 × 系数 + 会议秒 ÷ 60 × 系数 + AI 次 × 1,向上取整。旧字段保留不清
+3. **存量设备补快照**:`vipdays` 按批次 `rewardvip` 回填;`compute_grant` 按批次新值回填(批次没配就是 0);`compute_used = 0`
+4. **存量用户已领过的一次性绑定礼不追溯**
+
+---
+
+## 11. 待决策
+
+已拍板不再讨论:**VIP 是功能准入闸门**、**VIP 过期后翻译与会议纪要受限**、**用量统一折算算力**、**设备带赠送算力上限**、**算力闸门默认关**。
+
+| # | 问题 | 建议 |
+|---|---|---|
+| Q1 | 「禁止接管」挂 `has_storage` 还是 `need_erase`? | **挂 `need_erase`** |
+| Q2 | 设备赠送算力要不要设有效期(如与 VIP 期同步)? | **不设**,用完为止。「上限」语义就是总量,加有效期多一套结算 |
+| Q3 | 旧三桶余额:折算进 `compute_balance` 还是直接放弃? | **折算**,用户已购买的额度不能凭空消失 |
+| ~~Q4~~ | ~~三个换算系数的初始值~~ | ✅ 已落地:后台可配,默认全 1 |
+| ~~Q5~~ | ~~算力包商品的定价与档位~~ | ✅ 已落地:`UsageType.Compute=5`,后台可建算力包商品,客户端会员页展示并可购买。**具体档位与定价由运营在后台建商品** |
+| ~~Q6~~ | ~~开户礼送多少~~ | ✅ 已落地:后台可配(VIP 天数 + 算力),默认 30 天 / 100 算力,**都可配 0 = 不送** |
+| ~~Q7~~ | ~~提前提示阈值~~ | ✅ 已落地:后台可配(到期前 N 天、算力低于 N),默认 7 天 / 100,**配 0 = 不提示**;随 `user_getcompute` 下发,客户端据此显示提示条 |
+| Q8 | 设备端物理重置要不要做? | 二期与固件一起评估 |
+| Q9 | 后台强制解绑的权限与审计? | 限超管,留日志 |
+
+---
+
+## 附:口径演进
+
+| 轮次 | 变更 |
+|---|---|
+| v1 | 权益归属设备、按期发放、期末清零、灵活可配 |
+| v2 | 确认「优先扣设备额度」「激活日起算」;新增共享绑定、解绑隐私确权 |
+| v3 | 统一 token 计量;带存储禁止接管;清空标签;连续计时 |
+| v4 | VIP 成为唯一闸门;生产配置只留 VIP 天数;token 退回纯统计;绑定规则拆两期 |
+| v4 补充 | 取消额度闸门;VIP 过期受限范围 = 翻译与会议纪要;功能权限清单与拦截点 |
+| **v5(本版)** | **用量统一折算「算力」**,系数后台可配;**设备带赠送算力上限**;用户可充值算力;**算力闸门做成开关默认关**;补齐后台功能与接口清单到字段级 |