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,150 @@
|
||||
// Package store 数据模型与迁移。schema 设计见 doc/backend-architecture.html 第六章。
|
||||
package store
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `gorm:"primaryKey;size:32"`
|
||||
Nickname string `gorm:"size:64"`
|
||||
AvatarURL string `gorm:"size:512"`
|
||||
BalanceSeconds int64 `gorm:"not null;default:0"` // 冗余列,与 balance_ledger 同事务更新
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type WechatIdentity struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
UserID string `gorm:"size:32;index;not null"`
|
||||
OpenID string `gorm:"size:64;uniqueIndex;not null"`
|
||||
UnionID string `gorm:"size:64;index"`
|
||||
AppType string `gorm:"size:16;not null"` // web | mobile
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type DurationPack struct {
|
||||
ID string `gorm:"primaryKey;size:32"`
|
||||
Minutes int `gorm:"not null"`
|
||||
PriceCents int `gorm:"not null"`
|
||||
UnitDesc string `gorm:"size:64"`
|
||||
Tag string `gorm:"size:32"`
|
||||
Sort int
|
||||
Active bool `gorm:"not null;default:true"`
|
||||
}
|
||||
|
||||
const (
|
||||
OrderPending = "pending"
|
||||
OrderPaid = "paid"
|
||||
OrderClosed = "closed"
|
||||
)
|
||||
|
||||
type Order struct {
|
||||
ID string `gorm:"primaryKey;size:32"`
|
||||
UserID string `gorm:"size:32;index;not null"`
|
||||
PackID string `gorm:"size:32;not null"`
|
||||
PriceCents int `gorm:"not null"`
|
||||
Channel string `gorm:"size:16;not null"` // native | app
|
||||
Status string `gorm:"size:16;not null;index"` // pending | paid | closed
|
||||
TransactionID *string `gorm:"size:64;uniqueIndex"` // 微信支付单号,幂等去重
|
||||
PaidAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
LedgerPurchase = "purchase"
|
||||
LedgerUsage = "usage"
|
||||
LedgerGift = "gift"
|
||||
)
|
||||
|
||||
// BalanceLedger 时长账本:只追加不修改,余额 = SUM(delta_seconds)。
|
||||
type BalanceLedger struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
UserID string `gorm:"size:32;index;not null"`
|
||||
DeltaSeconds int64 `gorm:"not null"` // + 购买/赠送,− 用量
|
||||
Reason string `gorm:"size:16;not null"`
|
||||
OrderID *string `gorm:"size:32"`
|
||||
SessionID *string `gorm:"size:64"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// TrialUsage 每日免费试用:自然日(Asia/Shanghai)为键,无记录即未使用。
|
||||
type TrialUsage struct {
|
||||
UserID string `gorm:"primaryKey;size:32"`
|
||||
Date string `gorm:"primaryKey;size:10"` // YYYY-MM-DD
|
||||
UsedSeconds int `gorm:"not null;default:0"`
|
||||
}
|
||||
|
||||
// ASRSession 识别会话用量明细(计费拆分 + 排障)。
|
||||
type ASRSession struct {
|
||||
ID string `gorm:"primaryKey;size:64"`
|
||||
UserID string `gorm:"size:32;index;not null"`
|
||||
DeviceID string `gorm:"size:64"`
|
||||
AudioSeconds int `gorm:"not null"`
|
||||
TrialPart int `gorm:"not null"` // 试用扣减部分
|
||||
BalancePart int `gorm:"not null"` // 余额扣减部分
|
||||
ProviderMs int64 // Provider 报告的识别时长(ms),与 AudioSeconds 交叉校验/审计
|
||||
Provider string `gorm:"size:16"`
|
||||
Canceled bool
|
||||
CreatedAt time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
ID string `gorm:"primaryKey;size:64"`
|
||||
UserID string `gorm:"size:32;index"`
|
||||
Platform string `gorm:"size:16"` // mac | win | ios | android
|
||||
AppVersion string `gorm:"size:32"`
|
||||
LastSeen time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
FeedbackNew = "new"
|
||||
FeedbackTriaged = "triaged"
|
||||
FeedbackResolved = "resolved"
|
||||
)
|
||||
|
||||
// Feedback 用户反馈(v1.1)。
|
||||
type Feedback struct {
|
||||
ID string `gorm:"primaryKey;size:32"`
|
||||
UserID string `gorm:"size:32;index;not null"`
|
||||
Content string `gorm:"type:text;not null"`
|
||||
Images datatypes.JSON // OSS keys: ["fb/{id}/1.png", ...]
|
||||
Diagnostics datatypes.JSON
|
||||
Platform string `gorm:"size:16"`
|
||||
AppVersion string `gorm:"size:32"`
|
||||
Status string `gorm:"size:16;not null;default:new;index"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// MetricEvent 客户端打点原始事件(v1.1,保留 90 天定期清理)。
|
||||
type MetricEvent struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
UserID *string `gorm:"size:32"`
|
||||
DeviceID string `gorm:"size:64;not null"`
|
||||
Platform string `gorm:"size:16"`
|
||||
AppVersion string `gorm:"size:32"`
|
||||
OSVersion string `gorm:"size:64"`
|
||||
Event string `gorm:"size:64;not null;index"`
|
||||
Props datatypes.JSON
|
||||
ClientTs int64
|
||||
ReceivedAt time.Time // BRIN 索引在 Open() 中按 PG 专属 DDL 创建
|
||||
}
|
||||
|
||||
// MetricDaily 每日聚合(event × platform × date)。
|
||||
type MetricDaily struct {
|
||||
Date string `gorm:"primaryKey;size:10"`
|
||||
Event string `gorm:"primaryKey;size:64"`
|
||||
Platform string `gorm:"primaryKey;size:16"`
|
||||
Count int64 `gorm:"not null"`
|
||||
P50Ms float64 `gorm:"not null;default:0"`
|
||||
P95Ms float64 `gorm:"not null;default:0"`
|
||||
}
|
||||
|
||||
// AllModels 迁移清单(11 张表)。
|
||||
func AllModels() []any {
|
||||
return []any{
|
||||
&User{}, &WechatIdentity{}, &DurationPack{}, &Order{}, &BalanceLedger{},
|
||||
&TrialUsage{}, &ASRSession{}, &Device{}, &Feedback{}, &MetricEvent{}, &MetricDaily{},
|
||||
}
|
||||
}
|
||||
@@ -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 窗口按"次"记 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
|
||||
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
|
||||
@@ -0,0 +1,80 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func rdb(t *testing.T) *redis.Client {
|
||||
t.Helper()
|
||||
mr := miniredis.RunT(t)
|
||||
return redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
}
|
||||
|
||||
func TestAllowSessionWindow(t *testing.T) {
|
||||
r := rdb(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
// 30 次放行,第 31 次拒绝
|
||||
for i := 0; i < 30; i++ {
|
||||
ok, err := AllowSession(ctx, r, "dev1", now)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("session %d should pass: ok=%v err=%v", i+1, ok, err)
|
||||
}
|
||||
}
|
||||
if ok, _ := AllowSession(ctx, r, "dev1", now); ok {
|
||||
t.Fatal("31st session should be rejected")
|
||||
}
|
||||
// 其他设备不受影响
|
||||
if ok, _ := AllowSession(ctx, r, "dev2", now); !ok {
|
||||
t.Fatal("other device should pass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowAudioSecondsWindow(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")
|
||||
}
|
||||
// 再 100s(累计 1800)放行
|
||||
if ok, _ := AllowAudioSeconds(ctx, r, "dev1", 100, now.Add(time.Second)); !ok {
|
||||
t.Fatal("cumulative 1800s should pass")
|
||||
}
|
||||
// 再 1s 超限拒绝
|
||||
if ok, _ := AllowAudioSeconds(ctx, r, "dev1", 1, now.Add(2*time.Second)); ok {
|
||||
t.Fatal("1801s should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceSlot(t *testing.T) {
|
||||
r := rdb(t)
|
||||
ctx := context.Background()
|
||||
ok, err := AcquireDeviceSlot(ctx, r, "dev1", "s1")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("first acquire should pass: %v", err)
|
||||
}
|
||||
if ok, _ := AcquireDeviceSlot(ctx, r, "dev1", "s2"); ok {
|
||||
t.Fatal("second concurrent acquire should fail")
|
||||
}
|
||||
// 非持有者释放无效
|
||||
if err := ReleaseDeviceSlot(ctx, r, "dev1", "s2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, _ := AcquireDeviceSlot(ctx, r, "dev1", "s3"); ok {
|
||||
t.Fatal("slot should still be held by s1")
|
||||
}
|
||||
// 持有者释放后可再获取
|
||||
if err := ReleaseDeviceSlot(ctx, r, "dev1", "s1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, _ := AcquireDeviceSlot(ctx, r, "dev1", "s3"); !ok {
|
||||
t.Fatal("acquire after release should pass")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// Open 连接 PostgreSQL 并自动迁移全部表。
|
||||
func Open(dsn string) (*gorm.DB, error) {
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Warn),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.AutoMigrate(AllModels()...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 打点原始表用 BRIN(时间局部性强、体积小),PG 专属语法
|
||||
if err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_metric_events_received_at
|
||||
ON metric_events USING brin (received_at)`).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, SeedPacks(db)
|
||||
}
|
||||
|
||||
// SeedPacks 初始化三档时长包(已存在则跳过;价格可后续在库内调整)。
|
||||
func SeedPacks(db *gorm.DB) error {
|
||||
packs := []DurationPack{
|
||||
{ID: "pack_100", Minutes: 100, PriceCents: 900, UnitDesc: "约 ¥0.09 / 分钟", Tag: "", Sort: 1, Active: true},
|
||||
{ID: "pack_500", Minutes: 500, PriceCents: 3900, UnitDesc: "约 ¥0.078 / 分钟", Tag: "省 13%", Sort: 2, Active: true},
|
||||
{ID: "pack_2000", Minutes: 2000, PriceCents: 12900, UnitDesc: "约 ¥0.065 / 分钟", Tag: "省 28%", Sort: 3, Active: true},
|
||||
}
|
||||
for _, p := range packs {
|
||||
if err := db.Where(DurationPack{ID: p.ID}).FirstOrCreate(&p).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user