2026-07-13 01:15:30 +00:00
|
|
|
package repository
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-07-15 15:23:59 +00:00
|
|
|
"fmt"
|
2026-07-13 01:15:30 +00:00
|
|
|
|
|
|
|
|
libmongo "apps/backend/internal/lib/mongo"
|
|
|
|
|
"apps/backend/internal/module/usage/domain"
|
|
|
|
|
|
|
|
|
|
"github.com/zeromicro/go-zero/core/stores/mon"
|
|
|
|
|
"go.mongodb.org/mongo-driver/bson"
|
2026-07-15 15:23:59 +00:00
|
|
|
"go.mongodb.org/mongo-driver/mongo"
|
2026-07-13 01:15:30 +00:00
|
|
|
"go.mongodb.org/mongo-driver/mongo/options"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type MonStore struct {
|
|
|
|
|
events *mon.Model
|
2026-07-15 15:23:59 +00:00
|
|
|
counters *mon.Model
|
2026-07-13 01:15:30 +00:00
|
|
|
prefs *mon.Model
|
|
|
|
|
purchases *mon.Model
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewMonStore(uri, database string) *MonStore {
|
|
|
|
|
uri = libmongo.MustMongoURI(uri)
|
|
|
|
|
return &MonStore{
|
|
|
|
|
events: mon.MustNewModel(uri, database, "usage_events"),
|
2026-07-15 15:23:59 +00:00
|
|
|
counters: mon.MustNewModel(uri, database, "usage_monthly_counters"),
|
2026-07-13 01:15:30 +00:00
|
|
|
prefs: mon.MustNewModel(uri, database, "usage_prefs"),
|
|
|
|
|
purchases: mon.MustNewModel(uri, database, "usage_purchases"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 15:23:59 +00:00
|
|
|
func monthlyCounterID(uid int64, monthKey string) string {
|
|
|
|
|
return fmt.Sprintf("%d:%s", uid, monthKey)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func meterCounterField(meter string) (string, error) {
|
|
|
|
|
switch meter {
|
|
|
|
|
case domain.MeterAICopy, domain.MeterAIResearch, domain.MeterWebSearch, domain.MeterAIImage:
|
|
|
|
|
return "by_meter." + meter, nil
|
|
|
|
|
default:
|
|
|
|
|
return "", fmt.Errorf("invalid usage meter %q", meter)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *MonStore) ensureMonthlyCounter(ctx context.Context, uid int64, monthKey string) error {
|
|
|
|
|
id := monthlyCounterID(uid, monthKey)
|
|
|
|
|
var existing domain.MonthlyCounter
|
|
|
|
|
if err := s.counters.FindOne(ctx, &existing, bson.M{"_id": id}); err == nil {
|
|
|
|
|
return nil
|
|
|
|
|
} else if err != mon.ErrNotFound {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
events, err := s.ListEvents(ctx, uid, monthKey, domain.KeyModePlatform, 0)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
counter := &domain.MonthlyCounter{
|
|
|
|
|
ID: id, UID: uid, MonthKey: monthKey, ByMeter: map[string]int{}, UpdatedAt: domain.NowNano(),
|
|
|
|
|
}
|
|
|
|
|
for _, event := range events {
|
|
|
|
|
credits := event.Credits
|
|
|
|
|
if minimum := domain.MeterCost(event.Meter); credits < minimum {
|
|
|
|
|
credits = minimum
|
|
|
|
|
}
|
|
|
|
|
counter.TotalCredits += credits
|
|
|
|
|
counter.ByMeter[event.Meter] += credits
|
|
|
|
|
}
|
|
|
|
|
if _, err := s.counters.InsertOne(ctx, counter); err != nil && !mongo.IsDuplicateKeyError(err) {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *MonStore) ReservePlatform(ctx context.Context, uid int64, monthKey, meter string, cost, totalLimit, meterLimit int) error {
|
|
|
|
|
field, err := meterCounterField(meter)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if totalLimit >= 0 && cost > totalLimit {
|
|
|
|
|
return domain.ErrQuotaExceeded
|
|
|
|
|
}
|
|
|
|
|
if meterLimit >= 0 && cost > meterLimit {
|
|
|
|
|
return domain.ErrMeterCapExceeded
|
|
|
|
|
}
|
|
|
|
|
if err := s.ensureMonthlyCounter(ctx, uid, monthKey); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
filter := bson.M{"_id": monthlyCounterID(uid, monthKey)}
|
|
|
|
|
if totalLimit >= 0 {
|
|
|
|
|
filter["total_credits"] = bson.M{"$lte": totalLimit - cost}
|
|
|
|
|
}
|
|
|
|
|
if meterLimit >= 0 {
|
|
|
|
|
filter["$or"] = bson.A{
|
|
|
|
|
bson.M{field: bson.M{"$exists": false}},
|
|
|
|
|
bson.M{field: bson.M{"$lte": meterLimit - cost}},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
res, err := s.counters.UpdateOne(ctx, filter, bson.M{
|
|
|
|
|
"$inc": bson.M{"total_credits": cost, field: cost},
|
|
|
|
|
"$set": bson.M{"updated_at": domain.NowNano()},
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if res.MatchedCount != 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
counter, err := s.GetMonthlyCounter(ctx, uid, monthKey)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if totalLimit >= 0 && counter.TotalCredits+cost > totalLimit {
|
|
|
|
|
return domain.ErrQuotaExceeded
|
|
|
|
|
}
|
|
|
|
|
if meterLimit >= 0 && counter.ByMeter[meter]+cost > meterLimit {
|
|
|
|
|
return domain.ErrMeterCapExceeded
|
|
|
|
|
}
|
|
|
|
|
return fmt.Errorf("platform quota reservation did not match counter")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *MonStore) GetMonthlyCounter(ctx context.Context, uid int64, monthKey string) (*domain.MonthlyCounter, error) {
|
|
|
|
|
var counter domain.MonthlyCounter
|
|
|
|
|
err := s.counters.FindOne(ctx, &counter, bson.M{"_id": monthlyCounterID(uid, monthKey)})
|
|
|
|
|
if err == mon.ErrNotFound {
|
|
|
|
|
return nil, domain.ErrNotFound
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if counter.ByMeter == nil {
|
|
|
|
|
counter.ByMeter = map[string]int{}
|
|
|
|
|
}
|
|
|
|
|
return &counter, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 01:15:30 +00:00
|
|
|
func (s *MonStore) InsertEvent(ctx context.Context, e *domain.Event) error {
|
|
|
|
|
_, err := s.events.InsertOne(ctx, e)
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *MonStore) ListEvents(ctx context.Context, uid int64, monthKey, keyMode string, limit int) ([]*domain.Event, error) {
|
|
|
|
|
filter := bson.M{"uid": uid}
|
|
|
|
|
if monthKey != "" {
|
|
|
|
|
filter["month_key"] = monthKey
|
|
|
|
|
}
|
|
|
|
|
if keyMode != "" && keyMode != "all" {
|
|
|
|
|
filter["key_mode"] = keyMode
|
|
|
|
|
}
|
|
|
|
|
opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})
|
|
|
|
|
if limit > 0 {
|
|
|
|
|
opts.SetLimit(int64(limit))
|
|
|
|
|
}
|
|
|
|
|
var list []*domain.Event
|
|
|
|
|
err := s.events.Find(ctx, &list, filter, opts)
|
|
|
|
|
return list, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *MonStore) ListEventsAll(ctx context.Context, monthKey string, limit int) ([]*domain.Event, error) {
|
|
|
|
|
filter := bson.M{}
|
|
|
|
|
if monthKey != "" {
|
|
|
|
|
filter["month_key"] = monthKey
|
|
|
|
|
}
|
|
|
|
|
opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})
|
|
|
|
|
if limit > 0 {
|
|
|
|
|
opts.SetLimit(int64(limit))
|
|
|
|
|
}
|
|
|
|
|
var list []*domain.Event
|
|
|
|
|
err := s.events.Find(ctx, &list, filter, opts)
|
|
|
|
|
return list, err
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 15:23:59 +00:00
|
|
|
func (s *MonStore) ListEventsRange(ctx context.Context, fromNano, toExclusiveNano int64) ([]*domain.Event, error) {
|
|
|
|
|
var list []*domain.Event
|
|
|
|
|
err := s.events.Find(ctx, &list, bson.M{
|
|
|
|
|
"created_at": bson.M{"$gte": fromNano, "$lt": toExclusiveNano},
|
|
|
|
|
}, options.Find().SetSort(bson.D{{Key: "created_at", Value: 1}}))
|
|
|
|
|
return list, err
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 01:15:30 +00:00
|
|
|
func (s *MonStore) GetPrefs(ctx context.Context, uid int64) (*domain.MemberPrefs, error) {
|
|
|
|
|
var p domain.MemberPrefs
|
|
|
|
|
err := s.prefs.FindOne(ctx, &p, bson.M{"uid": uid})
|
|
|
|
|
if err != nil {
|
|
|
|
|
if err == mon.ErrNotFound {
|
|
|
|
|
return &domain.MemberPrefs{UID: uid, PlanID: domain.PlanFree}, nil
|
|
|
|
|
}
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return &p, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *MonStore) SavePrefs(ctx context.Context, p *domain.MemberPrefs) error {
|
|
|
|
|
_, err := s.prefs.ReplaceOne(ctx, bson.M{"uid": p.UID}, p, options.Replace().SetUpsert(true))
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *MonStore) InsertPurchase(ctx context.Context, p *domain.Purchase) error {
|
|
|
|
|
_, err := s.purchases.InsertOne(ctx, p)
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *MonStore) ListPurchases(ctx context.Context, uid int64, limit int) ([]*domain.Purchase, error) {
|
|
|
|
|
opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})
|
|
|
|
|
if limit > 0 {
|
|
|
|
|
opts.SetLimit(int64(limit))
|
|
|
|
|
}
|
|
|
|
|
var list []*domain.Purchase
|
|
|
|
|
err := s.purchases.Find(ctx, &list, bson.M{"uid": uid}, opts)
|
|
|
|
|
return list, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var _ domain.Repository = (*MonStore)(nil)
|