diff --git a/.gitignore b/.gitignore index 0f4e3b0625..bd2e3e6ddf 100644 --- a/.gitignore +++ b/.gitignore @@ -119,6 +119,8 @@ tests CLAUDE.md .claude scripts +!backend/scripts/ +!backend/scripts/resolve-version.sh .code-review-state #openspec/ code-reviews/ diff --git a/Dockerfile b/Dockerfile index 37b084e7a2..bae531ac6e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/README.md b/README.md index 7f0d72e9db..5315a9a0ec 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README_CN.md b/README_CN.md index 2af6217563..d8a4011a1e 100644 --- a/README_CN.md +++ b/README_CN.md @@ -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 diff --git a/README_JA.md b/README_JA.md index 1bc60c7529..05abbaca70 100644 --- a/README_JA.md +++ b/README_JA.md @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile index 26b1dc3337..d4adb5564b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 diff --git a/backend/Makefile b/backend/Makefile index 7084ccb93e..1e21f48ea5 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -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: diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 13d4c3fdbc..0f50ab723b 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -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] diff --git a/backend/internal/handler/admin/subscription_handler.go b/backend/internal/handler/admin/subscription_handler.go index 611666decf..990b19e72a 100644 --- a/backend/internal/handler/admin/subscription_handler.go +++ b/backend/internal/handler/admin/subscription_handler.go @@ -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 { diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index fb953057aa..b47310da1d 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -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 { diff --git a/backend/internal/handler/admin/user_platform_quota_admin_test.go b/backend/internal/handler/admin/user_platform_quota_admin_test.go index 5689aedceb..0211480b82 100644 --- a/backend/internal/handler/admin/user_platform_quota_admin_test.go +++ b/backend/internal/handler/admin/user_platform_quota_admin_test.go @@ -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) diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 24ba06e9d7..5bbab4d45f 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -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), } diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 98d5f94f02..b08dea5680 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -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"` diff --git a/backend/internal/pkg/openai/constants.go b/backend/internal/pkg/openai/constants.go index 3e85a59d91..f658cf0675 100644 --- a/backend/internal/pkg/openai/constants.go +++ b/backend/internal/pkg/openai/constants.go @@ -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"}, diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 314d7229ed..2a2f5e0576 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -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 } diff --git a/backend/internal/repository/account_repo_integration_test.go b/backend/internal/repository/account_repo_integration_test.go index b962ba9190..de7fd3a9f3 100644 --- a/backend/internal/repository/account_repo_integration_test.go +++ b/backend/internal/repository/account_repo_integration_test.go @@ -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) } diff --git a/backend/internal/repository/billing_cache.go b/backend/internal/repository/billing_cache.go index de229da9c3..af8ec49c08 100644 --- a/backend/internal/repository/billing_cache.go +++ b/backend/internal/repository/billing_cache.go @@ -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() diff --git a/backend/internal/repository/user_subscription_repo.go b/backend/internal/repository/user_subscription_repo.go index e3f64a5f6a..97a6bd08dc 100644 --- a/backend/internal/repository/user_subscription_repo.go +++ b/backend/internal/repository/user_subscription_repo.go @@ -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) diff --git a/backend/internal/repository/user_subscription_repo_integration_test.go b/backend/internal/repository/user_subscription_repo_integration_test.go index a74860e318..c800bc99fa 100644 --- a/backend/internal/repository/user_subscription_repo_integration_test.go +++ b/backend/internal/repository/user_subscription_repo_integration_test.go @@ -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() { diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 6a6396bc07..414f15416f 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -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, diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index d7b100c6dc..662c107e23 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -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) } diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 2849ddfc2f..a4c67a24e3 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -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 { diff --git a/backend/internal/service/billing_cache_service.go b/backend/internal/service/billing_cache_service.go index bfdb4c20bc..b1c6e0933e 100644 --- a/backend/internal/service/billing_cache_service.go +++ b/backend/internal/service/billing_cache_service.go @@ -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 { diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 19ed6b5b2e..ab853c280a 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -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 保留域名)。 diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 00e9d9029b..fed1a437a2 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -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 语义)。 diff --git a/backend/internal/service/setting_service_platform_quota_test.go b/backend/internal/service/setting_service_platform_quota_test.go index 557cc5f194..cde03b462c 100644 --- a/backend/internal/service/setting_service_platform_quota_test.go +++ b/backend/internal/service/setting_service_platform_quota_test.go @@ -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(全部已清空) diff --git a/backend/internal/service/subscription_revoke_cache_test.go b/backend/internal/service/subscription_revoke_cache_test.go new file mode 100644 index 0000000000..f1ba8e3893 --- /dev/null +++ b/backend/internal/service/subscription_revoke_cache_test.go @@ -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") +} diff --git a/backend/internal/service/subscription_service.go b/backend/internal/service/subscription_service.go index 9905e6a1e2..0d9043c0e5 100644 --- a/backend/internal/service/subscription_service.go +++ b/backend/internal/service/subscription_service.go @@ -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) } diff --git a/backend/internal/service/user_subscription.go b/backend/internal/service/user_subscription.go index 6303e6e3e3..732401ada2 100644 --- a/backend/internal/service/user_subscription.go +++ b/backend/internal/service/user_subscription.go @@ -25,6 +25,7 @@ type UserSubscription struct { CreatedAt time.Time UpdatedAt time.Time + DeletedAt *time.Time User *User Group *Group diff --git a/backend/scripts/resolve-version.sh b/backend/scripts/resolve-version.sh new file mode 100755 index 0000000000..7e3ac58348 --- /dev/null +++ b/backend/scripts/resolve-version.sh @@ -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")" diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 6bf0a50b4b..c9222da50f 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -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 diff --git a/deploy/Makefile b/deploy/Makefile index 2c6d521782..05f3c7957c 100644 --- a/deploy/Makefile +++ b/deploy/Makefile @@ -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 "构建产物已清理" \ No newline at end of file + @echo "构建产物已清理" diff --git a/frontend/src/api/admin/subscriptions.ts b/frontend/src/api/admin/subscriptions.ts index 611f67c2ca..63d92812ca 100644 --- a/frontend/src/api/admin/subscriptions.ts +++ b/frontend/src/api/admin/subscriptions.ts @@ -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 } diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index 6123a38a1e..6a08fb1722 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -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: { diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts index 244c6c8db2..43449b09ee 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -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' diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 1e2b49eab8..a3dd887279 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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