fix: 应用 xhigh 代码评审的跨端修复
来自 xhigh code review 的正确性/健壮性修复,覆盖全部五端: - server:鉴权 fail-closed、计量交叉校验与配额扣穿处理、WS 网关并发与关闭顺序、 billing 行锁、redis Lua 过期与设备槽刷新、config 解析 - desktop:会话 epoch 防串话、WS 重连与 401 处理、api 客户端复用、统一 usePoll 轮询 - android:握手时序、请求头封装、账户状态派生、按需重组 - ios:finalize 宽限、串行采集、错误文案服务端优先、删除死代码 CommitController Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -131,8 +131,16 @@ func (h *Handlers) MobileLogin(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Logout POST /v1/auth/logout(需登录)
|
||||
// 拉黑到 token 真实自然过期时间(中间件解析后存于 CtxExpires),确保即便
|
||||
// JWT_TTL_HOURS 配得很大,注销 token 也不会在固定窗口后复活(17B)。
|
||||
func (h *Handlers) Logout(c *gin.Context) {
|
||||
jti := c.GetString(CtxJTI)
|
||||
_ = h.JWT.Revoke(c, jti, time.Now().Add(8*24*time.Hour)) // 覆盖最长 TTL
|
||||
exp, _ := c.Get(CtxExpires)
|
||||
expiresAt, _ := exp.(time.Time)
|
||||
if expiresAt.IsZero() {
|
||||
// 兜底:claims 未带 exp(理论上不会发生),按当前配置 TTL 估一个上界。
|
||||
expiresAt = time.Now().Add(time.Duration(h.JWT.ttl))
|
||||
}
|
||||
_ = h.JWT.Revoke(c, jti, expiresAt)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -41,21 +42,32 @@ func (j *JWT) Sign(userID string) (string, error) {
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(j.secret)
|
||||
}
|
||||
|
||||
// Parse 校验签名、过期与黑名单,返回 userID 与 jti。
|
||||
func (j *JWT) Parse(ctx context.Context, token string) (userID, jti string, err error) {
|
||||
// Parse 校验签名、过期与黑名单,返回 userID、jti 与 token 自然过期时间。
|
||||
// 黑名单检查 fail-closed:Redis 不可用(Exists 返回 error)时拒绝放行,
|
||||
// 宁可误拒也不能让已登出/被踢出的 token 在 Redis 故障窗口复活(17A)。
|
||||
func (j *JWT) Parse(ctx context.Context, token string) (userID, jti string, expiresAt time.Time, err error) {
|
||||
var claims Claims
|
||||
_, err = jwt.ParseWithClaims(token, &claims, func(t *jwt.Token) (any, error) {
|
||||
return j.secret, nil
|
||||
}, jwt.WithValidMethods([]string{"HS256"}))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
if j.rdb != nil {
|
||||
if n, _ := j.rdb.Exists(ctx, store.KeyJwtBlock(claims.ID)).Result(); n == 1 {
|
||||
return "", "", jwt.ErrTokenExpired
|
||||
n, exErr := j.rdb.Exists(ctx, store.KeyJwtBlock(claims.ID)).Result()
|
||||
if exErr != nil {
|
||||
// fail-closed:无法确认黑名单状态时拒绝,避免故障期放行被撤销的 token。
|
||||
slog.Warn("jwt blacklist check failed, rejecting (fail-closed)", "jti", claims.ID, "err", exErr)
|
||||
return "", "", time.Time{}, exErr
|
||||
}
|
||||
if n == 1 {
|
||||
return "", "", time.Time{}, jwt.ErrTokenExpired
|
||||
}
|
||||
}
|
||||
return claims.Subject, claims.ID, nil
|
||||
if claims.ExpiresAt != nil {
|
||||
expiresAt = claims.ExpiresAt.Time
|
||||
}
|
||||
return claims.Subject, claims.ID, expiresAt, nil
|
||||
}
|
||||
|
||||
// Revoke 将 jti 拉黑至 token 自然过期(logout / 踢出)。
|
||||
@@ -68,8 +80,9 @@ func (j *JWT) Revoke(ctx context.Context, jti string, expiresAt time.Time) error
|
||||
}
|
||||
|
||||
const (
|
||||
CtxUserID = "auth.user_id"
|
||||
CtxJTI = "auth.jti"
|
||||
CtxUserID = "auth.user_id"
|
||||
CtxJTI = "auth.jti"
|
||||
CtxExpires = "auth.expires_at" // token 自然过期时间(time.Time),供 Logout 拉黑到期用
|
||||
)
|
||||
|
||||
// Middleware gin 鉴权中间件:Authorization: Bearer <JWT>。
|
||||
@@ -80,13 +93,31 @@ func (j *JWT) Middleware() gin.HandlerFunc {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, protocol.NewAPIError(protocol.ErrUnauthorized))
|
||||
return
|
||||
}
|
||||
uid, jti, err := j.Parse(c.Request.Context(), token)
|
||||
uid, jti, exp, err := j.Parse(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, protocol.NewAPIError(protocol.ErrUnauthorized))
|
||||
return
|
||||
}
|
||||
c.Set(CtxUserID, uid)
|
||||
c.Set(CtxJTI, jti)
|
||||
c.Set(CtxExpires, exp)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalMiddleware 可选鉴权(17F):带合法 Authorization 时注入 user_id;
|
||||
// 无 token 或解析失败(含 Redis 故障导致的 fail-closed)一律放行匿名,不拦截。
|
||||
// 用于 /v1/metrics/batch 等既接受匿名打点、又希望登录用户能关联 user_id 的端点。
|
||||
func (j *JWT) OptionalMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||
if token != "" {
|
||||
if uid, jti, exp, err := j.Parse(c.Request.Context(), token); err == nil {
|
||||
c.Set(CtxUserID, uid)
|
||||
c.Set(CtxJTI, jti)
|
||||
c.Set(CtxExpires, exp)
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,21 +19,46 @@ func TestSignParseRevoke(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uid, jti, err := j.Parse(ctx, token)
|
||||
uid, jti, exp, err := j.Parse(ctx, token)
|
||||
if err != nil || uid != "u1" || jti == "" {
|
||||
t.Fatalf("parse failed: uid=%s jti=%s err=%v", uid, jti, err)
|
||||
}
|
||||
if exp.IsZero() || time.Until(exp) <= 0 {
|
||||
t.Fatalf("expiresAt should be in the future, got %v", exp)
|
||||
}
|
||||
|
||||
// 拉黑后解析失败
|
||||
if err := j.Revoke(ctx, jti, time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := j.Parse(ctx, token); err == nil {
|
||||
if _, _, _, err := j.Parse(ctx, token); err == nil {
|
||||
t.Fatal("expect parse failure after revoke")
|
||||
}
|
||||
|
||||
// 篡改签名失败
|
||||
if _, _, err := j.Parse(ctx, token+"x"); err == nil {
|
||||
if _, _, _, err := j.Parse(ctx, token+"x"); err == nil {
|
||||
t.Fatal("expect parse failure on tampered token")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseFailClosed Redis 不可用时黑名单检查应 fail-closed:拒绝放行(17A)。
|
||||
func TestParseFailClosed(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
j := NewJWT("test-secret", time.Hour, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
token, err := j.Sign("u1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 正常情况下放行
|
||||
if _, _, _, err := j.Parse(ctx, token); err != nil {
|
||||
t.Fatalf("parse should pass while redis up: %v", err)
|
||||
}
|
||||
// Redis 故障:Exists 返回 error → 必须拒绝(不得 fail-open)。
|
||||
mr.Close()
|
||||
if _, _, _, err := j.Parse(ctx, token); err == nil {
|
||||
t.Fatal("expect parse failure when redis is unavailable (fail-closed)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"dudu/server/internal/auth"
|
||||
"dudu/server/internal/quota"
|
||||
@@ -171,7 +172,8 @@ func (h *Handlers) MarkPaid(ctx context.Context, res NotifyResult) error {
|
||||
var seconds int64
|
||||
err := h.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var order store.Order
|
||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").
|
||||
// GORM v2 行锁:v1 的 Set("gorm:query_option","FOR UPDATE") 在 v2 静默失效(16F)。
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&order, "id = ?", res.OrderID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"dudu/server/pkg/protocol"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -35,6 +39,10 @@ type Config struct {
|
||||
OSSBucket string
|
||||
OSSKeyID string
|
||||
OSSKeySecret string
|
||||
|
||||
// AppLatest 启动时解析 APP_LATEST_JSON 一次(17G):平台→版本信息。
|
||||
// 解析失败或未配置则为 nil/空,handler 据此返回 204,不 fail-fast。
|
||||
AppLatest map[string]protocol.AppLatestResponse
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
@@ -64,9 +72,26 @@ func Load() Config {
|
||||
OSSBucket: os.Getenv("OSS_BUCKET"),
|
||||
OSSKeyID: os.Getenv("OSS_KEY_ID"),
|
||||
OSSKeySecret: os.Getenv("OSS_KEY_SECRET"),
|
||||
|
||||
AppLatest: loadAppLatest(),
|
||||
}
|
||||
}
|
||||
|
||||
// loadAppLatest 启动时解析一次 APP_LATEST_JSON(17G)。失败仅告警并返回 nil,
|
||||
// 不 fail-fast——未配置此项时服务仍需正常启动。
|
||||
func loadAppLatest() map[string]protocol.AppLatestResponse {
|
||||
raw := os.Getenv("APP_LATEST_JSON")
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var all map[string]protocol.AppLatestResponse
|
||||
if err := json.Unmarshal([]byte(raw), &all); err != nil {
|
||||
slog.Warn("invalid APP_LATEST_JSON, app update disabled", "err", err)
|
||||
return nil
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func getenv(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
|
||||
@@ -48,9 +48,14 @@ type wsConn struct {
|
||||
c *websocket.Conn
|
||||
}
|
||||
|
||||
// wsWriteTimeout 单次写超时(16B):客户端不读时避免 WriteJSON 永久阻塞,
|
||||
// 致 pumpResults / 收尾路径卡死、goroutine 与连接泄漏。
|
||||
const wsWriteTimeout = 5 * time.Second
|
||||
|
||||
func (w *wsConn) sendJSON(v any) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
_ = w.c.SetWriteDeadline(time.Now().Add(wsWriteTimeout))
|
||||
return w.c.WriteJSON(v)
|
||||
}
|
||||
|
||||
@@ -77,7 +82,6 @@ func (h *Handler) HandleWS(c *gin.Context) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
ws := &wsConn{c: conn}
|
||||
|
||||
conn.SetPongHandler(func(string) error {
|
||||
@@ -86,7 +90,11 @@ func (h *Handler) HandleWS(c *gin.Context) {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(90 * time.Second))
|
||||
|
||||
var sess *session // 同连接串行多次会话
|
||||
// 单个收尾 defer,顺序显式(16B):先 conn.Close 拆连接——解开任何阻塞中的
|
||||
// pumpResults 写,再 finish(其 Wait 已带超时)。避免 LIFO 让 finish 先跑、
|
||||
// 而 Close 永不执行导致的 goroutine + 连接 + 设备槽泄漏。
|
||||
defer func() {
|
||||
conn.Close()
|
||||
if sess != nil {
|
||||
sess.finish(context.Background(), false)
|
||||
}
|
||||
@@ -165,6 +173,9 @@ func (h *Handler) startSession(ctx context.Context, ws *wsConn, uid, deviceID st
|
||||
h: h, ws: ws, id: msg.SessionID, uid: uid, deviceID: deviceID,
|
||||
provider: ps, started: now, done: make(chan struct{}),
|
||||
}
|
||||
// Add 须 happen-before finish 的 Wait(WaitGroup 文档要求),放在启动
|
||||
// goroutine 之前,避免 Add 与 Wait 竞态(16C)。
|
||||
s.resultsDone.Add(1)
|
||||
go s.pumpResults()
|
||||
go s.usageLoop()
|
||||
return s
|
||||
@@ -179,41 +190,48 @@ type session struct {
|
||||
provider asr.Session
|
||||
started time.Time
|
||||
|
||||
// consumeMu 串行化 Consume RPC 与 finish 取快照(16D):避免 finish 在
|
||||
// 一笔在途 consumeDelta 已扣 Redis、尚未把 trialPart/balancePart 计入快照时
|
||||
// 读到漏记的账。consumeMu 须在 mu 之外单独获取,且不可在持有 mu 时跨越 Redis 调用。
|
||||
consumeMu sync.Mutex
|
||||
|
||||
mu sync.Mutex
|
||||
audioBytes int
|
||||
audioBytes int // 计费音频字节累计;截断/死亡/扣穿后在 feed 处冻结不再增长
|
||||
providerEndMs int64 // Provider 报告的最大句尾时间戳(ms),0=未提供
|
||||
consumedSec int // 已增量扣减的秒数
|
||||
trialPart int
|
||||
balancePart int
|
||||
truncated bool
|
||||
finished bool
|
||||
done chan struct{}
|
||||
resultsDone sync.WaitGroup
|
||||
trialPart int
|
||||
balancePart int
|
||||
truncated bool
|
||||
providerDead bool // provider 死亡(r.Err / Results 非正常关闭)后置位,停止计费(16A)
|
||||
quotaExhausted bool // 余额+试用扣穿后置位,已下发过一次 QUOTA_EXCEEDED(16E)
|
||||
finished bool
|
||||
done chan struct{}
|
||||
resultsDone sync.WaitGroup
|
||||
}
|
||||
|
||||
// feed 转发音频帧并累计实收时长;超 180s 自动截断。
|
||||
// 截断 / provider 死亡点之后到达的帧不再计入 audioBytes(不扣费):
|
||||
// 用户为"未识别的音频"付费是 bug——先判停止条件,再决定是否累计(16A)。
|
||||
func (s *session) feed(pcm []byte) {
|
||||
s.mu.Lock()
|
||||
if s.finished {
|
||||
if s.finished || s.truncated || s.providerDead || s.quotaExhausted {
|
||||
// 已停止计费:丢弃此帧,不累计、不转发(扣穿后冻结,避免结算记录虚增秒数)
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
// 先用"若计入本帧"的时长判是否超 180s
|
||||
overCap := (s.audioBytes+len(pcm)+31999)/32000 >= protocol.MaxSessionSeconds
|
||||
if overCap {
|
||||
// 本帧触发截断:冻结记账(本帧不计入),flush 定稿
|
||||
s.truncated = true
|
||||
s.mu.Unlock()
|
||||
// 截断:flush 定稿,但保持会话记账状态直至客户端 stop / 连接收尾
|
||||
_ = s.provider.Close()
|
||||
s.ws.sendErr(s.id, protocol.ErrSessionLimit)
|
||||
return
|
||||
}
|
||||
s.audioBytes += len(pcm)
|
||||
overCap := s.audioSeconds() >= protocol.MaxSessionSeconds
|
||||
s.mu.Unlock()
|
||||
|
||||
if overCap {
|
||||
s.mu.Lock()
|
||||
already := s.truncated
|
||||
s.truncated = true
|
||||
s.mu.Unlock()
|
||||
if !already {
|
||||
// 截断:flush 定稿,但保持会话记账状态直至客户端 stop / 连接收尾
|
||||
_ = s.provider.Close()
|
||||
s.ws.sendErr(s.id, protocol.ErrSessionLimit)
|
||||
}
|
||||
return
|
||||
}
|
||||
_ = s.provider.SendAudio(pcm)
|
||||
}
|
||||
|
||||
@@ -223,11 +241,13 @@ func (s *session) audioSeconds() int {
|
||||
}
|
||||
|
||||
// pumpResults Provider → 客户端下行泵。
|
||||
// Add(1) 已移至 startSession(16C)。provider 死亡(r.Err 或 Results 非正常关闭)
|
||||
// 时置 providerDead,停止后续计费(16A)。
|
||||
func (s *session) pumpResults() {
|
||||
s.resultsDone.Add(1)
|
||||
defer s.resultsDone.Done()
|
||||
for r := range s.provider.Results() {
|
||||
if r.Err != nil {
|
||||
s.markProviderDead()
|
||||
s.ws.sendErr(s.id, protocol.ErrASRUnavailable)
|
||||
return
|
||||
}
|
||||
@@ -244,6 +264,19 @@ func (s *session) pumpResults() {
|
||||
}
|
||||
_ = s.ws.sendJSON(protocol.ServerMsg{Type: typ, SessionID: s.id, Text: r.Text})
|
||||
}
|
||||
// Results 通道关闭:若并非由 finish/截断/扣穿主动 Close 触发,则 provider 自行
|
||||
// 死亡(上游 EOF / 异常 task-finished),标记停止计费(16A)。
|
||||
s.markProviderDead()
|
||||
}
|
||||
|
||||
// markProviderDead 标记 provider 已死:feed 据此冻结 audioBytes(不再计入死亡点
|
||||
// 之后的帧),consumeDelta 据此停止继续扣费;已识别部分仍由 finish 的 consumeFinal
|
||||
// 正常结算。由 finish/截断/扣穿主动 Close 触发的通道关闭也会走到这里,但此时计费已
|
||||
// 另行冻结,置位无副作用。
|
||||
func (s *session) markProviderDead() {
|
||||
s.mu.Lock()
|
||||
s.providerDead = true
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// usageLoop 每 2s 增量扣减并下发 usage 帧。
|
||||
@@ -256,15 +289,28 @@ func (s *session) usageLoop() {
|
||||
return
|
||||
case <-t.C:
|
||||
s.consumeDelta(context.Background())
|
||||
// 续期设备槽(17D):单会话墙钟可远超 4min TTL(低速发帧刷新读超时),
|
||||
// 不续期则 slot 先于会话过期,同设备第二路 start 会被错误放行。
|
||||
_, _ = store.RefreshDeviceSlot(context.Background(), s.h.RDB, s.deviceID, s.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// consumeDelta 将"实收音频秒数 − 已扣秒数"差额扣减并广播余额。
|
||||
// consumeMu 串行化与 finish 取快照(16D):持锁期间 finish 不会读到漏记的在途账。
|
||||
func (s *session) consumeDelta(ctx context.Context) {
|
||||
s.consumeMu.Lock()
|
||||
defer s.consumeMu.Unlock()
|
||||
|
||||
s.mu.Lock()
|
||||
// providerDead 后停止继续计费(16A):audioBytes 已在 feed 处冻结,
|
||||
// 已识别部分由 finish 的 consumeFinal 正常结算。
|
||||
if s.finished || s.providerDead {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
delta := s.audioSeconds() - s.consumedSec
|
||||
if delta <= 0 || s.finished {
|
||||
if delta <= 0 {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
@@ -287,9 +333,30 @@ func (s *session) consumeDelta(ctx context.Context) {
|
||||
BalanceSeconds: res.BalanceSeconds,
|
||||
TrialRemaining: max(0, protocol.TrialDailySeconds-res.TrialUsedToday),
|
||||
})
|
||||
|
||||
// 扣穿检测(16E):余额与今日试用均已耗尽。优雅结束会话——不立刻掐断当句
|
||||
// (Close 让 provider flush 当前句 final),下发一次 QUOTA_EXCEEDED 后由 finish 收尾。
|
||||
if res.Exhausted() {
|
||||
s.mu.Lock()
|
||||
already := s.quotaExhausted
|
||||
s.quotaExhausted = true
|
||||
s.mu.Unlock()
|
||||
if !already {
|
||||
s.ws.sendErr(s.id, protocol.ErrQuotaExceeded)
|
||||
// 触发 provider flush 当前句尾 final 并结束;finish 串行收尾。
|
||||
_ = s.provider.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// finishWaitTimeout 收尾等待尾部 final 下发的最长时间(16B):客户端不读时
|
||||
// pumpResults 可能卡在写上(已由 sendJSON 写超时兜底),此处再加一层超时,
|
||||
// 超时则继续收尾不无限等。
|
||||
const finishWaitTimeout = 3 * time.Second
|
||||
|
||||
// finish 结束会话:flush final → 补扣 → usage 帧 → 异步 settle → 记窗口 → 释放槽。
|
||||
// 注意 done/consumeMu 顺序(16D):先停 usageLoop 并 drain 在途 consumeDelta,
|
||||
// 再读 trialPart/balancePart 快照,避免漏记在途扣费。
|
||||
func (s *session) finish(ctx context.Context, canceled bool) {
|
||||
s.mu.Lock()
|
||||
if s.finished {
|
||||
@@ -299,11 +366,20 @@ func (s *session) finish(ctx context.Context, canceled bool) {
|
||||
s.mu.Unlock()
|
||||
|
||||
_ = s.provider.Close()
|
||||
s.resultsDone.Wait() // 尾部 final 全部下发后再收尾
|
||||
s.waitResults() // 尾部 final 全部下发后再收尾(带超时,16B)
|
||||
|
||||
// 先标记 finished 并停 usageLoop,再 drain 在途 consumeDelta(16D):
|
||||
// 取 consumeMu 会等待任何已过 finished 检查、正等 Redis 返回的 consumeDelta
|
||||
// 完成并把 trialPart/balancePart 计入,从而快照不漏账。
|
||||
s.mu.Lock()
|
||||
s.finished = true
|
||||
close(s.done)
|
||||
s.mu.Unlock()
|
||||
|
||||
s.consumeMu.Lock() // drain:等当前在途 consume(若有)完成
|
||||
s.consumeMu.Unlock()
|
||||
|
||||
s.mu.Lock()
|
||||
seconds := s.audioSeconds()
|
||||
audioMs := int64(s.audioBytes) * 1000 / 32000
|
||||
providerMs := s.providerEndMs
|
||||
@@ -347,7 +423,23 @@ func (s *session) finish(ctx context.Context, canceled bool) {
|
||||
}()
|
||||
}
|
||||
|
||||
// waitResults 等 pumpResults 退出(尾部 final 下发完),最多 finishWaitTimeout(16B)。
|
||||
// 超时则放弃等待继续收尾——避免客户端不读时永久阻塞收尾路径。
|
||||
func (s *session) waitResults() {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
s.resultsDone.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(finishWaitTimeout):
|
||||
slog.Warn("finish: results wait timed out", "session", s.id)
|
||||
}
|
||||
}
|
||||
|
||||
// consumeFinal 结束时补扣差额(不足 2s 的短会话由此兜底)。
|
||||
// 由 finish 在持有 consumeMu drain 后调用,且 usageLoop 已停,天然与增量扣减串行。
|
||||
func (s *session) consumeFinal(ctx context.Context, seconds int) {
|
||||
s.mu.Lock()
|
||||
delta := seconds - s.consumedSec
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -155,35 +154,45 @@ func TestQuotaExceededOnStart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// mrConsumeAll 把 u1 的当日试用直接耗尽(经 quota 通道,保证键一致)。
|
||||
// mrConsumeAll 把 u1 的当日试用耗尽(经 quota 通道,保证键一致)。
|
||||
// 单会话被截断在略低于 180s 处(截断帧不再计费,16A),故一会话不足以扣满 180s 试用;
|
||||
// 此处循环开会话推音频,直到某个 usage 帧报告 TrialRemaining<=0 为止。
|
||||
func mrConsumeAll(t *testing.T, srv *httptest.Server, db *gorm.DB) {
|
||||
t.Helper()
|
||||
conn := dial(t, srv)
|
||||
start, _ := json.Marshal(protocol.ClientMsg{Type: "start", SessionID: "s0", SampleRate: 16000})
|
||||
_ = conn.WriteMessage(websocket.TextMessage, start)
|
||||
frame := make([]byte, protocol.FrameBytes)
|
||||
// 180s 音频 = 1800 帧
|
||||
for i := 0; i < protocol.TrialDailySeconds*10; i++ {
|
||||
if err := conn.WriteMessage(websocket.BinaryMessage, frame); err != nil {
|
||||
t.Fatal(err)
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for sess := 0; sess < 5 && time.Now().Before(deadline); sess++ {
|
||||
conn := dial(t, srv)
|
||||
sid := "s0_" + string(rune('a'+sess))
|
||||
start, _ := json.Marshal(protocol.ClientMsg{Type: "start", SessionID: sid, SampleRate: 16000})
|
||||
_ = conn.WriteMessage(websocket.TextMessage, start)
|
||||
// 推 180s 音频(1800 帧)→ 会话在临界处截断
|
||||
for i := 0; i < protocol.TrialDailySeconds*10; i++ {
|
||||
if err := conn.WriteMessage(websocket.BinaryMessage, frame); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
stop, _ := json.Marshal(protocol.ClientMsg{Type: "stop", SessionID: sid})
|
||||
_ = conn.WriteMessage(websocket.TextMessage, stop)
|
||||
|
||||
exhausted := false
|
||||
for time.Now().Before(deadline) {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
var msg protocol.ServerMsg
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
break
|
||||
}
|
||||
if msg.Type == protocol.MsgUsage && msg.TrialRemaining <= 0 {
|
||||
exhausted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
conn.Close()
|
||||
if exhausted {
|
||||
return
|
||||
}
|
||||
}
|
||||
stop, _ := json.Marshal(protocol.ClientMsg{Type: "stop", SessionID: "s0"})
|
||||
_ = conn.WriteMessage(websocket.TextMessage, stop)
|
||||
// 读到连接收尾的 usage 帧为止
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
var msg protocol.ServerMsg
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
break
|
||||
}
|
||||
if msg.Type == protocol.MsgUsage && msg.SessionSeconds >= protocol.TrialDailySeconds {
|
||||
break
|
||||
}
|
||||
}
|
||||
conn.Close()
|
||||
_ = context.Background()
|
||||
t.Fatal("failed to exhaust trial via repeated sessions")
|
||||
}
|
||||
|
||||
func TestSessionLimitTruncates(t *testing.T) {
|
||||
|
||||
@@ -4,6 +4,7 @@ package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -29,47 +30,65 @@ type Deps struct {
|
||||
Quota *quota.Manager
|
||||
}
|
||||
|
||||
// pickProvider 按配置选择 ASR Provider(gummy 在 6B 实现 + DASHSCOPE_API_KEY 就绪后接入)。
|
||||
func pickProvider(cfg config.Config) asr.Provider {
|
||||
// pickProvider 按配置选择 ASR Provider(17E)。ASR_PROVIDER=gummy 且 DASHSCOPE_API_KEY
|
||||
// 就绪时装配真实 gummy;缺 key 则告警并降级 mock。返回值 kind 为装配结果("gummy"/"mock")。
|
||||
func pickProvider(cfg config.Config) (asr.Provider, string) {
|
||||
switch cfg.ASRProvider {
|
||||
case "gummy":
|
||||
if cfg.DashScopeAPIKey != "" {
|
||||
return asr.NewGummy(cfg.DashScopeAPIKey), "gummy"
|
||||
}
|
||||
slog.Warn("ASR_PROVIDER=gummy but DASHSCOPE_API_KEY empty, falling back to mock")
|
||||
return asr.NewMock(), "mock"
|
||||
default:
|
||||
return asr.NewMock()
|
||||
return asr.NewMock(), "mock"
|
||||
}
|
||||
}
|
||||
|
||||
// pickWechat 微信凭证未配置(#2A 申请中)时使用 mock。
|
||||
func pickWechat(cfg config.Config) auth.WechatClient {
|
||||
// pickWechat 微信凭证未配置(#2A 申请中)时使用 mock。返回 kind="mock"(真实实现就绪前恒为 mock)。
|
||||
func pickWechat(cfg config.Config) (auth.WechatClient, string) {
|
||||
if cfg.WechatWebAppID == "" {
|
||||
return auth.MockWechat{}
|
||||
return auth.MockWechat{}, "mock"
|
||||
}
|
||||
return auth.MockWechat{} // TODO(5C-真实): 开放平台实现,凭证就绪后替换
|
||||
return auth.MockWechat{}, "mock" // TODO(5C-真实): 开放平台实现,凭证就绪后替换
|
||||
}
|
||||
|
||||
// pickPay 商户号未配置(#2B 申请中)时使用 mock。
|
||||
func pickPay(cfg config.Config) billing.PayClient {
|
||||
// pickPay 商户号未配置(#2B 申请中)时使用 mock。返回 kind="mock"。
|
||||
// ⚠️⚠️ 上线前必须替换为真实 PayClient!MockPay 不验签,/v1/pay/notify 等于公开充值接口。
|
||||
func pickPay(cfg config.Config) (billing.PayClient, string) {
|
||||
if cfg.WxPayMchID == "" {
|
||||
return billing.MockPay{}
|
||||
return billing.MockPay{}, "mock"
|
||||
}
|
||||
return billing.MockPay{} // TODO(7D-真实): wechatpay-go 实现,商户号就绪后替换
|
||||
// TODO(7D-真实)【上线阻断项】: wechatpay-go 实现 + APIv3 验签,商户号就绪后替换,否则充值接口可被伪造。
|
||||
return billing.MockPay{}, "mock"
|
||||
}
|
||||
|
||||
// pickStorage OSS 未配置时图片落本地盘。
|
||||
func pickStorage(cfg config.Config) feedback.Storage {
|
||||
// pickStorage OSS 未配置时图片落本地盘。返回 kind="local"(OSS 就绪后改为 "oss")。
|
||||
func pickStorage(cfg config.Config) (feedback.Storage, string) {
|
||||
if cfg.OSSBucket == "" {
|
||||
return feedback.LocalStorage{Dir: "var/uploads"}
|
||||
return feedback.LocalStorage{Dir: "var/uploads"}, "local"
|
||||
}
|
||||
return feedback.LocalStorage{Dir: "var/uploads"} // TODO(8C-真实): OSS 实现
|
||||
return feedback.LocalStorage{Dir: "var/uploads"}, "local" // TODO(8C-真实): OSS 实现
|
||||
}
|
||||
|
||||
func Register(r *gin.Engine, d *Deps) {
|
||||
r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
|
||||
|
||||
authH := &auth.Handlers{DB: d.DB, RDB: d.RDB, JWT: d.JWT, Wechat: pickWechat(d.Cfg)}
|
||||
billH := &billing.Handlers{DB: d.DB, Pay: pickPay(d.Cfg), Quota: d.Quota}
|
||||
userH := &user.Handlers{DB: d.DB, Quota: d.Quota}
|
||||
fbH := &feedback.Handlers{DB: d.DB, RDB: d.RDB, Storage: pickStorage(d.Cfg)}
|
||||
provider, providerKind := pickProvider(d.Cfg)
|
||||
wechat, wechatKind := pickWechat(d.Cfg)
|
||||
pay, payKind := pickPay(d.Cfg)
|
||||
storage, storageKind := pickStorage(d.Cfg)
|
||||
|
||||
// 启动时打印各外部依赖的装配结果(real/mock),便于部署核对(17E)。
|
||||
slog.Info("dependency assembly",
|
||||
"asr_provider", providerKind, "wechat", wechatKind, "pay", payKind, "storage", storageKind)
|
||||
|
||||
authH := &auth.Handlers{DB: d.DB, RDB: d.RDB, JWT: d.JWT, Wechat: wechat}
|
||||
billH := &billing.Handlers{DB: d.DB, Pay: pay, Quota: d.Quota}
|
||||
userH := &user.Handlers{DB: d.DB, Quota: d.Quota, AppVersions: d.Cfg.AppLatest}
|
||||
fbH := &feedback.Handlers{DB: d.DB, RDB: d.RDB, Storage: storage}
|
||||
telH := telemetry.New(d.DB, d.RDB)
|
||||
gwH := &gateway.Handler{Provider: pickProvider(d.Cfg), Quota: d.Quota, RDB: d.RDB}
|
||||
gwH := &gateway.Handler{Provider: provider, Quota: d.Quota, RDB: d.RDB}
|
||||
|
||||
// 后台任务
|
||||
bg := context.Background()
|
||||
@@ -85,8 +104,12 @@ func Register(r *gin.Engine, d *Deps) {
|
||||
v1.GET("/auth/wechat/callback", authH.QrCallback)
|
||||
v1.POST("/auth/wechat", authH.MobileLogin)
|
||||
v1.GET("/packs", billH.Packs)
|
||||
// ⚠️ 部署前必须切换到真实 PayClient(验签)!当前 MockPay 不验签,
|
||||
// /v1/pay/notify 等于一个无鉴权的公开充值接口(任意人可伪造支付成功回调充值)。
|
||||
// 上线前务必让 pickPay 返回带 APIv3 验签的真实实现(见 pickPay 的 TODO(7D-真实))。
|
||||
v1.POST("/pay/notify", billH.Notify)
|
||||
v1.POST("/metrics/batch", telH.Batch)
|
||||
// metrics/batch 可匿名上报;OptionalMiddleware 在带合法 JWT 时注入 user_id 以关联用户(17F)。
|
||||
v1.POST("/metrics/batch", d.JWT.OptionalMiddleware(), telH.Batch)
|
||||
v1.GET("/app/latest", userH.AppLatest)
|
||||
|
||||
// 需登录
|
||||
|
||||
@@ -95,6 +95,12 @@ type ConsumeResult struct {
|
||||
TrialUsedToday int
|
||||
}
|
||||
|
||||
// Exhausted 本次扣减后余额与今日试用是否均已耗尽(扣穿)。
|
||||
// 网关据此在句间下发 QUOTA_EXCEEDED 并优雅结束会话("扣穿当句不掐断")。
|
||||
func (r ConsumeResult) Exhausted() bool {
|
||||
return r.BalanceSeconds <= 0 && r.TrialUsedToday >= protocol.TrialDailySeconds
|
||||
}
|
||||
|
||||
// Consume 扣减 seconds 秒(识别中每 2s 增量调用 + 会话结束补差)。
|
||||
func (m *Manager) Consume(ctx context.Context, uid string, seconds int) (ConsumeResult, error) {
|
||||
if err := m.ensureBalanceLoaded(ctx, uid); err != nil {
|
||||
|
||||
@@ -2,7 +2,6 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -47,7 +46,9 @@ else
|
||||
end
|
||||
end
|
||||
if sum + val > limit then return 0 end
|
||||
redis.call('ZADD', key, now, now .. '-' .. redis.call('INCR', key .. ':seq') .. ':' .. val)
|
||||
local seq = redis.call('INCR', key .. ':seq')
|
||||
redis.call('EXPIRE', key .. ':seq', window + 60) -- 17C:seq 计数器与 ZSET 同寿命,避免按设备永久泄漏
|
||||
redis.call('ZADD', key, now, now .. '-' .. seq .. ':' .. val)
|
||||
redis.call('EXPIRE', key, window + 60)
|
||||
return 1
|
||||
`)
|
||||
@@ -59,13 +60,6 @@ func AllowSession(ctx context.Context, rdb *redis.Client, deviceID string, now t
|
||||
return ok == 1, err
|
||||
}
|
||||
|
||||
// AllowAudioSeconds 设备维度时长准入(原子检查并记录):30 分钟内累计 ≤1800s。
|
||||
func AllowAudioSeconds(ctx context.Context, rdb *redis.Client, deviceID string, seconds int, now time.Time) (bool, error) {
|
||||
ok, err := slideScript.Run(ctx, rdb, []string{KeyRateSecs(deviceID)},
|
||||
now.Unix(), 30*60, 30*60, seconds, "sum").Int()
|
||||
return ok == 1, err
|
||||
}
|
||||
|
||||
// AudioWindowExhausted 会话 start 时检查时长窗口是否已满(只查不记;本次秒数在结束时
|
||||
// 经 RecordAudioSeconds 记录——音频已实际消耗,结束时无条件记账)。
|
||||
func AudioWindowExhausted(ctx context.Context, rdb *redis.Client, deviceID string, now time.Time) (bool, error) {
|
||||
@@ -80,9 +74,28 @@ func RecordAudioSeconds(ctx context.Context, rdb *redis.Client, deviceID string,
|
||||
now.Unix(), 30*60, 1<<30, seconds, "sum").Err()
|
||||
}
|
||||
|
||||
const deviceSlotTTL = 4 * time.Minute
|
||||
|
||||
// AcquireDeviceSlot 单设备同时仅 1 路识别会话(SET NX + TTL 兜底防泄漏)。
|
||||
//
|
||||
// 续期契约(17D):槽位 TTL 仅 deviceSlotTTL(4min)作为崩溃/泄漏兜底,并非会话上限。
|
||||
// 长会话期间持有者必须周期性调用 RefreshDeviceSlot 续期(间隔需 < TTL),
|
||||
// 否则 TTL 到期后槽位被释放、并发设备可抢占。网关 usageLoop 每个 tick(2s)
|
||||
// 应顺带调用 RefreshDeviceSlot 续命;会话正常/异常结束时由 ReleaseDeviceSlot 主动释放。
|
||||
func AcquireDeviceSlot(ctx context.Context, rdb *redis.Client, deviceID, sessionID string) (bool, error) {
|
||||
return rdb.SetNX(ctx, KeyActiveSession(deviceID), sessionID, 4*time.Minute).Result()
|
||||
return rdb.SetNX(ctx, KeyActiveSession(deviceID), sessionID, deviceSlotTTL).Result()
|
||||
}
|
||||
|
||||
// RefreshDeviceSlot 仅当槽位仍由本会话持有时续期 TTL(17D)。
|
||||
// 返回 (true,nil) 表示续期成功;(false,nil) 表示槽位已不属于自己(被抢占/已释放),
|
||||
// 调用方应据此判定会话是否仍合法持有槽位。供 gateway usageLoop 每 tick 调用。
|
||||
func RefreshDeviceSlot(ctx context.Context, rdb *redis.Client, deviceID, sessionID string) (bool, error) {
|
||||
// 持有者校验 + EXPIRE 原子化,避免续到别人刚抢占的槽位。
|
||||
script := redis.NewScript(`
|
||||
if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('EXPIRE', KEYS[1], ARGV[2]) end
|
||||
return 0`)
|
||||
n, err := script.Run(ctx, rdb, []string{KeyActiveSession(deviceID)}, sessionID, int(deviceSlotTTL.Seconds())).Int()
|
||||
return n == 1, err
|
||||
}
|
||||
|
||||
func ReleaseDeviceSlot(ctx context.Context, rdb *redis.Client, deviceID, sessionID string) error {
|
||||
@@ -103,5 +116,3 @@ func IncrDailyCounter(ctx context.Context, rdb *redis.Client, key string) (int64
|
||||
}
|
||||
return incr.Val(), nil
|
||||
}
|
||||
|
||||
var _ = fmt.Sprintf // keep fmt for future use
|
||||
|
||||
@@ -35,21 +35,25 @@ func TestAllowSessionWindow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowAudioSecondsWindow(t *testing.T) {
|
||||
// TestAudioWindowExhausted 复用生产路径(AudioWindowExhausted 只查 + RecordAudioSeconds 记账)。
|
||||
func TestAudioWindowExhausted(t *testing.T) {
|
||||
r := rdb(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
// 1700s 放行
|
||||
if ok, _ := AllowAudioSeconds(ctx, r, "dev1", 1700, now); !ok {
|
||||
t.Fatal("1700s should pass")
|
||||
// 初始未满
|
||||
if full, _ := AudioWindowExhausted(ctx, r, "dev1", now); full {
|
||||
t.Fatal("fresh window should not be exhausted")
|
||||
}
|
||||
// 再 100s(累计 1800)放行
|
||||
if ok, _ := AllowAudioSeconds(ctx, r, "dev1", 100, now.Add(time.Second)); !ok {
|
||||
t.Fatal("cumulative 1800s should pass")
|
||||
// 记满后窗口耗尽(AudioWindowExhausted 只查不记,val=0,sum>limit 才算满)
|
||||
if err := RecordAudioSeconds(ctx, r, "dev1", 1801, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 再 1s 超限拒绝
|
||||
if ok, _ := AllowAudioSeconds(ctx, r, "dev1", 1, now.Add(2*time.Second)); ok {
|
||||
t.Fatal("1801s should be rejected")
|
||||
if full, _ := AudioWindowExhausted(ctx, r, "dev1", now.Add(time.Second)); !full {
|
||||
t.Fatal("window should be exhausted after exceeding 1800s")
|
||||
}
|
||||
// 其他设备不受影响
|
||||
if full, _ := AudioWindowExhausted(ctx, r, "dev2", now); full {
|
||||
t.Fatal("other device window should not be exhausted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,3 +82,24 @@ func TestDeviceSlot(t *testing.T) {
|
||||
t.Fatal("acquire after release should pass")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshDeviceSlot 仅持有者能续期;非持有者续期无效(17D)。
|
||||
func TestRefreshDeviceSlot(t *testing.T) {
|
||||
r := rdb(t)
|
||||
ctx := context.Background()
|
||||
if ok, err := AcquireDeviceSlot(ctx, r, "dev1", "s1"); err != nil || !ok {
|
||||
t.Fatalf("acquire should pass: %v", err)
|
||||
}
|
||||
// 持有者续期成功
|
||||
if ok, err := RefreshDeviceSlot(ctx, r, "dev1", "s1"); err != nil || !ok {
|
||||
t.Fatalf("holder refresh should succeed: ok=%v err=%v", ok, err)
|
||||
}
|
||||
// 非持有者续期失败
|
||||
if ok, _ := RefreshDeviceSlot(ctx, r, "dev1", "s2"); ok {
|
||||
t.Fatal("non-holder refresh should fail")
|
||||
}
|
||||
// 续期未释放槽位:他人仍抢不到
|
||||
if ok, _ := AcquireDeviceSlot(ctx, r, "dev1", "s2"); ok {
|
||||
t.Fatal("slot should still be held after refresh")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,8 @@ package user
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"encoding/json"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@@ -21,6 +18,8 @@ import (
|
||||
type Handlers struct {
|
||||
DB *gorm.DB
|
||||
Quota *quota.Manager
|
||||
// AppVersions 启动时由 config 解析好的平台→版本信息(17G);nil/空表示未配置。
|
||||
AppVersions map[string]protocol.AppLatestResponse
|
||||
}
|
||||
|
||||
// Me GET /v1/me(需登录):余额/试用/账户态聚合 + 设备心跳登记。
|
||||
@@ -75,20 +74,10 @@ func (h *Handlers) touchDevice(c *gin.Context, uid string) {
|
||||
}
|
||||
|
||||
// AppLatest GET /v1/app/latest?platform=mac|win|android|ios 🔓
|
||||
// 版本信息来自 APP_LATEST_JSON 环境变量({"mac":{"version":...,"url":...},...}),
|
||||
// MVP 不建表;未配置平台返回 204。
|
||||
// 版本信息来自启动时 config 解析好的 APP_LATEST_JSON(17G,{"mac":{"version":...,"url":...},...}),
|
||||
// MVP 不建表;未配置/未知平台返回 204。
|
||||
func (h *Handlers) AppLatest(c *gin.Context) {
|
||||
raw := os.Getenv("APP_LATEST_JSON")
|
||||
if raw == "" {
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
var all map[string]protocol.AppLatestResponse
|
||||
if err := json.Unmarshal([]byte(raw), &all); err != nil {
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if v, ok := all[c.Query("platform")]; ok {
|
||||
if v, ok := h.AppVersions[c.Query("platform")]; ok {
|
||||
c.JSON(http.StatusOK, v)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -52,10 +52,12 @@ type ServerMsg struct {
|
||||
// partial / final
|
||||
Text string `json:"text,omitempty"`
|
||||
|
||||
// usage:识别中每 2s 及 stop 后下发,驱动 UI 实时刷新余额
|
||||
SessionSeconds int `json:"session_seconds,omitempty"`
|
||||
BalanceSeconds int64 `json:"balance_seconds,omitempty"`
|
||||
TrialRemaining int `json:"trial_remaining,omitempty"`
|
||||
// usage:识别中每 2s 及 stop 后下发,驱动 UI 实时刷新余额。
|
||||
// 去 omitempty(16G):0 是合法值(扣穿 / 试用用尽),客户端须能区分
|
||||
// "未下发"与"为 0"。partial/final 帧亦会带这三个 0 字段,客户端以 type 分发,无碍。
|
||||
SessionSeconds int `json:"session_seconds"`
|
||||
BalanceSeconds int64 `json:"balance_seconds"`
|
||||
TrialRemaining int `json:"trial_remaining"`
|
||||
|
||||
// error
|
||||
Code string `json:"code,omitempty"`
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
set -e
|
||||
if rbw get dashscope-api-key >/dev/null 2>&1; then
|
||||
echo "[run-dev] 使用真实 gummy provider"
|
||||
rbw get dashscope-api-key | { read -r K; DASHSCOPE_API_KEY="$K" exec go run ./cmd/server; }
|
||||
rbw get dashscope-api-key | { read -r K; ASR_PROVIDER=gummy DASHSCOPE_API_KEY="$K" exec go run ./cmd/server; }
|
||||
else
|
||||
echo "[run-dev] 未取到 dashscope-api-key(rbw 未解锁或条目不存在),使用 mock provider"
|
||||
exec go run ./cmd/server
|
||||
|
||||
Reference in New Issue
Block a user