feat(heicode): GET /api/heicode/self for desktop balance + usage
The cc-haha desktop client used to read its balance from
/v1/dashboard/billing/{subscription,usage}. Those endpoints honor the
token row's UnlimitedQuota flag — and device-bound tokens have that
flag set true because they are an auth mechanism, not a billing
boundary. Result: the balance pill always showed 100_000_000 USD
regardless of the user's real balance.
The right source is the user row (User.Quota / UsedQuota /
RequestCount), which is what /api/user/self surfaces to the web
dashboard. But that endpoint is UserAuth-only (session cookie / JWT),
which the desktop client doesn't carry — it holds a sk- bearer or
signs requests with its V2 device key.
This commit adds a slim sibling endpoint /api/heicode/self mounted on
TokenAuth so either sk- or V2 signature authenticates. Returns only
the fields the desktop balance pill + usage panel consume (quota,
used_quota, request_count, plus username/group/role for the title
bar) — no PII beyond what relay calls already expose. Quota numbers
go through the same QuotaPerUnit / display-type normalization that
billing.go uses, so the desktop pill and web dashboard show the same
number.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/i18n"
|
||||
"github.com/heicode/manager/model"
|
||||
"github.com/heicode/manager/setting/operation_setting"
|
||||
)
|
||||
|
||||
// quotaToDisplayUnit converts a raw quota integer (DB unit) to the
|
||||
// site-configured display unit (USD / CNY / tokens). Mirrors the
|
||||
// conversion in billing.go so the desktop "balance pill" shows the
|
||||
// same number the user sees in the web dashboard.
|
||||
func quotaToDisplayUnit(raw int) float64 {
|
||||
amount := float64(raw)
|
||||
switch operation_setting.GetQuotaDisplayType() {
|
||||
case operation_setting.QuotaDisplayTypeCNY:
|
||||
return amount / common.QuotaPerUnit * operation_setting.USDExchangeRate
|
||||
case operation_setting.QuotaDisplayTypeTokens:
|
||||
return amount
|
||||
default:
|
||||
return amount / common.QuotaPerUnit
|
||||
}
|
||||
}
|
||||
|
||||
// GetHeicodeSelf serves GET /api/heicode/self.
|
||||
//
|
||||
// Mounted on TokenAuth so the cc-haha desktop client can read it with
|
||||
// either:
|
||||
// - a legacy sk- bearer (30-day transition window), or
|
||||
// - a V2 device-signed request (after pair).
|
||||
//
|
||||
// Returns the *user-row* quota counters (not the device-token's
|
||||
// UnlimitedQuota flag, which is just an auth-mechanism marker). This
|
||||
// is the same data /api/user/self surfaces to the web dashboard, but
|
||||
// trimmed to just the fields the desktop "balance pill" + usage panel
|
||||
// need. No PII (email/social ids) so a leaked sk- can't enumerate
|
||||
// account identity beyond what relay calls already expose.
|
||||
func GetHeicodeSelf(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
if userId == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgUnauthorized),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := model.GetUserById(userId, false)
|
||||
if err != nil {
|
||||
common.SysLog("GetHeicodeSelf: " + err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"id": user.Id,
|
||||
"username": user.Username,
|
||||
"display_name": user.DisplayName,
|
||||
"group": user.Group,
|
||||
"role": user.Role,
|
||||
"quota": quotaToDisplayUnit(user.Quota),
|
||||
"used_quota": quotaToDisplayUnit(user.UsedQuota),
|
||||
"request_count": user.RequestCount,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -350,6 +350,22 @@ func SetApiRouter(router *gin.Engine) {
|
||||
)
|
||||
}
|
||||
|
||||
// Heicode desktop balance/usage endpoint. The standard
|
||||
// /api/user/self lives behind UserAuth (session cookie or JWT)
|
||||
// because the web dashboard's axios layer always carries them.
|
||||
// The desktop client has neither — it holds a sk- (legacy) or
|
||||
// signs requests with its V2 device key. We mount a slim
|
||||
// quota/used_quota/request_count view on TokenAuth here so the
|
||||
// client's "balance pill" can read the *user-row* counters
|
||||
// (which are the real billing numbers) instead of relying on
|
||||
// /v1/dashboard/billing/* — those reflect the device token's
|
||||
// unlimited-quota flag, not the user's actual balance.
|
||||
heicodeSelfRoute := apiRouter.Group("/heicode")
|
||||
heicodeSelfRoute.Use(middleware.TokenAuth())
|
||||
{
|
||||
heicodeSelfRoute.GET("/self", controller.GetHeicodeSelf)
|
||||
}
|
||||
|
||||
usageRoute := apiRouter.Group("/usage")
|
||||
usageRoute.Use(middleware.CORS(), middleware.CriticalRateLimit())
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user