Files
dudu/server/internal/store/redis.go
T
wangjia 19281d9c42
ci / server (push) Failing after 10s
ci / design-system (push) Failing after 10s
feat(auth+desktop): 邮箱验证码登录 + 登录窗改版(微信/邮箱双方式,无边框)
后端(协议 → 存储 → 接口,E2E 测试全绿):
- protocol:AuthEmailCodeRequest / AuthEmailRequest DTO
- store:EmailIdentity 表(邮箱唯一索引)+ authmail:* Redis key
- auth/email.go:POST /v1/auth/email/code 发 6 位码(crypto/rand,
  60s 冷却 SetNX,10 分钟 TTL);POST /v1/auth/email 常量时间比对、
  码一次性、邮箱建号(昵称取前缀)→ JWT(与微信登录同响应形态)
- Mailer 接口 + MockMailer(验证码打日志供联调;SMTP 未配置自动降级,
  装配结果入启动日志,上线前须换真实实现)
- TestEmailLogin E2E:发码/冷却 429/错码拒绝/登录/token 可用/码防复用

桌面(Rust 命令 + UI 改版):
- api.rs:login_email_code / login_email(成功保存 token + ws 重连,
  与扫码登录同路径)
- 登录窗改版(原型 LoginPurchase 先行,dark 截图验收):logo + 分段
  切换(微信扫码 / 邮箱登录,与设置页同控件语言)+ 邮箱表单
  (60s 倒计时、错误文案、未注册自动建号提示)
- 二维码真渲染:qrcode 库画 canvas(前景/背景取主题 token),
  替换原 URL 文本占位;过期态半透明化
- 登录窗无边框化:transparent + Overlay 标题栏 + 原生贴形阴影;
  抽公共 WindowFrame 组件(设置窗同步重构复用)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:40:16 +08:00

125 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"context"
"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 KeyAuthEmail(email string) string { return "authmail:" + email }
func KeyAuthEmailCd(email string) string { return "authmail:cd:" + email }
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
-- val=0 为"只查不记"AudioWindowExhausted 的 start 预检):不落 0 值成员,
-- 避免污染窗口 ZSET 并空耗 seq;count 模式 val 恒为 1,不受影响。
if val > 0 then
local seq = redis.call('INCR', key .. ':seq')
redis.call('EXPIRE', key .. ':seq', window + 60) -- 17Cseq 计数器与 ZSET 同寿命,避免按设备永久泄漏
redis.call('ZADD', key, now, now .. '-' .. seq .. ':' .. val)
redis.call('EXPIRE', key, window + 60)
end
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
}
// 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()
}
const deviceSlotTTL = 4 * time.Minute
// AcquireDeviceSlot 单设备同时仅 1 路识别会话(SET NX + TTL 兜底防泄漏)。
//
// 续期契约(17D):槽位 TTL 仅 deviceSlotTTL4min)作为崩溃/泄漏兜底,并非会话上限。
// 长会话期间持有者必须周期性调用 RefreshDeviceSlot 续期(间隔需 < TTL),
// 否则 TTL 到期后槽位被释放、并发设备可抢占。网关 usageLoop 每个 tick2s
// 应顺带调用 RefreshDeviceSlot 续命;会话正常/异常结束时由 ReleaseDeviceSlot 主动释放。
func AcquireDeviceSlot(ctx context.Context, rdb *redis.Client, deviceID, sessionID string) (bool, error) {
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 {
// 仅当持有者是自己时释放
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
}