diff --git a/apps/admin/app/pages/users.vue b/apps/admin/app/pages/users.vue
index 5b368fba..3c20398e 100644
--- a/apps/admin/app/pages/users.vue
+++ b/apps/admin/app/pages/users.vue
@@ -290,20 +290,20 @@
目标用户 UID:
+
+
+ 只发算力,不按功能分桶。翻译 / 会议纪要 / AI 对话消耗时,服务端按「会员与算力」页配的系数
+ 把上报的分钟数、字数折成算力再扣,后台无需换算。
+
@@ -387,7 +387,7 @@ const logs = ref[]>([])
const logsTotal = ref(0)
const giftModal = ref(null)
-const gift = reactive({ vipday: 0, trademin: 0, meetmin: 0 })
+const gift = reactive({ vipday: 0, compute: 0 })
// 一级:去重后的应用名(同一应用的国内/海外部署合成一项)
const appNames = computed(() => {
@@ -704,22 +704,21 @@ async function refreshCurrent() {
function openGift() {
if (!lastUid.value) { warn('请先选择用户'); return }
gift.vipday = 0
- gift.trademin = 0
- gift.meetmin = 0
+ gift.compute = 0
giftModal.value?.showModal()
}
function closeGift() { giftModal.value?.close() }
async function submitGift() {
const vipday = Number(gift.vipday) || 0
- const trademin = Number(gift.trademin) || 0
- const meetmin = Number(gift.meetmin) || 0
- if (vipday < 0 || trademin < 0 || meetmin < 0) { warn('数量不能为负'); return }
- if (!vipday && !trademin && !meetmin) { warn('请至少填写一项'); return }
+ const compute = Number(gift.compute) || 0
+ if (vipday < 0 || compute < 0) { warn('数量不能为负'); return }
+ if (!vipday && !compute) { warn('请至少填写一项'); return }
if (!lastAppId.value || !lastUid.value) { warn('请先选择用户'); return }
auth.setCurrentApp(Number(lastAppId.value))
try {
- await webApi('giftuserresource', { uid: lastUid.value, vipday, trademin, meetmin })
+ // 只送 VIP 天数与算力。后端的 trademin/meetmin 只是老前端的兼容入参,新前端不再传。
+ await webApi('giftuserresource', { uid: lastUid.value, vipday, compute })
success('赠送成功')
giftModal.value?.close()
await refreshCurrent()
diff --git a/apps/services/modules/console/api_compute.go b/apps/services/modules/console/api_compute.go
index 9d91e186..c55e93d3 100644
--- a/apps/services/modules/console/api_compute.go
+++ b/apps/services/modules/console/api_compute.go
@@ -63,6 +63,24 @@ func (this *serverComp) appConnFromCtx(c *gin.Context) (mysql.ISys, bool) {
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 读选中应用的算力换算系数与闸门。
func (this *serverComp) getComputeConfig(c *gin.Context) {
conn, ok := this.appConnFromCtx(c)
diff --git a/apps/services/modules/console/pkgdb_boundary_test.go b/apps/services/modules/console/pkgdb_boundary_test.go
new file mode 100644
index 00000000..90ced155
--- /dev/null
+++ b/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
+ })
+ }
+ }
+}
diff --git a/apps/services/modules/console/server.go b/apps/services/modules/console/server.go
index f394ac1f..a364fb99 100644
--- a/apps/services/modules/console/server.go
+++ b/apps/services/modules/console/server.go
@@ -955,10 +955,17 @@ func (this *serverComp) getUserInfo(c *gin.Context) {
}
// 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) {
identity := currentIdentity(c)
if identity != pb.Identity_Admin && identity != pb.Identity_Manager && identity != pb.Identity_Agent {
@@ -966,10 +973,12 @@ func (this *serverComp) giftUserResource(c *gin.Context) {
return
}
var req struct {
- Uid string `json:"uid"`
- Vipday int64 `json:"vipday"`
- Trademin int64 `json:"trademin"`
- Meetmin int64 `json:"meetmin"`
+ Uid string `json:"uid"`
+ Vipday int64 `json:"vipday"`
+ Compute int64 `json:"compute"`
+ // 兼容入参:老前端按功能分桶送分钟数,服务端折成算力。
+ Trademin int64 `json:"trademin"`
+ Meetmin int64 `json:"meetmin"`
}
_ = c.ShouldBindJSON(&req)
uid := strings.TrimSpace(req.Uid)
@@ -977,11 +986,11 @@ func (this *serverComp) giftUserResource(c *gin.Context) {
writeErr(c, pb.ErrorCode_ReqParameterError, "请输入要赠送的用户UID")
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, "赠送数量不能为负")
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, "请至少填写一项赠送数量")
return
}
@@ -1007,7 +1016,15 @@ func (this *serverComp) giftUserResource(c *gin.Context) {
now := time.Now().Unix()
accId := currentAccountId(c)
// 代理:先原子扣减自己的资源点余额(CAS 防超发),不足即拒绝。
+ //
+ // ⚠️ 代理余额只有 balance_vipday/trademin/meetmin 三列,**没有算力列**,所以代理走不了
+ // compute 这条新路。这段实际已不可达:品牌商(identity=3)于 2026-09-12 整体下线,
+ // migrateBrandAccountsToOperator 在 console 启动时把存量全部改成 identity=4 且禁用。
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)
if e != nil {
writeErr(c, pb.ErrorCode_DBError, e.Error())
@@ -1020,8 +1037,6 @@ func (this *serverComp) giftUserResource(c *gin.Context) {
}
// 应用到用户余额(VIP 过期时间:已过期/未开通则从现在起算,否则顺延;与公码奖励口径一致)。
- addTradeSec := req.Trademin * 60
- addMeetSec := req.Meetmin * 60
if req.Vipday > 0 {
addVipSec := req.Vipday * 24 * 60 * 60
if user.Vipexptime == 0 || user.Vipexptime < now {
@@ -1030,8 +1045,14 @@ func (this *serverComp) giftUserResource(c *gin.Context) {
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
if err := conn.Save(comm.TableUser, user); err != nil {
// 代理:写库失败回滚已扣余额,避免用户没到账却扣了代理。
@@ -1043,8 +1064,8 @@ func (this *serverComp) giftUserResource(c *gin.Context) {
}
// 流水(业务库 useruselog)。已到账,失败不回滚,仅吞掉。
_ = conn.Insert(comm.TableUserUseLog, &pb.DBUserUseLog{
- Uid: uid,
- Ts: now,
+ Uid: uid,
+ Ts: now,
Logtype: pb.UserLogType_AdminGive,
Addvipday: req.Vipday,
Compute: addCompute,