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:
2026-05-21 11:31:21 +08:00
co-authored by Claude Opus 4.7
parent e2cac00c14
commit 683ecc7470
2 changed files with 92 additions and 0 deletions
+76
View File
@@ -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,
},
})
}
+16
View File
@@ -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())
{