Browse Source

services,admin: 后台赠送只发 VIP 天数与算力;console 读系数走应用连接

- giftUserResource 入参改为 vipday + compute,后台不再按功能分桶发放分钟数
  (那是 2026-09 停用的旧三桶模型);trademin/meetmin 保留为入参兼容,
  老 admin 容器与历史脚本送上来的分钟数按当前系数折成算力,进同一个 addcompute。
  代理余额只有 balance_vipday/trademin/meetmin 三列、没有算力列,这条路的边界在注释里写明。
- console 读换算系数改用按 X-App-Id 取出的连接(computeRatesOf)。
  ⚠️ console 是单例服务,InitSys 从不 mysql.OnInit,包级 comm.LoadComputeRates()
  一调就 nil interface method call panic,被 gin.Recovery 兜成 HTTP 500,
  前端只看得到状态码、拿不到任何业务错误;何况系数本来就按应用存在各自业务库。
  新增 pkgdb_boundary_test.go 守这条边界。
- 后台上传 OTA 更新包放开 .upd 扩展名。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 4 weeks ago
parent
commit
461289b780
  1. 2
      apps/admin/app/pages/product.vue
  2. 29
      apps/admin/app/pages/users.vue
  3. 18
      apps/services/modules/console/api_compute.go
  4. 79
      apps/services/modules/console/pkgdb_boundary_test.go
  5. 35
      apps/services/modules/console/server.go

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

@ -368,7 +368,7 @@
</div> </div>
<div class="form-control mb-3"> <div class="form-control mb-3">
<label class="label"><span class="label-text text-xs">OTA 更新包 *</span></label> <label class="label"><span class="label-text text-xs">OTA 更新包 *</span></label>
<input type="file" class="file-input file-input-bordered file-input-sm w-full" accept=".bin,.hex,.zip,.gz,.tar,.ota,.ufw,.fot" @change="onOtaPick" /> <input type="file" class="file-input file-input-bordered file-input-sm w-full" accept=".bin,.hex,.zip,.gz,.tar,.ota,.ufw,.fot,.upd" @change="onOtaPick" />
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<label class="flex items-center gap-2 cursor-pointer"><input v-model="ver.isuse" type="checkbox" class="checkbox checkbox-primary checkbox-sm" /><span class="text-sm">设为当前使用版本</span></label> <label class="flex items-center gap-2 cursor-pointer"><input v-model="ver.isuse" type="checkbox" class="checkbox checkbox-primary checkbox-sm" /><span class="text-sm">设为当前使用版本</span></label>

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

@ -290,20 +290,20 @@
目标用户 UID:<span class="font-mono text-slate-600">{{ lastUid }}</span>。 目标用户 UID:<span class="font-mono text-slate-600">{{ lastUid }}</span>。
这里是<b>增量赠送</b>(在现有余额上累加);代理账号从自己的资源点余额中扣减,超管/管理员无消耗。 这里是<b>增量赠送</b>(在现有余额上累加);代理账号从自己的资源点余额中扣减,超管/管理员无消耗。
</p> </p>
<div class="grid grid-cols-3 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="form-control"> <div class="form-control">
<label class="label py-1"><span class="label-text text-sm">VIP天数</span></label> <label class="label py-1"><span class="label-text text-sm">VIP天数</span></label>
<input v-model.number="gift.vipday" type="number" class="input input-bordered" min="0" /> <input v-model.number="gift.vipday" type="number" class="input input-bordered" min="0" />
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label py-1"><span class="label-text text-sm">翻译分钟</span><span class="label-text-alt text-slate-400">按系数折成算力</span></label> <label class="label py-1"><span class="label-text text-sm">算力</span></label>
<input v-model.number="gift.trademin" type="number" class="input input-bordered" min="0" /> <input v-model.number="gift.compute" type="number" class="input input-bordered" min="0" />
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">会议分钟</span><span class="label-text-alt text-slate-400">按系数折成算力</span></label>
<input v-model.number="gift.meetmin" type="number" class="input input-bordered" min="0" />
</div> </div>
</div> </div>
<p class="text-xs text-slate-400 mt-3">
只发算力,不按功能分桶。翻译 / 会议纪要 / AI 对话消耗时,服务端按「会员与算力」页配的系数
把上报的分钟数、字数折成算力再扣,后台无需换算。
</p>
<div class="modal-action"> <div class="modal-action">
<button class="btn btn-sm" @click="closeGift">取消</button> <button class="btn btn-sm" @click="closeGift">取消</button>
<button class="btn btn-success btn-sm text-white" @click="submitGift">确认赠送</button> <button class="btn btn-success btn-sm text-white" @click="submitGift">确认赠送</button>
@ -387,7 +387,7 @@ const logs = ref<Record<string, any>[]>([])
const logsTotal = ref(0) const logsTotal = ref(0)
const giftModal = ref<HTMLDialogElement | null>(null) const giftModal = ref<HTMLDialogElement | null>(null)
const gift = reactive({ vipday: 0, trademin: 0, meetmin: 0 }) const gift = reactive({ vipday: 0, compute: 0 })
// 一级:去重后的应用名(同一应用的国内/海外部署合成一项) // 一级:去重后的应用名(同一应用的国内/海外部署合成一项)
const appNames = computed(() => { const appNames = computed(() => {
@ -704,22 +704,21 @@ async function refreshCurrent() {
function openGift() { function openGift() {
if (!lastUid.value) { warn('请先选择用户'); return } if (!lastUid.value) { warn('请先选择用户'); return }
gift.vipday = 0 gift.vipday = 0
gift.trademin = 0 gift.compute = 0
gift.meetmin = 0
giftModal.value?.showModal() giftModal.value?.showModal()
} }
function closeGift() { giftModal.value?.close() } function closeGift() { giftModal.value?.close() }
async function submitGift() { async function submitGift() {
const vipday = Number(gift.vipday) || 0 const vipday = Number(gift.vipday) || 0
const trademin = Number(gift.trademin) || 0 const compute = Number(gift.compute) || 0
const meetmin = Number(gift.meetmin) || 0 if (vipday < 0 || compute < 0) { warn('数量不能为负'); return }
if (vipday < 0 || trademin < 0 || meetmin < 0) { warn('数量不能为负'); return } if (!vipday && !compute) { warn('请至少填写一项'); return }
if (!vipday && !trademin && !meetmin) { warn('请至少填写一项'); return }
if (!lastAppId.value || !lastUid.value) { warn('请先选择用户'); return } if (!lastAppId.value || !lastUid.value) { warn('请先选择用户'); return }
auth.setCurrentApp(Number(lastAppId.value)) auth.setCurrentApp(Number(lastAppId.value))
try { try {
await webApi('giftuserresource', { uid: lastUid.value, vipday, trademin, meetmin }) // 只送 VIP 天数与算力。后端的 trademin/meetmin 只是老前端的兼容入参,新前端不再传。
await webApi('giftuserresource', { uid: lastUid.value, vipday, compute })
success('赠送成功') success('赠送成功')
giftModal.value?.close() giftModal.value?.close()
await refreshCurrent() await refreshCurrent()

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

@ -63,6 +63,24 @@ func (this *serverComp) appConnFromCtx(c *gin.Context) (mysql.ISys, bool) {
return conn, true return conn, true
} }
// computeRatesOf 读选中应用业务库里的换算系数(读不到回默认值,计费不能因配置表抖动而中断)。
//
// ⚠️ **console 里不能用 comm.LoadComputeRates()**:它走的是包级 mysql 连接(业务进程自己的那条),
// 而 console 是单例服务,InitSys 只初始化 postgres(主库)/redis/nats/email,**从不 mysql.OnInit** ——
// 包级 defsys 恒为 nil,一调就 nil interface method call panic,被 gin.Recovery 兜成 HTTP 500,
// 前端只看得到状态码、拿不到任何业务错误信息。console 拿到的是按 X-App-Id 取出的连接对象,只能走 conn。
// 另外系数本来就是**按应用**存在各自业务库的 config 表里,包级连接就算初始化了也会读到别人那一份。
func computeRatesOf(conn mysql.ISys) comm.ComputeRates {
items := make([]*pb.DBAppConfigItem, 0, 4)
kv := map[string]string{}
if err := conn.Find(comm.TableAppConfig, &items, "`key` IN ?", computeConfigKeys); err == nil {
for _, it := range items {
kv[it.Key] = it.Value
}
}
return comm.ParseComputeRates(kv)
}
// getComputeConfig 读选中应用的算力换算系数与闸门。 // getComputeConfig 读选中应用的算力换算系数与闸门。
func (this *serverComp) getComputeConfig(c *gin.Context) { func (this *serverComp) getComputeConfig(c *gin.Context) {
conn, ok := this.appConnFromCtx(c) conn, ok := this.appConnFromCtx(c)

79
apps/services/modules/console/pkgdb_boundary_test.go

@ -0,0 +1,79 @@
package console
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"strings"
"testing"
)
// console 是**单例服务**:InitSys 只初始化 postgres(主库)/redis/nats/email,**从不 mysql.OnInit**。
// 业务库连接是按 X-App-Id 用 mysql.NewSys 现建的实例(registry.buildAppConn),跟包级连接是两回事。
//
// 所以包级 mysql 的 defsys 恒为 nil,任何走它的调用都是 nil interface method call panic,
// 被 gin.Recovery 兜成 HTTP 500 —— 前端只拿得到状态码,看不到任何业务错误信息,极难定位。
// 2026-09-14 的「赠送资源点」500 就是 giftUserResource 调了 comm.LoadComputeRates() 造成的。
//
// 而且这些配置本来就是**按应用**存在各自业务库里的,包级连接即使初始化了也会读到别人那一份。
// console 侧一律走 conn(见 api_compute.go 的 computeRatesOf / appConnFromCtx)。
// comm 包里直接使用包级 mysql/postgres 连接的导出函数,console 不能调。
var commPkgLevelDBFuncs = map[string]string{
"LoadOpsParams": "改用 conn.Find(comm.TableAppConfig, ...) + comm.ParseOpsParams",
"LoadComputeRates": "改用 computeRatesOf(conn)",
"SaveComputeRates": "改用 saveComputeConfig 里按 conn 写的那套",
"BoundFactoryDevices": "改用 registry 主库连接自己查 device_mac",
"ResolveUserVip": "改用 userComputeSummary(conn, user) 那条跨库拼装",
"ApplyComputeUsage": "console 不记消耗,只按 conn 调整余额",
"AdjustUserCompute": "改用 api_compute.go 里按 conn 重写的那份",
"GrantUserCompute": "改用 conn.Table(comm.TableUser).Update(...)",
}
func TestConsoleDoesNotUsePackageLevelDB(t *testing.T) {
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, ".", func(fi fs.FileInfo) bool {
return !strings.HasSuffix(fi.Name(), "_test.go")
}, 0) // 0 = 丢弃注释,注释里提到函数名不算调用
if err != nil {
t.Fatalf("解析 console 包失败: %v", err)
}
for _, pkg := range pkgs {
for path, f := range pkg.Files {
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
ident, ok := sel.X.(*ast.Ident)
if !ok {
return true
}
pos := fset.Position(call.Pos())
switch ident.Name {
case "comm":
if fix, bad := commPkgLevelDBFuncs[sel.Sel.Name]; bad {
t.Errorf("%s:%d: console 不能调 comm.%s —— 它走包级 mysql 连接,"+
"console 从不初始化,运行时必 panic → HTTP 500。%s",
path, pos.Line, sel.Sel.Name, fix)
}
case "mysql":
// 只允许建实例与设置选项:mysql.NewSys / mysql.SetXxx。
// 其余(Find/FindOne/Insert/Save/Table/Exec…)都是包级 defsys 的转发。
name := sel.Sel.Name
if name != "NewSys" && !strings.HasPrefix(name, "Set") {
t.Errorf("%s:%d: console 不能调包级 mysql.%s —— defsys 恒为 nil,"+
"改用按 X-App-Id 取出的 conn(appConnFromCtx / registry.getServiceDB)",
path, pos.Line, name)
}
}
return true
})
}
}
}

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

@ -955,10 +955,17 @@ func (this *serverComp) getUserInfo(c *gin.Context) {
} }
// giftUserResource 赠送资源点给终端用户:直连选中应用(X-App-Id)业务库,给 user 加 // giftUserResource 赠送资源点给终端用户:直连选中应用(X-App-Id)业务库,给 user 加
// VIP天/翻译分钟/会议分钟,并写 useruselog(AdminGive) 流水。 // VIP天 / 算力,并写 useruselog(AdminGive) 流水。
// - 超管 / 管理员:无消耗,直接发放; // - 超管 / 管理员:无消耗,直接发放;
// - 代理:先原子扣减自己的资源点余额,不足即拒绝;写库失败回滚已扣余额; // - 代理:先原子扣减自己的资源点余额,不足即拒绝;写库失败回滚已扣余额;
// - 运营:无权赠送。 // - 运营:无权赠送。
//
// **后台只管控两个量:VIP 天数与算力值。** 翻译 / 会议 / AI 对话的分钟数、字符数不在这里填 ——
// 它们是消耗侧的原始量,由 comm.ApplyComputeUsage 在上报时按后台配的系数折成算力再扣,
// 后台没有必要、也不应该按功能分桶发放(那正是 2026-09 停用的旧三桶模型)。
//
// trademin / meetmin 仅为**入参兼容**保留(老 admin 容器、历史脚本),非零时按当前系数折成算力
// 并进同一个 addcompute;新前端只送 compute。
func (this *serverComp) giftUserResource(c *gin.Context) { func (this *serverComp) giftUserResource(c *gin.Context) {
identity := currentIdentity(c) identity := currentIdentity(c)
if identity != pb.Identity_Admin && identity != pb.Identity_Manager && identity != pb.Identity_Agent { if identity != pb.Identity_Admin && identity != pb.Identity_Manager && identity != pb.Identity_Agent {
@ -968,6 +975,8 @@ func (this *serverComp) giftUserResource(c *gin.Context) {
var req struct { var req struct {
Uid string `json:"uid"` Uid string `json:"uid"`
Vipday int64 `json:"vipday"` Vipday int64 `json:"vipday"`
Compute int64 `json:"compute"`
// 兼容入参:老前端按功能分桶送分钟数,服务端折成算力。
Trademin int64 `json:"trademin"` Trademin int64 `json:"trademin"`
Meetmin int64 `json:"meetmin"` Meetmin int64 `json:"meetmin"`
} }
@ -977,11 +986,11 @@ func (this *serverComp) giftUserResource(c *gin.Context) {
writeErr(c, pb.ErrorCode_ReqParameterError, "请输入要赠送的用户UID") writeErr(c, pb.ErrorCode_ReqParameterError, "请输入要赠送的用户UID")
return return
} }
if req.Vipday < 0 || req.Trademin < 0 || req.Meetmin < 0 { if req.Vipday < 0 || req.Compute < 0 || req.Trademin < 0 || req.Meetmin < 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "赠送数量不能为负") writeErr(c, pb.ErrorCode_ReqParameterError, "赠送数量不能为负")
return return
} }
if req.Vipday == 0 && req.Trademin == 0 && req.Meetmin == 0 { if req.Vipday == 0 && req.Compute == 0 && req.Trademin == 0 && req.Meetmin == 0 {
writeErr(c, pb.ErrorCode_ReqParameterError, "请至少填写一项赠送数量") writeErr(c, pb.ErrorCode_ReqParameterError, "请至少填写一项赠送数量")
return return
} }
@ -1007,7 +1016,15 @@ func (this *serverComp) giftUserResource(c *gin.Context) {
now := time.Now().Unix() now := time.Now().Unix()
accId := currentAccountId(c) accId := currentAccountId(c)
// 代理:先原子扣减自己的资源点余额(CAS 防超发),不足即拒绝。 // 代理:先原子扣减自己的资源点余额(CAS 防超发),不足即拒绝。
//
// ⚠️ 代理余额只有 balance_vipday/trademin/meetmin 三列,**没有算力列**,所以代理走不了
// compute 这条新路。这段实际已不可达:品牌商(identity=3)于 2026-09-12 整体下线,
// migrateBrandAccountsToOperator 在 console 启动时把存量全部改成 identity=4 且禁用。
if identity == pb.Identity_Agent { if identity == pb.Identity_Agent {
if req.Compute > 0 {
writeErr(c, pb.ErrorCode_InsufficientPermissions, "代理账号没有算力余额,无法赠送算力")
return
}
ok, e := this.module.model.agentDeductBalance(accId, req.Vipday, req.Trademin, req.Meetmin, now) ok, e := this.module.model.agentDeductBalance(accId, req.Vipday, req.Trademin, req.Meetmin, now)
if e != nil { if e != nil {
writeErr(c, pb.ErrorCode_DBError, e.Error()) writeErr(c, pb.ErrorCode_DBError, e.Error())
@ -1020,8 +1037,6 @@ func (this *serverComp) giftUserResource(c *gin.Context) {
} }
// 应用到用户余额(VIP 过期时间:已过期/未开通则从现在起算,否则顺延;与公码奖励口径一致)。 // 应用到用户余额(VIP 过期时间:已过期/未开通则从现在起算,否则顺延;与公码奖励口径一致)。
addTradeSec := req.Trademin * 60
addMeetSec := req.Meetmin * 60
if req.Vipday > 0 { if req.Vipday > 0 {
addVipSec := req.Vipday * 24 * 60 * 60 addVipSec := req.Vipday * 24 * 60 * 60
if user.Vipexptime == 0 || user.Vipexptime < now { if user.Vipexptime == 0 || user.Vipexptime < now {
@ -1030,8 +1045,14 @@ func (this *serverComp) giftUserResource(c *gin.Context) {
user.Vipexptime += addVipSec user.Vipexptime += addVipSec
} }
} }
// 2026-09 起三个额度桶停用:翻译分 / 会议分按当前换算系数折成算力发到用户余额。 // 算力:新前端直接给算力值;老前端给的分钟数按**本应用**当前系数折算后并进同一个数。
addCompute := comm.LegacyBucketsToCompute(comm.LoadComputeRates(), addTradeSec, addMeetSec, 0) //
// ⚠️ 系数必须用 computeRatesOf(conn) 读,不能用 comm.LoadComputeRates()——后者走包级 mysql
// 连接,console 从不初始化它,一调就 panic → HTTP 500。理由见 api_compute.go 的 computeRatesOf。
addCompute := req.Compute
if req.Trademin > 0 || req.Meetmin > 0 {
addCompute += comm.LegacyBucketsToCompute(computeRatesOf(conn), req.Trademin*60, req.Meetmin*60, 0)
}
user.Computebalance += addCompute user.Computebalance += addCompute
if err := conn.Save(comm.TableUser, user); err != nil { if err := conn.Save(comm.TableUser, user); err != nil {
// 代理:写库失败回滚已扣余额,避免用户没到账却扣了代理。 // 代理:写库失败回滚已扣余额,避免用户没到账却扣了代理。

Loading…
Cancel
Save