19281d9c42
后端(协议 → 存储 → 接口,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>
125 lines
5.7 KiB
Go
125 lines
5.7 KiB
Go
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 窗口按"次"记 1;secs 窗口按本次秒数记。
|
||
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) -- 17C:seq 计数器与 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 仅 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, 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
|
||
}
|