mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge branch 'Wei-Shaw:main' into main
This commit is contained in:
@@ -119,6 +119,8 @@ tests
|
||||
CLAUDE.md
|
||||
.claude
|
||||
scripts
|
||||
!backend/scripts/
|
||||
!backend/scripts/resolve-version.sh
|
||||
.code-review-state
|
||||
#openspec/
|
||||
code-reviews/
|
||||
|
||||
+2
-2
@@ -67,9 +67,9 @@ COPY backend/ ./
|
||||
COPY --from=frontend-builder /app/backend/internal/web/dist ./internal/web/dist
|
||||
|
||||
# Build the binary (BuildType=release for CI builds, embed frontend)
|
||||
# Version precedence: build arg VERSION > cmd/server/VERSION
|
||||
# Version precedence: build arg VERSION > exact git tag > cmd/server/VERSION
|
||||
RUN VERSION_VALUE="${VERSION}" && \
|
||||
if [ -z "${VERSION_VALUE}" ]; then VERSION_VALUE="$(tr -d '\r\n' < ./cmd/server/VERSION)"; fi && \
|
||||
if [ -z "${VERSION_VALUE}" ]; then VERSION_VALUE="$(./scripts/resolve-version.sh)"; fi && \
|
||||
DATE_VALUE="${DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" && \
|
||||
CGO_ENABLED=0 GOOS=linux go build \
|
||||
-tags embed \
|
||||
|
||||
@@ -447,7 +447,8 @@ pnpm run build
|
||||
|
||||
# 4. Build backend with embedded frontend
|
||||
cd ../backend
|
||||
go build -tags embed -o sub2api ./cmd/server
|
||||
VERSION="$(./scripts/resolve-version.sh)"
|
||||
go build -tags embed -ldflags="-X main.Version=${VERSION}" -o sub2api ./cmd/server
|
||||
|
||||
# 5. Create configuration file
|
||||
cp ../deploy/config.example.yaml ./config.yaml
|
||||
|
||||
+2
-1
@@ -462,7 +462,8 @@ pnpm run build
|
||||
|
||||
# 4. 编译后端(嵌入前端)
|
||||
cd ../backend
|
||||
go build -tags embed -o sub2api ./cmd/server
|
||||
VERSION="$(./scripts/resolve-version.sh)"
|
||||
go build -tags embed -ldflags="-X main.Version=${VERSION}" -o sub2api ./cmd/server
|
||||
|
||||
# 5. 创建配置文件
|
||||
cp ../deploy/config.example.yaml ./config.yaml
|
||||
|
||||
+2
-1
@@ -445,7 +445,8 @@ pnpm run build
|
||||
|
||||
# 4. フロントエンドを組み込んだバックエンドをビルド
|
||||
cd ../backend
|
||||
go build -tags embed -o sub2api ./cmd/server
|
||||
VERSION="$(./scripts/resolve-version.sh)"
|
||||
go build -tags embed -ldflags="-X main.Version=${VERSION}" -o sub2api ./cmd/server
|
||||
|
||||
# 5. 設定ファイルを作成
|
||||
cp ../deploy/config.example.yaml ./config.yaml
|
||||
|
||||
+2
-1
@@ -15,7 +15,8 @@ RUN go mod download
|
||||
COPY . .
|
||||
|
||||
# 构建应用
|
||||
RUN go build -o main ./cmd/server/
|
||||
RUN VERSION_VALUE="$(./scripts/resolve-version.sh)" && \
|
||||
go build -ldflags="-s -w -X main.Version=${VERSION_VALUE}" -o main ./cmd/server/
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8080
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
.PHONY: build generate test test-unit test-integration test-e2e
|
||||
|
||||
VERSION ?= $(shell tr -d '\r\n' < ./cmd/server/VERSION)
|
||||
VERSION ?= $(shell ./scripts/resolve-version.sh)
|
||||
LDFLAGS ?= -s -w -X main.Version=$(VERSION)
|
||||
|
||||
build:
|
||||
|
||||
@@ -3812,7 +3812,7 @@ func slotOf(s *service.DefaultPlatformQuotaSetting, win string) *float64 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// equalPlatformQuotaSettings reports whether two platform-quota maps are identical across all 12 slots.
|
||||
// equalPlatformQuotaSettings reports whether two platform-quota maps are identical across all allowed slots.
|
||||
func equalPlatformQuotaSettings(before, after map[string]*service.DefaultPlatformQuotaSetting) bool {
|
||||
for _, platform := range service.AllowedQuotaPlatforms {
|
||||
b := before[platform]
|
||||
|
||||
@@ -249,8 +249,9 @@ func (h *SubscriptionHandler) ResetQuota(c *gin.Context) {
|
||||
response.Success(c, dto.UserSubscriptionFromServiceAdmin(sub))
|
||||
}
|
||||
|
||||
// Revoke handles revoking a subscription
|
||||
// DELETE /api/v1/admin/subscriptions/:id
|
||||
// Revoke handles revoking a subscription.
|
||||
// POST /api/v1/admin/subscriptions/:id/revoke
|
||||
// DELETE /api/v1/admin/subscriptions/:id is kept for backward compatibility.
|
||||
func (h *SubscriptionHandler) Revoke(c *gin.Context) {
|
||||
subscriptionID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package admin
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
@@ -629,8 +630,8 @@ func (h *UserHandler) UpdateUserPlatformQuotas(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Quotas) > 4 {
|
||||
response.BadRequest(c, "quotas length must be <= 4")
|
||||
if len(req.Quotas) > len(service.AllowedQuotaPlatforms) {
|
||||
response.BadRequest(c, fmt.Sprintf("quotas length must be <= %d", len(service.AllowedQuotaPlatforms)))
|
||||
return
|
||||
}
|
||||
seen := make(map[string]struct{}, len(req.Quotas))
|
||||
@@ -749,7 +750,7 @@ func (h *UserHandler) UpdateUserPlatformQuotas(c *gin.Context) {
|
||||
|
||||
// 失效 cache:对全部允许的 platform 统一 invalidate。
|
||||
// Trade-off:精确失效(仅 req 涉及平台 + 被软删平台)需 upsert 前额外 ListByUser,
|
||||
// 增加一次 DB 查询和逻辑复杂度。由于 AllowedQuotaPlatforms 只有 4 个元素,
|
||||
// 增加一次 DB 查询和逻辑复杂度。由于 AllowedQuotaPlatforms 数量很少,
|
||||
// 全量 invalidate 的额外开销可接受,且能可靠覆盖软删除场景。
|
||||
if h.billingCache != nil {
|
||||
for _, p := range service.AllowedQuotaPlatforms {
|
||||
|
||||
@@ -98,7 +98,10 @@ func TestUpdateUserPlatformQuotas_Success(t *testing.T) {
|
||||
|
||||
body := `{"quotas":[
|
||||
{"platform":"anthropic","daily_limit_usd":10.0,"weekly_limit_usd":null,"monthly_limit_usd":100.0},
|
||||
{"platform":"openai","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null}
|
||||
{"platform":"openai","daily_limit_usd":80.0,"weekly_limit_usd":300.0,"monthly_limit_usd":null},
|
||||
{"platform":"gemini","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null},
|
||||
{"platform":"antigravity","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null},
|
||||
{"platform":"grok","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null}
|
||||
]}`
|
||||
c, w := putReq(t, body)
|
||||
h.UpdateUserPlatformQuotas(c)
|
||||
@@ -109,10 +112,10 @@ func TestUpdateUserPlatformQuotas_Success(t *testing.T) {
|
||||
if len(repo.upsertCalls) != 1 {
|
||||
t.Fatalf("UpsertForUser should be called once, got %d", len(repo.upsertCalls))
|
||||
}
|
||||
if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != 2 {
|
||||
if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != len(service.AllowedQuotaPlatforms) {
|
||||
t.Errorf("unexpected upsert call: %+v", repo.upsertCalls[0])
|
||||
}
|
||||
// 缓存失效:请求中 2 个 platform + 软删除的 3 个 platform(gemini, antigravity, grok)= 5 次
|
||||
// 缓存失效:按全部允许平台统一失效。
|
||||
if len(cache.deleteCalls) != 5 {
|
||||
t.Errorf("expected 5 cache delete calls, got %d: %+v", len(cache.deleteCalls), cache.deleteCalls)
|
||||
}
|
||||
@@ -154,7 +157,7 @@ func TestUpdateUserPlatformQuotas_RejectsNegativeLimit(t *testing.T) {
|
||||
func TestUpdateUserPlatformQuotas_RejectsTooManyEntries(t *testing.T) {
|
||||
h := buildTestHandler(&upsertCapturingQuotaRepo{}, &billingCacheStub{})
|
||||
body := `{"quotas":[
|
||||
{"platform":"anthropic"},{"platform":"openai"},{"platform":"gemini"},{"platform":"antigravity"},{"platform":"anthropic"}
|
||||
{"platform":"anthropic"},{"platform":"openai"},{"platform":"gemini"},{"platform":"antigravity"},{"platform":"grok"},{"platform":"anthropic"}
|
||||
]}`
|
||||
c, w := putReq(t, body)
|
||||
h.UpdateUserPlatformQuotas(c)
|
||||
|
||||
@@ -759,6 +759,7 @@ func userSubscriptionFromServiceBase(sub *service.UserSubscription) UserSubscrip
|
||||
MonthlyUsageUSD: sub.MonthlyUsageUSD,
|
||||
CreatedAt: sub.CreatedAt,
|
||||
UpdatedAt: sub.UpdatedAt,
|
||||
RevokedAt: sub.DeletedAt,
|
||||
User: UserFromServiceShallow(sub.User),
|
||||
Group: GroupFromServiceShallow(sub.Group),
|
||||
}
|
||||
|
||||
@@ -599,8 +599,9 @@ type UserSubscription struct {
|
||||
WeeklyUsageUSD float64 `json:"weekly_usage_usd"`
|
||||
MonthlyUsageUSD float64 `json:"monthly_usage_usd"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
|
||||
User *User `json:"user,omitempty"`
|
||||
Group *Group `json:"group,omitempty"`
|
||||
|
||||
@@ -21,7 +21,6 @@ var DefaultModels = []Model{
|
||||
{ID: "gpt-5.5", Object: "model", Created: 1776873600, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.5"},
|
||||
{ID: "gpt-5.4", Object: "model", Created: 1738368000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.4"},
|
||||
{ID: "gpt-5.4-mini", Object: "model", Created: 1738368000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.4 Mini"},
|
||||
{ID: "gpt-5.3-codex", Object: "model", Created: 1735689600, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.3 Codex"},
|
||||
{ID: "gpt-5.3-codex-spark", Object: "model", Created: 1735689600, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.3 Codex Spark"},
|
||||
{ID: "codex-auto-review", Object: "model", Created: 1776902400, OwnedBy: "openai", Type: "model", DisplayName: "Codex Auto Review"},
|
||||
{ID: "gpt-5.2", Object: "model", Created: 1733875200, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.2"},
|
||||
|
||||
@@ -575,7 +575,11 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati
|
||||
}))
|
||||
}
|
||||
|
||||
total, err := q.Count(ctx)
|
||||
// Clone before Count so interceptor-appended predicates (SoftDeleteMixin's
|
||||
// deleted_at IS NULL) don't accumulate on the shared builder and pollute the
|
||||
// subsequent list query. Same pattern used in group_repo/promo_code_repo/user_repo
|
||||
// (P1-03 audit fix, commit 2588fa6a).
|
||||
total, err := q.Clone().Count(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -445,9 +445,14 @@ func (s *AccountRepoSuite) TestListWithFilters() {
|
||||
|
||||
tt.setup(client)
|
||||
|
||||
accounts, _, err := repo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, tt.platform, tt.accType, tt.status, tt.search, tt.groupID, tt.privacyMode)
|
||||
accounts, page, err := repo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, tt.platform, tt.accType, tt.status, tt.search, tt.groupID, tt.privacyMode)
|
||||
s.Require().NoError(err)
|
||||
s.Require().Len(accounts, tt.wantCount)
|
||||
// Regression guard for issue #3601: when the whole result set fits on a single page,
|
||||
// pagination.Total must match len(items). A mismatch means the Count query was applied
|
||||
// against different predicates than the list query — the exact symptom reported.
|
||||
s.Require().NotNil(page)
|
||||
s.Require().Equal(int64(tt.wantCount), page.Total, "total must match items on single page")
|
||||
if tt.validate != nil {
|
||||
tt.validate(accounts)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ const (
|
||||
billingBalanceKeyPrefix = "billing:balance:"
|
||||
billingSubKeyPrefix = "billing:sub:"
|
||||
billingRateLimitKeyPrefix = "apikey:rate:"
|
||||
subCacheInvalidateChannel = "subscription:cache:invalidate"
|
||||
billingCacheTTL = 5 * time.Minute
|
||||
billingCacheJitter = 30 * time.Second
|
||||
rateLimitCacheTTL = 7 * 24 * time.Hour // 7 days matches the longest window
|
||||
@@ -256,6 +257,45 @@ func (c *billingCache) InvalidateSubscriptionCache(ctx context.Context, userID,
|
||||
return c.rdb.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
func (c *billingCache) PublishSubscriptionCacheInvalidation(ctx context.Context, cacheKey string) error {
|
||||
return c.rdb.Publish(ctx, subCacheInvalidateChannel, cacheKey).Err()
|
||||
}
|
||||
|
||||
func (c *billingCache) SubscribeSubscriptionCacheInvalidation(ctx context.Context, handler func(cacheKey string)) error {
|
||||
pubsub := c.rdb.Subscribe(ctx, subCacheInvalidateChannel)
|
||||
|
||||
_, err := pubsub.Receive(ctx)
|
||||
if err != nil {
|
||||
_ = pubsub.Close()
|
||||
return fmt.Errorf("subscribe to subscription cache invalidation: %w", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if err := pubsub.Close(); err != nil {
|
||||
log.Printf("Warning: failed to close subscription cache invalidation pubsub: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
ch := pubsub.Channel()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case msg, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if msg != nil {
|
||||
handler(msg.Payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *billingCache) GetAPIKeyRateLimit(ctx context.Context, keyID int64) (*service.APIKeyRateLimitCacheData, error) {
|
||||
key := billingRateLimitKey(keyID)
|
||||
result, err := c.rdb.HGetAll(ctx, key).Result()
|
||||
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/ent/group"
|
||||
"github.com/Wei-Shaw/sub2api/ent/predicate"
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
"github.com/Wei-Shaw/sub2api/ent/user"
|
||||
"github.com/Wei-Shaw/sub2api/ent/usersubscription"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -194,6 +197,7 @@ func (r *userSubscriptionRepository) ListByGroupID(ctx context.Context, groupID
|
||||
func (r *userSubscriptionRepository) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, platform, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) {
|
||||
client := clientFromContext(ctx, r.client)
|
||||
q := client.UserSubscription.Query()
|
||||
includeSoftDeleted := status == "" || status == service.SubscriptionStatusRevoked
|
||||
if userID != nil {
|
||||
q = q.Where(usersubscription.UserIDEQ(*userID))
|
||||
}
|
||||
@@ -201,7 +205,11 @@ func (r *userSubscriptionRepository) List(ctx context.Context, params pagination
|
||||
q = q.Where(usersubscription.GroupIDEQ(*groupID))
|
||||
}
|
||||
if platform != "" {
|
||||
q = q.Where(usersubscription.HasGroupWith(group.PlatformEQ(platform)))
|
||||
groupPredicates := []predicate.Group{group.PlatformEQ(platform)}
|
||||
if includeSoftDeleted {
|
||||
groupPredicates = append(groupPredicates, group.DeletedAtIsNil())
|
||||
}
|
||||
q = q.Where(usersubscription.HasGroupWith(groupPredicates...))
|
||||
}
|
||||
|
||||
// Status filtering with real-time expiration check
|
||||
@@ -224,20 +232,29 @@ func (r *userSubscriptionRepository) List(ctx context.Context, params pagination
|
||||
),
|
||||
),
|
||||
)
|
||||
case service.SubscriptionStatusRevoked:
|
||||
// Revoked is a DTO/API display state backed by user_subscriptions.deleted_at.
|
||||
q = q.Where(usersubscription.DeletedAtNotNil())
|
||||
case "":
|
||||
// No filter
|
||||
// No filter. Use SkipSoftDelete below so admin "all status" includes revoked history.
|
||||
default:
|
||||
// Other status (e.g., revoked)
|
||||
// Other persisted status.
|
||||
q = q.Where(usersubscription.StatusEQ(status))
|
||||
}
|
||||
|
||||
total, err := q.Clone().Count(ctx)
|
||||
queryCtx := ctx
|
||||
if includeSoftDeleted {
|
||||
queryCtx = mixins.SkipSoftDelete(ctx)
|
||||
}
|
||||
|
||||
total, err := q.Clone().Count(queryCtx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
q = q.WithUser().WithGroup().WithAssignedByUser()
|
||||
if !includeSoftDeleted {
|
||||
q = q.WithUser().WithGroup().WithAssignedByUser()
|
||||
}
|
||||
|
||||
// Determine sort field
|
||||
var field string
|
||||
@@ -260,12 +277,19 @@ func (r *userSubscriptionRepository) List(ctx context.Context, params pagination
|
||||
subs, err := q.
|
||||
Offset(params.Offset()).
|
||||
Limit(params.Limit()).
|
||||
All(ctx)
|
||||
All(queryCtx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return userSubscriptionEntitiesToService(subs), paginationResultFromTotal(int64(total), params), nil
|
||||
result := userSubscriptionEntitiesToService(subs)
|
||||
if includeSoftDeleted {
|
||||
if err := r.attachUserSubscriptionRelations(ctx, result); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return result, paginationResultFromTotal(int64(total), params), nil
|
||||
}
|
||||
|
||||
func (r *userSubscriptionRepository) ExistsByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) {
|
||||
@@ -425,17 +449,91 @@ func (r *userSubscriptionRepository) DeleteByGroupID(ctx context.Context, groupI
|
||||
return int64(n), err
|
||||
}
|
||||
|
||||
func (r *userSubscriptionRepository) attachUserSubscriptionRelations(ctx context.Context, subs []service.UserSubscription) error {
|
||||
if len(subs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
userIDs := make([]int64, 0, len(subs))
|
||||
groupIDs := make([]int64, 0, len(subs))
|
||||
assignedByIDs := make([]int64, 0, len(subs))
|
||||
for i := range subs {
|
||||
userIDs = append(userIDs, subs[i].UserID)
|
||||
groupIDs = append(groupIDs, subs[i].GroupID)
|
||||
if subs[i].AssignedBy != nil {
|
||||
assignedByIDs = append(assignedByIDs, *subs[i].AssignedBy)
|
||||
}
|
||||
}
|
||||
|
||||
client := clientFromContext(ctx, r.client)
|
||||
users, err := client.User.Query().Where(user.IDIn(uniqueInt64s(userIDs)...)).All(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userByID := make(map[int64]*service.User, len(users))
|
||||
for _, u := range users {
|
||||
userByID[u.ID] = userEntityToService(u)
|
||||
}
|
||||
|
||||
groups, err := client.Group.Query().Where(group.IDIn(uniqueInt64s(groupIDs)...)).All(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groupByID := make(map[int64]*service.Group, len(groups))
|
||||
for _, g := range groups {
|
||||
groupByID[g.ID] = groupEntityToService(g)
|
||||
}
|
||||
|
||||
assignedByID := map[int64]*service.User{}
|
||||
if len(assignedByIDs) > 0 {
|
||||
assignedUsers, err := client.User.Query().Where(user.IDIn(uniqueInt64s(assignedByIDs)...)).All(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
assignedByID = make(map[int64]*service.User, len(assignedUsers))
|
||||
for _, u := range assignedUsers {
|
||||
assignedByID[u.ID] = userEntityToService(u)
|
||||
}
|
||||
}
|
||||
|
||||
for i := range subs {
|
||||
subs[i].User = userByID[subs[i].UserID]
|
||||
subs[i].Group = groupByID[subs[i].GroupID]
|
||||
if subs[i].AssignedBy != nil {
|
||||
subs[i].AssignedByUser = assignedByID[*subs[i].AssignedBy]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func uniqueInt64s(values []int64) []int64 {
|
||||
seen := make(map[int64]struct{}, len(values))
|
||||
out := make([]int64, 0, len(values))
|
||||
for _, v := range values {
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func userSubscriptionEntityToService(m *dbent.UserSubscription) *service.UserSubscription {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
status := m.Status
|
||||
if m.DeletedAt != nil {
|
||||
status = service.SubscriptionStatusRevoked
|
||||
}
|
||||
out := &service.UserSubscription{
|
||||
ID: m.ID,
|
||||
UserID: m.UserID,
|
||||
GroupID: m.GroupID,
|
||||
StartsAt: m.StartsAt,
|
||||
ExpiresAt: m.ExpiresAt,
|
||||
Status: m.Status,
|
||||
Status: status,
|
||||
DailyWindowStart: m.DailyWindowStart,
|
||||
WeeklyWindowStart: m.WeeklyWindowStart,
|
||||
MonthlyWindowStart: m.MonthlyWindowStart,
|
||||
@@ -447,6 +545,7 @@ func userSubscriptionEntityToService(m *dbent.UserSubscription) *service.UserSub
|
||||
Notes: derefString(m.Notes),
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
DeletedAt: m.DeletedAt,
|
||||
}
|
||||
if m.Edges.User != nil {
|
||||
out.User = userEntityToService(m.Edges.User)
|
||||
|
||||
@@ -326,6 +326,61 @@ func (s *UserSubscriptionRepoSuite) TestList_FilterByStatus() {
|
||||
s.Require().Equal(service.SubscriptionStatusExpired, subs[0].Status)
|
||||
}
|
||||
|
||||
func (s *UserSubscriptionRepoSuite) TestList_IncludesRevokedWhenStatusEmpty() {
|
||||
user1 := s.mustCreateUser("allstatus1@test.com", service.RoleUser)
|
||||
user2 := s.mustCreateUser("allstatus2@test.com", service.RoleUser)
|
||||
user3 := s.mustCreateUser("allstatus3@test.com", service.RoleUser)
|
||||
group1 := s.mustCreateGroup("g-allstatus-1")
|
||||
group2 := s.mustCreateGroup("g-allstatus-2")
|
||||
group3 := s.mustCreateGroup("g-allstatus-3")
|
||||
|
||||
s.mustCreateSubscription(user1.ID, group1.ID, nil)
|
||||
s.mustCreateSubscription(user2.ID, group2.ID, func(c *dbent.UserSubscriptionCreate) {
|
||||
c.SetStatus(service.SubscriptionStatusExpired)
|
||||
c.SetExpiresAt(time.Now().Add(-24 * time.Hour))
|
||||
})
|
||||
revoked := s.mustCreateSubscription(user3.ID, group3.ID, nil)
|
||||
s.Require().NoError(s.repo.Delete(s.ctx, revoked.ID))
|
||||
|
||||
subs, pag, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, nil, nil, "", "", "", "")
|
||||
s.Require().NoError(err)
|
||||
s.Require().Len(subs, 3)
|
||||
s.Require().Equal(int64(3), pag.Total)
|
||||
|
||||
var gotRevoked *service.UserSubscription
|
||||
for i := range subs {
|
||||
if subs[i].ID == revoked.ID {
|
||||
gotRevoked = &subs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
s.Require().NotNil(gotRevoked, "all status should include soft-deleted subscription")
|
||||
s.Require().Equal(service.SubscriptionStatusRevoked, gotRevoked.Status)
|
||||
s.Require().NotNil(gotRevoked.DeletedAt)
|
||||
s.Require().NotNil(gotRevoked.User)
|
||||
s.Require().NotNil(gotRevoked.Group)
|
||||
}
|
||||
|
||||
func (s *UserSubscriptionRepoSuite) TestList_FilterByRevokedStatus() {
|
||||
user1 := s.mustCreateUser("revokedfilter1@test.com", service.RoleUser)
|
||||
user2 := s.mustCreateUser("revokedfilter2@test.com", service.RoleUser)
|
||||
group1 := s.mustCreateGroup("g-revoked-1")
|
||||
group2 := s.mustCreateGroup("g-revoked-2")
|
||||
|
||||
active := s.mustCreateSubscription(user1.ID, group1.ID, nil)
|
||||
revoked := s.mustCreateSubscription(user2.ID, group2.ID, nil)
|
||||
s.Require().NoError(s.repo.Delete(s.ctx, revoked.ID))
|
||||
|
||||
subs, pag, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, nil, nil, service.SubscriptionStatusRevoked, "", "", "")
|
||||
s.Require().NoError(err)
|
||||
s.Require().Len(subs, 1)
|
||||
s.Require().Equal(int64(1), pag.Total)
|
||||
s.Require().Equal(revoked.ID, subs[0].ID)
|
||||
s.Require().NotEqual(active.ID, subs[0].ID)
|
||||
s.Require().Equal(service.SubscriptionStatusRevoked, subs[0].Status)
|
||||
s.Require().NotNil(subs[0].DeletedAt)
|
||||
}
|
||||
|
||||
// --- Usage tracking ---
|
||||
|
||||
func (s *UserSubscriptionRepoSuite) TestIncrementUsage() {
|
||||
|
||||
@@ -806,7 +806,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"force_email_on_third_party_signup": false,
|
||||
"default_concurrency": 5,
|
||||
"default_balance": 1.25,
|
||||
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null}},
|
||||
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null}},
|
||||
"auth_source_default_email_platform_quotas": null,
|
||||
"auth_source_default_github_platform_quotas": null,
|
||||
"auth_source_default_google_platform_quotas": null,
|
||||
@@ -1054,7 +1054,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"purchase_subscription_url": "",
|
||||
"table_default_page_size": 20,
|
||||
"table_page_size_options": [10, 20, 50],
|
||||
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null}},
|
||||
"default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null}},
|
||||
"auth_source_default_email_platform_quotas": null,
|
||||
"auth_source_default_github_platform_quotas": null,
|
||||
"auth_source_default_google_platform_quotas": null,
|
||||
|
||||
@@ -561,6 +561,7 @@ func registerSubscriptionRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
subscriptions.POST("/bulk-assign", h.Admin.Subscription.BulkAssign)
|
||||
subscriptions.POST("/:id/extend", h.Admin.Subscription.Extend)
|
||||
subscriptions.POST("/:id/reset-quota", h.Admin.Subscription.ResetQuota)
|
||||
subscriptions.POST("/:id/revoke", h.Admin.Subscription.Revoke)
|
||||
subscriptions.DELETE("/:id", h.Admin.Subscription.Revoke)
|
||||
}
|
||||
|
||||
|
||||
@@ -1659,7 +1659,7 @@ func resolvedTokenVersion(user *User) int64 {
|
||||
return user.TokenVersion ^ fingerprint
|
||||
}
|
||||
|
||||
// snapshotPlatformQuotaDefaults 把 plan.PlatformQuotas(4 platform × 3 window)以
|
||||
// snapshotPlatformQuotaDefaults 把 plan.PlatformQuotas(platform × 3 window)以
|
||||
// BulkInsertInitial 形式写入 user_platform_quotas 表。失败 fail-open(仅 warn log)。
|
||||
func (s *AuthService) snapshotPlatformQuotaDefaults(ctx context.Context, userID int64, plan *signupGrantPlan) error {
|
||||
if s.userPlatformQuotaRepo == nil || plan == nil || len(plan.PlatformQuotas) == 0 {
|
||||
|
||||
@@ -96,6 +96,11 @@ type apiKeyRateLimitLoader interface {
|
||||
GetRateLimitData(ctx context.Context, keyID int64) (*APIKeyRateLimitData, error)
|
||||
}
|
||||
|
||||
type subscriptionCacheInvalidationPubSub interface {
|
||||
PublishSubscriptionCacheInvalidation(ctx context.Context, cacheKey string) error
|
||||
SubscribeSubscriptionCacheInvalidation(ctx context.Context, handler func(cacheKey string)) error
|
||||
}
|
||||
|
||||
// BillingCacheService 计费缓存服务
|
||||
// 负责余额和订阅数据的缓存管理,提供高性能的计费资格检查
|
||||
type BillingCacheService struct {
|
||||
@@ -525,6 +530,28 @@ func (s *BillingCacheService) InvalidateSubscription(ctx context.Context, userID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BillingCacheService) PublishSubscriptionCacheInvalidation(ctx context.Context, cacheKey string) error {
|
||||
if s.cache == nil {
|
||||
return nil
|
||||
}
|
||||
pubsub, ok := s.cache.(subscriptionCacheInvalidationPubSub)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return pubsub.PublishSubscriptionCacheInvalidation(ctx, cacheKey)
|
||||
}
|
||||
|
||||
func (s *BillingCacheService) SubscribeSubscriptionCacheInvalidation(ctx context.Context, handler func(cacheKey string)) error {
|
||||
if s.cache == nil {
|
||||
return nil
|
||||
}
|
||||
pubsub, ok := s.cache.(subscriptionCacheInvalidationPubSub)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return pubsub.SubscribeSubscriptionCacheInvalidation(ctx, handler)
|
||||
}
|
||||
|
||||
// InvalidateAPIKeyRateLimit invalidates the Redis rate-limit usage cache for an API key.
|
||||
func (s *BillingCacheService) InvalidateAPIKeyRateLimit(ctx context.Context, keyID int64) error {
|
||||
if s.cache == nil {
|
||||
|
||||
@@ -107,6 +107,8 @@ const (
|
||||
SubscriptionStatusActive = domain.SubscriptionStatusActive
|
||||
SubscriptionStatusExpired = domain.SubscriptionStatusExpired
|
||||
SubscriptionStatusSuspended = domain.SubscriptionStatusSuspended
|
||||
// SubscriptionStatusRevoked 是 soft-deleted 订阅的 API 展示态,不写入 status 字段。
|
||||
SubscriptionStatusRevoked = "revoked"
|
||||
)
|
||||
|
||||
// LinuxDoConnectSyntheticEmailDomain 是 LinuxDo Connect 用户的合成邮箱后缀(RFC 保留域名)。
|
||||
|
||||
@@ -5228,17 +5228,15 @@ func (s *SettingService) SetStreamTimeoutSettings(ctx context.Context, settings
|
||||
return s.settingRepo.Set(ctx, SettingKeyStreamTimeoutSettings, string(data))
|
||||
}
|
||||
|
||||
// GetDefaultPlatformQuotas 读取系统全局 platform quota JSON key,返回 4 platform x 3 window 的设置。
|
||||
// 永远返回包含全部 4 platform key 的 map(值可能为零值/nil 字段,表示"上层未配置 = 不限制")。
|
||||
// GetDefaultPlatformQuotas 读取系统全局 platform quota JSON key,返回全部允许平台 x 3 window 的设置。
|
||||
// 永远返回包含全部允许 platform key 的 map(值可能为零值/nil 字段,表示"上层未配置 = 不限制")。
|
||||
//
|
||||
// 使用单个 JSON key(default_platform_quotas),一次 DB roundtrip,消除旧 12-KV 格式的 N+1 问题。
|
||||
// 容错语义:取值失败或 unmarshal 失败 → 返回补齐 4 key 的空 map(fail-open,注册不被阻断)。
|
||||
// 容错语义:取值失败或 unmarshal 失败 → 返回补齐全部允许平台 key 的空 map(fail-open,注册不被阻断)。
|
||||
func (s *SettingService) GetDefaultPlatformQuotas(ctx context.Context) (map[string]*DefaultPlatformQuotaSetting, error) {
|
||||
out := map[string]*DefaultPlatformQuotaSetting{
|
||||
"anthropic": {},
|
||||
"openai": {},
|
||||
"gemini": {},
|
||||
"antigravity": {},
|
||||
out := make(map[string]*DefaultPlatformQuotaSetting, len(AllowedQuotaPlatforms))
|
||||
for _, platform := range AllowedQuotaPlatforms {
|
||||
out[platform] = &DefaultPlatformQuotaSetting{}
|
||||
}
|
||||
raw, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultPlatformQuotas)
|
||||
if err != nil || raw == "" {
|
||||
@@ -5254,7 +5252,7 @@ func (s *SettingService) GetDefaultPlatformQuotas(ctx context.Context) (map[stri
|
||||
out[platform] = v
|
||||
}
|
||||
}
|
||||
return out, nil // 补齐 4 platform key,保持与旧实现一致的下游契约
|
||||
return out, nil // 补齐全部允许 platform key,保持与旧实现一致的下游契约
|
||||
}
|
||||
|
||||
// GetAuthSourcePlatformQuotas 读取指定 auth source 的 platform quota 覆盖(仅返回有配置的平台,override 语义)。
|
||||
|
||||
@@ -60,18 +60,18 @@ func newSettingServiceForPlatformQuotaTest(seed map[string]string) *SettingServi
|
||||
return NewSettingService(repo, &config.Config{})
|
||||
}
|
||||
|
||||
func TestGetDefaultPlatformQuotas_ReturnsFourPlatforms(t *testing.T) {
|
||||
func TestGetDefaultPlatformQuotas_ReturnsAllowedPlatforms(t *testing.T) {
|
||||
zero := 0.0
|
||||
svc := newSettingServiceForPlatformQuotaTest(map[string]string{
|
||||
// 新 JSON 格式:anthropic daily=10.5, openai monthly=0, gemini/antigravity 无配置
|
||||
// 新 JSON 格式:anthropic daily=10.5, openai monthly=0, 其他平台无配置
|
||||
SettingKeyDefaultPlatformQuotas: `{"anthropic":{"daily":10.5},"openai":{"monthly":0}}`,
|
||||
})
|
||||
got, err := svc.GetDefaultPlatformQuotas(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// 必须包含全部 4 个 platform key(补齐契约)
|
||||
for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity"} {
|
||||
// 必须包含全部允许 platform key(补齐契约)
|
||||
for _, platform := range AllowedQuotaPlatforms {
|
||||
if _, ok := got[platform]; !ok {
|
||||
t.Errorf("missing platform key: %q", platform)
|
||||
}
|
||||
@@ -152,7 +152,7 @@ func TestGetAuthSourcePlatformQuotas_AllNegativeOrEmpty_NoEntry(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestSystemPlatformQuotas_WriteReadRoundTrip 验证系统层 platform quota 经 buildSystemSettingsUpdates(写)
|
||||
// 再由 GetDefaultPlatformQuotas(读)正确往返——覆盖真实 write→read 路径,锁住 4-key 补齐契约。
|
||||
// 再由 GetDefaultPlatformQuotas(读)正确往返,覆盖真实 write→read 路径并锁住平台补齐契约。
|
||||
func TestSystemPlatformQuotas_WriteReadRoundTrip(t *testing.T) {
|
||||
svc := newSettingServiceForPlatformQuotaTest(nil)
|
||||
ctx := context.Background()
|
||||
@@ -171,10 +171,10 @@ func TestSystemPlatformQuotas_WriteReadRoundTrip(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 4-key 补齐契约:无论写了几个 platform,读回必须含全部 4 个
|
||||
for _, p := range []string{"anthropic", "openai", "gemini", "antigravity"} {
|
||||
// 平台补齐契约:无论写了几个 platform,读回必须含全部允许平台
|
||||
for _, p := range AllowedQuotaPlatforms {
|
||||
if _, ok := got[p]; !ok {
|
||||
t.Errorf("4-key contract violated: missing platform %q", p)
|
||||
t.Errorf("allowed-platform contract violated: missing platform %q", p)
|
||||
}
|
||||
}
|
||||
// 写入值正确往返
|
||||
@@ -188,7 +188,7 @@ func TestSystemPlatformQuotas_WriteReadRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestSystemPlatformQuotas_EmptyMapClearsAll 验证空 map 的整体替换语义:
|
||||
// 写入 DefaultPlatformQuotas={} 后,GetDefaultPlatformQuotas 返回 4 个平台、所有字段均为 nil,
|
||||
// 写入 DefaultPlatformQuotas={} 后,GetDefaultPlatformQuotas 返回全部允许平台、所有字段均为 nil,
|
||||
// 明确文档化"空 map = 清空全部配额"是有意为之的 whole-replace 语义。
|
||||
func TestSystemPlatformQuotas_EmptyMapClearsAll(t *testing.T) {
|
||||
svc := newSettingServiceForPlatformQuotaTest(nil)
|
||||
@@ -215,10 +215,10 @@ func TestSystemPlatformQuotas_EmptyMapClearsAll(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 4 个 key 仍然存在(补齐契约)
|
||||
for _, p := range []string{"anthropic", "openai", "gemini", "antigravity"} {
|
||||
// 全部允许平台 key 仍然存在(补齐契约)
|
||||
for _, p := range AllowedQuotaPlatforms {
|
||||
if _, ok := got[p]; !ok {
|
||||
t.Errorf("4-key contract violated after empty write: missing %q", p)
|
||||
t.Errorf("allowed-platform contract violated after empty write: missing %q", p)
|
||||
}
|
||||
}
|
||||
// 所有字段 nil(全部已清空)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type revokeCacheUserSubRepoStub struct {
|
||||
userSubRepoNoop
|
||||
|
||||
sub *UserSubscription
|
||||
deleted bool
|
||||
getActiveCalls int
|
||||
}
|
||||
|
||||
func (r *revokeCacheUserSubRepoStub) GetByID(_ context.Context, id int64) (*UserSubscription, error) {
|
||||
if r.sub == nil || r.sub.ID != id || r.deleted {
|
||||
return nil, ErrSubscriptionNotFound
|
||||
}
|
||||
cp := *r.sub
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (r *revokeCacheUserSubRepoStub) Delete(_ context.Context, id int64) error {
|
||||
if r.sub == nil || r.sub.ID != id || r.deleted {
|
||||
return ErrSubscriptionNotFound
|
||||
}
|
||||
r.deleted = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *revokeCacheUserSubRepoStub) GetActiveByUserIDAndGroupID(_ context.Context, userID, groupID int64) (*UserSubscription, error) {
|
||||
r.getActiveCalls++
|
||||
if r.deleted || r.sub == nil || r.sub.UserID != userID || r.sub.GroupID != groupID {
|
||||
return nil, ErrSubscriptionNotFound
|
||||
}
|
||||
cp := *r.sub
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func TestRevokeSubscription_InvalidatesL1CacheSynchronously(t *testing.T) {
|
||||
repo := &revokeCacheUserSubRepoStub{
|
||||
sub: &UserSubscription{
|
||||
ID: 1,
|
||||
UserID: 10,
|
||||
GroupID: 20,
|
||||
Status: SubscriptionStatusActive,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
}
|
||||
svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, &config.Config{
|
||||
SubscriptionCache: config.SubscriptionCacheConfig{
|
||||
L1Size: 16,
|
||||
L1TTLSeconds: 60,
|
||||
},
|
||||
})
|
||||
t.Cleanup(svc.Stop)
|
||||
|
||||
_, err := svc.GetActiveSubscription(context.Background(), 10, 20)
|
||||
require.NoError(t, err)
|
||||
svc.subCacheL1.Wait()
|
||||
require.Equal(t, 1, repo.getActiveCalls)
|
||||
|
||||
err = svc.RevokeSubscription(context.Background(), 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.GetActiveSubscription(context.Background(), 10, 20)
|
||||
require.ErrorIs(t, err, ErrSubscriptionNotFound)
|
||||
require.Equal(t, 2, repo.getActiveCalls, "撤销后应回源确认订阅已不存在,不能命中旧 L1")
|
||||
}
|
||||
@@ -65,6 +65,7 @@ func NewSubscriptionService(groupRepo GroupRepository, userSubRepo UserSubscript
|
||||
}
|
||||
svc.initSubCache(cfg)
|
||||
svc.initMaintenanceQueue(cfg)
|
||||
svc.StartSubCacheInvalidationSubscriber(context.Background())
|
||||
return svc
|
||||
}
|
||||
|
||||
@@ -142,6 +143,48 @@ func (s *SubscriptionService) InvalidateSubCache(userID, groupID int64) {
|
||||
s.subCacheL1.Del(subCacheKey(userID, groupID))
|
||||
}
|
||||
|
||||
// InvalidateSubCacheSync 失效订阅 L1 缓存并等待 Ristretto 删除操作生效。
|
||||
func (s *SubscriptionService) InvalidateSubCacheSync(userID, groupID int64) {
|
||||
s.invalidateSubCacheKeySync(subCacheKey(userID, groupID))
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) invalidateSubCacheKeySync(key string) {
|
||||
if s.subCacheL1 == nil {
|
||||
return
|
||||
}
|
||||
s.subCacheL1.Del(key)
|
||||
s.subCacheL1.Wait()
|
||||
}
|
||||
|
||||
// StartSubCacheInvalidationSubscriber 启动跨实例订阅 L1 缓存失效订阅。
|
||||
func (s *SubscriptionService) StartSubCacheInvalidationSubscriber(ctx context.Context) {
|
||||
if s.billingCacheService == nil || s.subCacheL1 == nil {
|
||||
return
|
||||
}
|
||||
if err := s.billingCacheService.SubscribeSubscriptionCacheInvalidation(ctx, func(cacheKey string) {
|
||||
s.invalidateSubCacheKeySync(cacheKey)
|
||||
}); err != nil {
|
||||
log.Printf("Warning: failed to start subscription cache invalidation subscriber: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) invalidateSubscriptionCaches(userID, groupID int64) error {
|
||||
s.InvalidateSubCacheSync(userID, groupID)
|
||||
if s.billingCacheService == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := s.billingCacheService.InvalidateSubscription(cacheCtx, userID, groupID); err != nil {
|
||||
return fmt.Errorf("invalidate billing subscription cache: %w", err)
|
||||
}
|
||||
if err := s.billingCacheService.PublishSubscriptionCacheInvalidation(cacheCtx, subCacheKey(userID, groupID)); err != nil {
|
||||
return fmt.Errorf("publish subscription cache invalidation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AssignSubscriptionInput 分配订阅输入
|
||||
type AssignSubscriptionInput struct {
|
||||
UserID int64
|
||||
@@ -528,15 +571,8 @@ func (s *SubscriptionService) RevokeSubscription(ctx context.Context, subscripti
|
||||
return err
|
||||
}
|
||||
|
||||
// 失效订阅缓存
|
||||
s.InvalidateSubCache(sub.UserID, sub.GroupID)
|
||||
if s.billingCacheService != nil {
|
||||
userID, groupID := sub.UserID, sub.GroupID
|
||||
go func() {
|
||||
cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = s.billingCacheService.InvalidateSubscription(cacheCtx, userID, groupID)
|
||||
}()
|
||||
if err := s.invalidateSubscriptionCaches(sub.UserID, sub.GroupID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -779,10 +815,7 @@ func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionI
|
||||
// Invalidate L1 ristretto cache. Ristretto's Del() is asynchronous by design,
|
||||
// so call Wait() immediately after to flush pending operations and guarantee
|
||||
// the deleted key is not returned on the very next Get() call.
|
||||
s.InvalidateSubCache(sub.UserID, sub.GroupID)
|
||||
if s.subCacheL1 != nil {
|
||||
s.subCacheL1.Wait()
|
||||
}
|
||||
s.InvalidateSubCacheSync(sub.UserID, sub.GroupID)
|
||||
if s.billingCacheService != nil {
|
||||
_ = s.billingCacheService.InvalidateSubscription(ctx, sub.UserID, sub.GroupID)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ type UserSubscription struct {
|
||||
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt *time.Time
|
||||
|
||||
User *User
|
||||
Group *Group
|
||||
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
BACKEND_DIR="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||
REPO_DIR="$(CDPATH= cd -- "$BACKEND_DIR/.." && pwd)"
|
||||
VERSION_FILE="$BACKEND_DIR/cmd/server/VERSION"
|
||||
|
||||
# Prefer the exact release tag when building from a tagged checkout so
|
||||
# source builds from vX.Y.Z don't inherit the previous VERSION file value.
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
TAG="$(
|
||||
git -C "$REPO_DIR" describe --tags --exact-match --match 'v[0-9]*' 2>/dev/null || \
|
||||
git -C "$REPO_DIR" describe --tags --exact-match --match '[0-9]*' 2>/dev/null || \
|
||||
true
|
||||
)"
|
||||
if [ -n "$TAG" ]; then
|
||||
printf '%s\n' "${TAG#v}"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '%s\n' "$(tr -d '\r\n' < "$VERSION_FILE")"
|
||||
+7
-3
@@ -41,7 +41,7 @@ RUN pnpm run build
|
||||
FROM ${GOLANG_IMAGE} AS backend-builder
|
||||
|
||||
# Build arguments for version info (set by CI)
|
||||
ARG VERSION=docker
|
||||
ARG VERSION=
|
||||
ARG COMMIT=docker
|
||||
ARG DATE
|
||||
ARG GOPROXY
|
||||
@@ -66,9 +66,13 @@ COPY backend/ ./
|
||||
COPY --from=frontend-builder /app/backend/internal/web/dist ./internal/web/dist
|
||||
|
||||
# Build the binary (BuildType=release for CI builds, embed frontend)
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
# Version precedence: build arg VERSION > exact git tag > cmd/server/VERSION
|
||||
RUN VERSION_VALUE="${VERSION}" && \
|
||||
if [ -z "${VERSION_VALUE}" ]; then VERSION_VALUE="$(./scripts/resolve-version.sh)"; fi && \
|
||||
DATE_VALUE="${DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" && \
|
||||
CGO_ENABLED=0 GOOS=linux go build \
|
||||
-tags embed \
|
||||
-ldflags="-s -w -X main.Commit=${COMMIT} -X main.Date=${DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)} -X main.BuildType=release" \
|
||||
-ldflags="-s -w -X main.Version=${VERSION_VALUE} -X main.Commit=${COMMIT} -X main.Date=${DATE_VALUE} -X main.BuildType=release" \
|
||||
-o /app/sub2api \
|
||||
./cmd/server
|
||||
|
||||
|
||||
+6
-3
@@ -1,5 +1,8 @@
|
||||
.PHONY: wire build build-embed test-unit test-integration test-e2e test-cover-integration
|
||||
|
||||
VERSION ?= $(shell ./scripts/resolve-version.sh)
|
||||
LDFLAGS ?= -s -w -X main.Version=$(VERSION)
|
||||
|
||||
wire:
|
||||
@echo "生成 Wire 代码..."
|
||||
@cd cmd/server && go generate
|
||||
@@ -7,12 +10,12 @@ wire:
|
||||
|
||||
build:
|
||||
@echo "构建后端(不嵌入前端)..."
|
||||
@go build -o bin/server ./cmd/server
|
||||
@go build -ldflags="$(LDFLAGS)" -o bin/server ./cmd/server
|
||||
@echo "构建完成: bin/server"
|
||||
|
||||
build-embed:
|
||||
@echo "构建后端(嵌入前端)..."
|
||||
@go build -tags embed -o bin/server ./cmd/server
|
||||
@go build -tags embed -ldflags="$(LDFLAGS)" -o bin/server ./cmd/server
|
||||
@echo "构建完成: bin/server (with embedded frontend)"
|
||||
|
||||
test-unit:
|
||||
@@ -38,4 +41,4 @@ clean-coverage:
|
||||
|
||||
clean: clean-coverage
|
||||
@rm -rf bin/
|
||||
@echo "构建产物已清理"
|
||||
@echo "构建产物已清理"
|
||||
|
||||
@@ -117,7 +117,7 @@ export async function extend(
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function revoke(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/subscriptions/${id}`)
|
||||
const { data } = await apiClient.post<{ message: string }>(`/admin/subscriptions/${id}/revoke`)
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -700,22 +700,6 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
|
||||
xhigh: {}
|
||||
}
|
||||
},
|
||||
'gpt-5.3-codex': {
|
||||
name: 'GPT-5.3 Codex',
|
||||
limit: {
|
||||
context: 400000,
|
||||
output: 128000
|
||||
},
|
||||
options: {
|
||||
store: false
|
||||
},
|
||||
variants: {
|
||||
low: {},
|
||||
medium: {},
|
||||
high: {},
|
||||
xhigh: {}
|
||||
}
|
||||
},
|
||||
'codex-mini-latest': {
|
||||
name: 'Codex Mini',
|
||||
limit: {
|
||||
|
||||
@@ -12,7 +12,7 @@ const openaiModels = [
|
||||
// GPT-5.4 系列
|
||||
'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-2026-03-05',
|
||||
// GPT-5.3 / Codex 系列
|
||||
'gpt-5.3-codex', 'gpt-5.3-codex-spark', 'codex-auto-review',
|
||||
'gpt-5.3-codex-spark', 'codex-auto-review',
|
||||
'gpt-4o-audio-preview', 'gpt-4o-realtime-preview',
|
||||
// GPT Image 系列
|
||||
'gpt-image-1', 'gpt-image-1.5', 'gpt-image-2'
|
||||
|
||||
@@ -1625,6 +1625,7 @@ export interface UserSubscription {
|
||||
monthly_window_start: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
revoked_at?: string | null
|
||||
expires_at: string | null
|
||||
user?: User
|
||||
group?: Group
|
||||
|
||||
Reference in New Issue
Block a user