dudu MVP:五端语音输入法初始提交
- server:Go 网关(WS 流式识别中继/计费配额/微信登录支付 mock/反馈/埋点),gummy provider 已真实联调 - desktop:Tauri 2(全局快捷键 push-to-talk/浮层/托盘/设置/登录购买/反馈/首启引导) - android:Compose 主 App + IME(键盘内录音直传) - ios:App + 键盘扩展(1A spike 实证键盘内不可录音,走 deep link 听写) - design/design-pipeline:设计系统 + token 导出 iOS/Android 主题 - doc:前后端设计文档(HTML);web:官网宣传页;todo:任务看板 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
// Package quota 用量计量与扣减:先扣当日试用(180s/天),余下扣余额。
|
||||
// Redis 实时计数(Lua 原子),会话结束异步落库 ledger / asr_sessions。
|
||||
// 设计见 doc/backend-architecture.html 第五章。
|
||||
package quota
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"dudu/server/internal/store"
|
||||
"dudu/server/pkg/protocol"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
rdb *redis.Client
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func New(rdb *redis.Client, db *gorm.DB) *Manager { return &Manager{rdb: rdb, db: db} }
|
||||
|
||||
// consumeScript 原子扣减:先试用桶后余额桶。
|
||||
// KEYS[1]=trial key, KEYS[2]=balance key
|
||||
// ARGV[1]=need 秒, ARGV[2]=trial 每日上限
|
||||
// 返回 {trialPart, balancePart, remainBalance, trialUsed};不足部分允许扣穿到 0(识别中增量扣减,
|
||||
// 是否拒绝由 Precheck 与网关在句间判断——"扣穿当句不掐断")。
|
||||
var consumeScript = redis.NewScript(`
|
||||
local trialKey, balKey = KEYS[1], KEYS[2]
|
||||
local need, trialLimit = tonumber(ARGV[1]), tonumber(ARGV[2])
|
||||
local trialUsed = tonumber(redis.call('GET', trialKey) or '0')
|
||||
local bal = tonumber(redis.call('GET', balKey) or '0')
|
||||
local trialPart = math.min(need, math.max(0, trialLimit - trialUsed))
|
||||
local balancePart = math.min(need - trialPart, math.max(0, bal))
|
||||
if trialPart > 0 then
|
||||
redis.call('INCRBY', trialKey, trialPart)
|
||||
redis.call('EXPIRE', trialKey, 172800)
|
||||
end
|
||||
if balancePart > 0 then
|
||||
redis.call('DECRBY', balKey, balancePart)
|
||||
end
|
||||
return {trialPart, balancePart, bal - balancePart, trialUsed + trialPart}
|
||||
`)
|
||||
|
||||
type Snapshot struct {
|
||||
BalanceSeconds int64
|
||||
TrialUsedToday int
|
||||
}
|
||||
|
||||
// ensureBalanceLoaded 余额键懒加载:缺失时从 DB 冗余列加载(SET NX 防并发覆盖)。
|
||||
func (m *Manager) ensureBalanceLoaded(ctx context.Context, uid string) error {
|
||||
key := store.KeyQuotaBalance(uid)
|
||||
if exists, err := m.rdb.Exists(ctx, key).Result(); err != nil || exists == 1 {
|
||||
return err
|
||||
}
|
||||
var u store.User
|
||||
if err := m.db.WithContext(ctx).Select("balance_seconds").First(&u, "id = ?", uid).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return m.rdb.SetNX(ctx, key, u.BalanceSeconds, 0).Err()
|
||||
}
|
||||
|
||||
// Get 当前余额与今日试用(驱动 /v1/me 与 usage 帧)。
|
||||
func (m *Manager) Get(ctx context.Context, uid string) (Snapshot, error) {
|
||||
if err := m.ensureBalanceLoaded(ctx, uid); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
day := store.Day(time.Now())
|
||||
bal, err := m.rdb.Get(ctx, store.KeyQuotaBalance(uid)).Int64()
|
||||
if err != nil && err != redis.Nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
trial, err := m.rdb.Get(ctx, store.KeyQuotaTrial(uid, day)).Int()
|
||||
if err != nil && err != redis.Nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
return Snapshot{BalanceSeconds: bal, TrialUsedToday: trial}, nil
|
||||
}
|
||||
|
||||
// Precheck start 预检:试用剩余 + 余额 ≤ 0 → 拒绝(QUOTA_EXCEEDED)。
|
||||
func (m *Manager) Precheck(ctx context.Context, uid string) (bool, Snapshot, error) {
|
||||
s, err := m.Get(ctx, uid)
|
||||
if err != nil {
|
||||
return false, s, err
|
||||
}
|
||||
trialRemain := protocol.TrialDailySeconds - s.TrialUsedToday
|
||||
return trialRemain > 0 || s.BalanceSeconds > 0, s, nil
|
||||
}
|
||||
|
||||
type ConsumeResult struct {
|
||||
TrialPart int
|
||||
BalancePart int
|
||||
BalanceSeconds int64 // 扣减后余额
|
||||
TrialUsedToday int
|
||||
}
|
||||
|
||||
// Consume 扣减 seconds 秒(识别中每 2s 增量调用 + 会话结束补差)。
|
||||
func (m *Manager) Consume(ctx context.Context, uid string, seconds int) (ConsumeResult, error) {
|
||||
if err := m.ensureBalanceLoaded(ctx, uid); err != nil {
|
||||
return ConsumeResult{}, err
|
||||
}
|
||||
day := store.Day(time.Now())
|
||||
vals, err := consumeScript.Run(ctx, m.rdb,
|
||||
[]string{store.KeyQuotaTrial(uid, day), store.KeyQuotaBalance(uid)},
|
||||
seconds, protocol.TrialDailySeconds).Int64Slice()
|
||||
if err != nil {
|
||||
return ConsumeResult{}, err
|
||||
}
|
||||
return ConsumeResult{
|
||||
TrialPart: int(vals[0]),
|
||||
BalancePart: int(vals[1]),
|
||||
BalanceSeconds: vals[2],
|
||||
TrialUsedToday: int(vals[3]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Exhausted 判断是否已无可用时长(网关句间检查,决定何时下发 QUOTA_EXCEEDED)。
|
||||
func (s Snapshot) Exhausted() bool {
|
||||
return s.BalanceSeconds <= 0 && s.TrialUsedToday >= protocol.TrialDailySeconds
|
||||
}
|
||||
|
||||
// CreditPurchase 购买入账:事务内写 ledger + 更新冗余列,并同步 Redis 余额键。
|
||||
func (m *Manager) CreditPurchase(ctx context.Context, uid string, seconds int64, orderID string) error {
|
||||
err := m.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&store.BalanceLedger{
|
||||
UserID: uid, DeltaSeconds: seconds, Reason: store.LedgerPurchase, OrderID: &orderID,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&store.User{}).Where("id = ?", uid).
|
||||
UpdateColumn("balance_seconds", gorm.Expr("balance_seconds + ?", seconds)).Error
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Redis 键存在则同步增加;不存在则下次懒加载
|
||||
if exists, _ := m.rdb.Exists(ctx, store.KeyQuotaBalance(uid)).Result(); exists == 1 {
|
||||
return m.rdb.IncrBy(ctx, store.KeyQuotaBalance(uid), seconds).Err()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SettleSession 会话结束落库:用量 ledger(负向)+ asr_sessions 明细 + 冗余列。
|
||||
func (m *Manager) SettleSession(ctx context.Context, sess store.ASRSession) error {
|
||||
return m.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&sess).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if sess.BalancePart > 0 {
|
||||
sid := sess.ID
|
||||
if err := tx.Create(&store.BalanceLedger{
|
||||
UserID: sess.UserID, DeltaSeconds: -int64(sess.BalancePart),
|
||||
Reason: store.LedgerUsage, SessionID: &sid,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&store.User{}).Where("id = ?", sess.UserID).
|
||||
UpdateColumn("balance_seconds", gorm.Expr("balance_seconds - ?", sess.BalancePart)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if sess.TrialPart > 0 {
|
||||
day := store.Day(sess.CreatedAt)
|
||||
if err := tx.Exec(`INSERT INTO trial_usages (user_id, date, used_seconds) VALUES (?, ?, ?)
|
||||
ON CONFLICT (user_id, date) DO UPDATE SET used_seconds = trial_usages.used_seconds + ?`,
|
||||
sess.UserID, day, sess.TrialPart, sess.TrialPart).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package quota
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"dudu/server/internal/store"
|
||||
"dudu/server/pkg/protocol"
|
||||
)
|
||||
|
||||
func setup(t *testing.T) (*Manager, *redis.Client, *gorm.DB) {
|
||||
t.Helper()
|
||||
mr := miniredis.RunT(t)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(store.AllModels()...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return New(rdb, db), rdb, db
|
||||
}
|
||||
|
||||
func TestConsumeTrialFirstThenBalance(t *testing.T) {
|
||||
m, _, db := setup(t)
|
||||
ctx := context.Background()
|
||||
db.Create(&store.User{ID: "u1", BalanceSeconds: 100})
|
||||
|
||||
// 第一次扣 60s:全部走试用(180s/天)
|
||||
r, err := m.Consume(ctx, "u1", 60)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.TrialPart != 60 || r.BalancePart != 0 {
|
||||
t.Fatalf("want trial=60 balance=0, got %+v", r)
|
||||
}
|
||||
|
||||
// 再扣 150s:试用只剩 120s,余下 30s 走余额
|
||||
r, _ = m.Consume(ctx, "u1", 150)
|
||||
if r.TrialPart != 120 || r.BalancePart != 30 || r.BalanceSeconds != 70 {
|
||||
t.Fatalf("want trial=120 balance=30 remain=70, got %+v", r)
|
||||
}
|
||||
|
||||
// 再扣 100s:试用已尽,余额只剩 70 → 扣穿到 0(不掐断当句,由网关句间判断)
|
||||
r, _ = m.Consume(ctx, "u1", 100)
|
||||
if r.TrialPart != 0 || r.BalancePart != 70 || r.BalanceSeconds != 0 {
|
||||
t.Fatalf("want trial=0 balance=70 remain=0, got %+v", r)
|
||||
}
|
||||
|
||||
s, _ := m.Get(ctx, "u1")
|
||||
if !s.Exhausted() {
|
||||
t.Fatalf("expect exhausted, got %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrecheck(t *testing.T) {
|
||||
m, _, db := setup(t)
|
||||
ctx := context.Background()
|
||||
db.Create(&store.User{ID: "u2", BalanceSeconds: 0})
|
||||
|
||||
// 余额 0 但有当日试用 → 放行
|
||||
ok, _, err := m.Precheck(ctx, "u2")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("expect precheck pass (trial available), ok=%v err=%v", ok, err)
|
||||
}
|
||||
// 用尽试用 → 拒绝
|
||||
if _, err := m.Consume(ctx, "u2", protocol.TrialDailySeconds); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ok, _, _ = m.Precheck(ctx, "u2")
|
||||
if ok {
|
||||
t.Fatal("expect precheck reject after trial exhausted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreditAndSettle(t *testing.T) {
|
||||
m, rdb, db := setup(t)
|
||||
ctx := context.Background()
|
||||
db.Create(&store.User{ID: "u3", BalanceSeconds: 0})
|
||||
|
||||
if err := m.CreditPurchase(ctx, "u3", 6000, "o1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var u store.User
|
||||
db.First(&u, "id = ?", "u3")
|
||||
if u.BalanceSeconds != 6000 {
|
||||
t.Fatalf("want 6000, got %d", u.BalanceSeconds)
|
||||
}
|
||||
var n int64
|
||||
db.Model(&store.BalanceLedger{}).Where("user_id = ? AND reason = ?", "u3", store.LedgerPurchase).Count(&n)
|
||||
if n != 1 {
|
||||
t.Fatalf("want 1 ledger row, got %d", n)
|
||||
}
|
||||
|
||||
// 落库会话:30s = 试用 20 + 余额 10
|
||||
err := m.SettleSession(ctx, store.ASRSession{
|
||||
ID: "s1", UserID: "u3", AudioSeconds: 30, TrialPart: 20, BalancePart: 10,
|
||||
Provider: "mock", CreatedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.First(&u, "id = ?", "u3")
|
||||
if u.BalanceSeconds != 5990 {
|
||||
t.Fatalf("want 5990 after settle, got %d", u.BalanceSeconds)
|
||||
}
|
||||
var tu store.TrialUsage
|
||||
if err := db.First(&tu, "user_id = ?", "u3").Error; err != nil || tu.UsedSeconds != 20 {
|
||||
t.Fatalf("want trial_usage 20, got %+v err=%v", tu, err)
|
||||
}
|
||||
_ = rdb
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package quota
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"dudu/server/internal/store"
|
||||
)
|
||||
|
||||
// InvalidateBalance 删除 Redis 余额键 → 下次读取从 DB 冗余列懒加载(购买入账后调用)。
|
||||
func (m *Manager) InvalidateBalance(ctx context.Context, uid string) error {
|
||||
return m.rdb.Del(ctx, store.KeyQuotaBalance(uid)).Err()
|
||||
}
|
||||
|
||||
// Reconcile 对账:users.balance_seconds 必须等于 SUM(balance_ledger.delta_seconds)。
|
||||
// fix=true 时以 ledger 为准修正冗余列并失效 Redis 键。返回发现的偏差数。
|
||||
func (m *Manager) Reconcile(ctx context.Context, fix bool) (int, error) {
|
||||
type row struct {
|
||||
ID string
|
||||
Balance int64
|
||||
Ledger int64
|
||||
}
|
||||
var rows []row
|
||||
err := m.db.WithContext(ctx).Raw(`
|
||||
SELECT u.id, u.balance_seconds AS balance, COALESCE(SUM(l.delta_seconds), 0) AS ledger
|
||||
FROM users u LEFT JOIN balance_ledgers l ON l.user_id = u.id
|
||||
GROUP BY u.id, u.balance_seconds
|
||||
HAVING u.balance_seconds <> COALESCE(SUM(l.delta_seconds), 0)`).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
slog.Error("balance drift detected", "user", r.ID, "redundant", r.Balance, "ledger", r.Ledger)
|
||||
if fix {
|
||||
if err := m.db.WithContext(ctx).Model(&store.User{}).
|
||||
Where("id = ?", r.ID).UpdateColumn("balance_seconds", r.Ledger).Error; err != nil {
|
||||
return len(rows), err
|
||||
}
|
||||
_ = m.InvalidateBalance(ctx, r.ID)
|
||||
}
|
||||
}
|
||||
return len(rows), nil
|
||||
}
|
||||
|
||||
// StartDailyReconcile 每日对账任务(凌晨 4 点 CST)。
|
||||
func (m *Manager) StartDailyReconcile(ctx context.Context) {
|
||||
go func() {
|
||||
for {
|
||||
now := time.Now().In(time.FixedZone("CST", 8*3600))
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), 4, 0, 0, 0, now.Location())
|
||||
if !next.After(now) {
|
||||
next = next.Add(24 * time.Hour)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(time.Until(next)):
|
||||
if n, err := m.Reconcile(ctx, true); err != nil {
|
||||
slog.Error("reconcile failed", "err", err)
|
||||
} else if n > 0 {
|
||||
slog.Warn("reconcile fixed drifts", "count", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user