feat(v2): LimitAware 真实用量源——DBUsageSource 快照 + 对账 job 周期聚合当日已收(替 NopUsage)

This commit is contained in:
wangjia
2026-07-10 17:32:48 +08:00
parent e04cd73983
commit 4ac4e6ca9f
4 changed files with 142 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
package reconcile
import (
"context"
"sync"
"time"
"github.com/wangjia/pay/internal/store"
)
// UsageSource 满足 accounts.UsageSource:持「账户→当日已收 minor」快照,对账 job 周期 Refresh。
// Pick 路径读快照(零 DB);Refresh 从 attempts 聚合。跨天由 now() 的自然日窗自动滚动。
type UsageSource struct {
orders *store.OrderStore
now func() time.Time
mu sync.RWMutex
snap map[string]int64
}
func NewUsageSource(orders *store.OrderStore, now func() time.Time) *UsageSource {
if now == nil {
now = time.Now
}
return &UsageSource{orders: orders, now: now, snap: map[string]int64{}}
}
// TodayUsedMinor 读快照(accounts.UsageSource 接口);未刷新/未知账户返回 0(不误拒)。
func (u *UsageSource) TodayUsedMinor(accountID string) int64 {
u.mu.RLock()
defer u.mu.RUnlock()
return u.snap[accountID]
}
// Refresh 从 attempts 重算当日快照(幂等覆盖)。当日起点用 now() 的 UTC 日期。
func (u *UsageSource) Refresh(ctx context.Context) error {
m, err := u.orders.SumPaidAttemptMinorByAccountSince(StartOfDay(u.now()))
if err != nil {
return err
}
u.mu.Lock()
u.snap = m
u.mu.Unlock()
return nil
}
// StartOfDay 返回 t 所在 UTC 自然日 00:00(DailyLimit 按自然日结算)。
func StartOfDay(t time.Time) time.Time {
y, mo, d := t.UTC().Date()
return time.Date(y, mo, d, 0, 0, 0, 0, time.UTC)
}
// RefreshUsageTask 把 Refresh 包成周期任务体。
func RefreshUsageTask(u *UsageSource) func(ctx context.Context) error {
return func(ctx context.Context) error { return u.Refresh(ctx) }
}
+37
View File
@@ -0,0 +1,37 @@
package reconcile_test
import (
"context"
"testing"
"time"
"github.com/wangjia/pay/internal/accounts"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/reconcile"
"github.com/wangjia/pay/internal/store"
)
func TestUsageSourceRefreshAndInterface(t *testing.T) {
db := model.OpenTestDB(t)
s := store.NewOrderStore(db)
now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
paid := now.Add(-time.Hour)
_ = s.CreateAttempt(&model.Attempt{OutTradeNo: "A", Channel: "alipay", AccountID: "acct-1",
ProviderRef: "R-A", AmountMinor: 12000, Currency: "CNY", Status: model.AttemptPaid, PaidAt: &paid})
u := reconcile.NewUsageSource(s, func() time.Time { return now })
var _ accounts.UsageSource = u // 编译期断言满足接口
if u.TodayUsedMinor("acct-1") != 0 {
t.Fatalf("刷新前应 0")
}
if err := u.Refresh(context.Background()); err != nil {
t.Fatalf("refresh: %v", err)
}
if u.TodayUsedMinor("acct-1") != 12000 {
t.Fatalf("刷新后 acct-1 应 12000, got %d", u.TodayUsedMinor("acct-1"))
}
if u.TodayUsedMinor("unknown") != 0 {
t.Fatalf("未知账户应 0")
}
}