From 37817f2e962e34bca240253569d2f3353cd9b96a Mon Sep 17 00:00:00 2001 From: Rodger-Wang <1367893453@qq.com> Date: Fri, 11 Sep 2026 18:11:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=9A=E5=91=98=E4=B8=8E=E7=AE=97=E5=8A=9B?= =?UTF-8?q?=EF=BC=9AVIP=20=E7=AE=A1=E5=87=86=E5=85=A5=E3=80=81=E7=AE=97?= =?UTF-8?q?=E5=8A=9B=E7=AE=A1=E8=AE=A1=E9=87=8F=EF=BC=9B=E8=A1=A5=E4=B8=8A?= =?UTF-8?q?=E7=BA=BF=E8=90=BD=E5=9C=B0=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VIP 与算力两层权益各管一件事,判定与记账统一走 comm/compute.go: - VIP 管功能准入(无 VIP 时 user_translate / echomeet_starttask 返回 VipRequired); 自购记 user.vipexptime,设备赋予记在 device_mac 行上(激活日 + 天数), 两者在 ResolveUserVip 合并成有效值下发,库里仍只记自购——以前绑定礼把天数 累加进用户表,解绑时拆不开。 - 算力管用量计量,按后台系数折算,先扣设备赠送再扣用户余额。 三个旧额度桶停用,存量余额由 home 启动时一次性折进 computebalance。 - 闸门 COMPUTE_GATE 默认关:user_usages 是用完才上报、没有预扣, 现在开闸门形同虚设。 另含本次「阿龙测试服 → 正式服」上线落地文档(docs/), 以及 admin 侧商品/设备/产品/用户页的配套改动。 Co-Authored-By: Claude Opus 5 (1M context) --- apps/admin/app/pages/appgoods.vue | 48 +- apps/admin/app/pages/compute.vue | 301 +++++++ apps/admin/app/pages/device.vue | 125 ++- apps/admin/app/pages/product.vue | 109 +-- apps/admin/app/pages/users.vue | 201 ++++- apps/admin/app/utils/menus.ts | 1 + apps/proto/.DS_Store | Bin 6148 -> 8196 bytes apps/proto/api/api_msg.proto | 4 +- apps/proto/db.proto | 48 +- apps/proto/enum.proto | 8 + apps/proto/errorcode.proto | 5 + apps/proto/user/user_msg.proto | 52 +- apps/services/comm/appgoods.go | 1 + apps/services/comm/compute.go | 664 ++++++++++++++ apps/services/comm/compute_test.go | 172 ++++ apps/services/comm/svcpool.go | 5 + apps/services/lego/.DS_Store | Bin 6148 -> 6148 bytes apps/services/modules/.DS_Store | Bin 6148 -> 10244 bytes .../modules/api/api_dispatchresource.go | 19 +- apps/services/modules/api/model.go | 9 + apps/services/modules/console/api_compute.go | 427 +++++++++ apps/services/modules/console/api_config.go | 4 + apps/services/modules/console/api_device.go | 42 +- apps/services/modules/console/api_goods.go | 2 + apps/services/modules/console/registry.go | 43 + apps/services/modules/console/server.go | 60 +- .../modules/echomeet/api_starttask.go | 56 +- apps/services/modules/pay/api.go | 47 +- .../services/modules/user/api_bindauthcode.go | 28 +- apps/services/modules/user/api_binddevice.go | 185 ++-- apps/services/modules/user/api_getcompute.go | 93 ++ apps/services/modules/user/api_getinfo.go | 7 + apps/services/modules/user/api_sgin.go | 8 +- apps/services/modules/user/api_translate.go | 7 + apps/services/modules/user/api_usages.go | 150 ++-- .../modules/user/api_v2_getthirdsvcs.go | 2 +- .../modules/user/api_v3_getappconfig.go | 2 +- apps/services/modules/user/model_config.go | 5 + apps/services/modules/user/model_user.go | 51 ++ apps/services/modules/user/module_test.go | 65 ++ apps/services/modules/user/newuser_gift.go | 57 +- .../modules/user/newuser_gift_test.go | 59 +- apps/services/modules/user/svcresolve.go | 24 + apps/services/pb/api_msg.pb.go | 24 +- apps/services/pb/db.pb.go | 308 +++++-- apps/services/pb/enum.pb.go | 20 +- apps/services/pb/errorcode.pb.go | 17 +- apps/services/pb/user_msg.pb.go | 816 ++++++++++++++---- apps/services/services/.DS_Store | Bin 6148 -> 6148 bytes deploy/app/dev-deploy.sh | 15 +- ...¾™测试服上线正式服-落地文档.md | 642 ++++++++++++++ ...›Š与用量统计-设计与开发文档.md | 587 +++++++++++++ 52 files changed, 4847 insertions(+), 778 deletions(-) create mode 100644 apps/admin/app/pages/compute.vue create mode 100644 apps/services/comm/compute.go create mode 100644 apps/services/comm/compute_test.go create mode 100644 apps/services/modules/console/api_compute.go create mode 100644 apps/services/modules/user/api_getcompute.go create mode 100644 docs/2026-09-11-阿龙测试服上线正式服-落地文档.md create mode 100644 docs/会员权益与用量统计-设计与开发文档.md diff --git a/apps/admin/app/pages/appgoods.vue b/apps/admin/app/pages/appgoods.vue index 75ba3124..91dd8176 100644 --- a/apps/admin/app/pages/appgoods.vue +++ b/apps/admin/app/pages/appgoods.vue @@ -5,7 +5,7 @@

应用商品配置

-

应用的内购商品(VIP 时长、AI/会议/翻译资源点包等),支持按语言配置名称、货币与售价。统一存后台公共库,按应用隔离,同一应用的各区域部署共用一份。

+

应用的内购商品(VIP 时长、算力包),支持按语言配置名称、货币与售价。统一存后台公共库,按应用隔离,同一应用的各区域部署共用一份。

@@ -115,25 +115,20 @@

发放内容

-

购买成功后给用户加的资源,按需填写,不发的留 0。

-
-
- - -
-
- - -
+

购买成功后给用户加的资源,按需填写,不发的留 0。算力归账号、不过期;VIP 天数累加到自购到期时间。

+
- - + +
- - + +
+

+ 这个商品还配着旧的资源点(AI {{ form.ainum }} / 会议 {{ form.meetnum }}s / 翻译 {{ form.tradenum }}s)。算力填 0 时发货会按当前换算系数把它们折成算力;填了算力则以算力为准。 +

@@ -209,18 +204,22 @@ interface Goods { meetnum: number tradenum: number viptime: number + compute: number sort: number remark: string locales: Record } -// pb.UsageType:1翻译 2会议 3智能体 4购买VIP时长 +// pb.UsageType:1翻译 2会议 3智能体 4购买VIP时长 5购买算力 +// ⚠️ 1/2/3 是旧的资源点包类型,2026-09 起不再新建(三个额度桶已停用); +// 存量商品仍按这些类型分组展示,发货时旧资源点会按换算系数折成算力。 const USAGE_TYPES = [ { value: 0, label: '未指定' }, - { value: 1, label: '翻译' }, - { value: 2, label: '会议' }, - { value: 3, label: '智能体' }, + { value: 1, label: '翻译(旧)' }, + { value: 2, label: '会议(旧)' }, + { value: 3, label: '智能体(旧)' }, { value: 4, label: 'VIP 时长' }, + { value: 5, label: '算力包' }, ] const { consoleApi } = useApi() @@ -248,6 +247,7 @@ const form = reactive({ meetnum: 0, tradenum: 0, viptime: 0, + compute: 0, sort: 0, remark: '', locales: [] as Array<{ lang: string } & GoodsLocale>, @@ -268,9 +268,13 @@ function minutesText(sec: number) { function rewardText(g: Goods) { const parts: string[] = [] if (g.viptime) parts.push(`VIP ${g.viptime} 天`) - if (g.ainum) parts.push(`AI ${g.ainum} 点`) - if (g.meetnum) parts.push(`会议 ${minutesText(g.meetnum)}`) - if (g.tradenum) parts.push(`翻译 ${minutesText(g.tradenum)}`) + if (g.compute) parts.push(`算力 ${g.compute}`) + else { + // 老商品:没配算力、还留着旧资源点,发货时会折算;列表上标出来免得以为它什么都不发 + if (g.ainum) parts.push(`AI ${g.ainum} 点(旧)`) + if (g.meetnum) parts.push(`会议 ${minutesText(g.meetnum)}(旧)`) + if (g.tradenum) parts.push(`翻译 ${minutesText(g.tradenum)}(旧)`) + } return parts.join(' · ') } diff --git a/apps/admin/app/pages/compute.vue b/apps/admin/app/pages/compute.vue new file mode 100644 index 00000000..10e31005 --- /dev/null +++ b/apps/admin/app/pages/compute.vue @@ -0,0 +1,301 @@ + + + + + diff --git a/apps/admin/app/pages/device.vue b/apps/admin/app/pages/device.vue index af864031..8991163f 100644 --- a/apps/admin/app/pages/device.vue +++ b/apps/admin/app/pages/device.vue @@ -16,7 +16,10 @@
@@ -25,7 +28,7 @@
-

MAC记录默认按生产时间倒序

+

MAC记录一条 = 一台设备,默认按生产时间倒序

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