dudu MVP:五端语音输入法初始提交
ci / server (push) Failing after 14s
ci / design-tokens (push) Failing after 11s

- 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:
wangjia
2026-06-12 00:38:37 +08:00
commit 40760aa884
252 changed files with 40789 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
package store
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
// Redis Key 设计见 doc/backend-architecture.html 第七章。
func KeyQuotaBalance(uid string) string { return "quota:" + uid + ":balance" }
func KeyQuotaTrial(uid string, day string) string {
return "quota:" + uid + ":trial:" + day
}
func KeyAuthQr(state string) string { return "authqr:" + state }
func KeyJwtBlock(jti string) string { return "jwt:block:" + jti }
func KeyRateCnt(did string) string { return "rate:" + did + ":asr:cnt" }
func KeyRateSecs(did string) string { return "rate:" + did + ":asr:secs" }
func KeyRateFb(uid, day string) string { return "rate:" + uid + ":fb:" + day }
func KeyActiveSession(did string) string { return "asr:active:" + did }
func OpenRedis(addr string, db int) *redis.Client {
return redis.NewClient(&redis.Options{Addr: addr, DB: db})
}
// Day 返回服务端时区(Asia/Shanghai)的自然日,作为试用与反馈限频的键。
var cst = time.FixedZone("CST", 8*3600)
func Day(t time.Time) string { return t.In(cst).Format("2006-01-02") }
// ─── 设备 30 分钟滑动窗口限制(ZSET,member 唯一、score 为时间戳秒)───────────────
// slideWindow 原子地:清理过期成员 → 检查阈值 → 通过则记录本次。
// cnt 窗口按"次"记 1secs 窗口按本次秒数记。
var slideScript = redis.NewScript(`
local key, now, window, limit, val = KEYS[1], tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local sum = 0
if ARGV[5] == 'count' then
sum = redis.call('ZCARD', key)
else
local members = redis.call('ZRANGE', key, 0, -1)
for _, m in ipairs(members) do
local v = string.match(m, ':(%d+)$')
if v then sum = sum + tonumber(v) end
end
end
if sum + val > limit then return 0 end
redis.call('ZADD', key, now, now .. '-' .. redis.call('INCR', key .. ':seq') .. ':' .. val)
redis.call('EXPIRE', key, window + 60)
return 1
`)
// AllowSession 设备维度新会话准入:30 分钟内 ≤30 次。
func AllowSession(ctx context.Context, rdb *redis.Client, deviceID string, now time.Time) (bool, error) {
ok, err := slideScript.Run(ctx, rdb, []string{KeyRateCnt(deviceID)},
now.Unix(), 30*60, 30, 1, "count").Int()
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) {
ok, err := slideScript.Run(ctx, rdb, []string{KeyRateSecs(deviceID)},
now.Unix(), 30*60, 30*60, 0, "sum").Int()
return ok == 0, err
}
// RecordAudioSeconds 会话结束记录本次识别秒数(无条件,limit 取大数)。
func RecordAudioSeconds(ctx context.Context, rdb *redis.Client, deviceID string, seconds int, now time.Time) error {
return slideScript.Run(ctx, rdb, []string{KeyRateSecs(deviceID)},
now.Unix(), 30*60, 1<<30, seconds, "sum").Err()
}
// AcquireDeviceSlot 单设备同时仅 1 路识别会话(SET NX + TTL 兜底防泄漏)。
func AcquireDeviceSlot(ctx context.Context, rdb *redis.Client, deviceID, sessionID string) (bool, error) {
return rdb.SetNX(ctx, KeyActiveSession(deviceID), sessionID, 4*time.Minute).Result()
}
func ReleaseDeviceSlot(ctx context.Context, rdb *redis.Client, deviceID, sessionID string) error {
// 仅当持有者是自己时释放
script := redis.NewScript(`
if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end
return 0`)
return script.Run(ctx, rdb, []string{KeyActiveSession(deviceID)}, sessionID).Err()
}
// IncrDailyCounter 自然日计数器(反馈限频等),返回自增后的值。
func IncrDailyCounter(ctx context.Context, rdb *redis.Client, key string) (int64, error) {
pipe := rdb.TxPipeline()
incr := pipe.Incr(ctx, key)
pipe.Expire(ctx, key, 48*time.Hour)
if _, err := pipe.Exec(ctx); err != nil {
return 0, err
}
return incr.Val(), nil
}
var _ = fmt.Sprintf // keep fmt for future use