From 4cbd4932a0431b15e0811652eea8425d5a94145e Mon Sep 17 00:00:00 2001 From: Michael-Jetson <18769939178@163.com> Date: Tue, 5 May 2026 02:42:56 -0700 Subject: [PATCH 01/25] feat: add redeem code affiliate rebate, batch concurrency API, and markdown page rendering 1. Redeem code affiliate rebate: balance-type redeem codes now trigger invite rebate for the inviter. Payment fulfillment uses context key to prevent double-rebate. 2. Batch concurrency update: new POST /admin/users/batch-concurrency endpoint supporting mode=set/add with all=true for all users. 3. Markdown page rendering: new GET /api/v1/pages/:slug API serves local .md files. Custom menu items with url="md:slug" render markdown with collapsible TOC sidebar, scroll spy, and copy buttons on code blocks. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/cmd/server/wire_gen.go | 2 +- .../handler/admin/admin_service_stub_test.go | 4 + .../internal/handler/admin/setting_handler.go | 32 +- .../internal/handler/admin/user_handler.go | 60 ++++ .../handler/auth_oauth_pending_flow_test.go | 8 + backend/internal/handler/dto/settings.go | 1 + backend/internal/handler/page_handler.go | 127 +++++++ backend/internal/handler/user_handler_test.go | 2 + backend/internal/repository/user_repo.go | 31 ++ backend/internal/server/api_contract_test.go | 5 +- .../server/middleware/admin_auth_test.go | 3 + backend/internal/server/router.go | 2 + backend/internal/server/routes/admin.go | 1 + backend/internal/service/admin_service.go | 34 ++ .../service/admin_service_apikey_test.go | 3 + .../service/admin_service_delete_test.go | 3 + .../admin_service_email_identity_sync_test.go | 3 + .../service/auth_service_email_bind_test.go | 3 + .../internal/service/payment_fulfillment.go | 2 +- .../service/payment_order_lifecycle_test.go | 4 + backend/internal/service/redeem_service.go | 38 +++ backend/internal/service/user_service.go | 2 + backend/internal/service/user_service_test.go | 3 + frontend/src/types/index.ts | 1 + frontend/src/views/user/CustomPageView.vue | 310 +++++++++++++++++- 25 files changed, 666 insertions(+), 18 deletions(-) create mode 100644 backend/internal/handler/page_handler.go diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 81225ca6..049ae82e 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -74,7 +74,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService) userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache) redeemCache := repository.NewRedeemCache(redisClient) - redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator) + redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator, affiliateService) secretEncryptor, err := repository.NewAESEncryptor(configConfig) if err != nil { return nil, err diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index b187b47f..2fef94f1 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -175,6 +175,10 @@ func (s *stubAdminService) UpdateUserBalance(ctx context.Context, userID int64, return &user, nil } +func (s *stubAdminService) BatchUpdateConcurrency(ctx context.Context, userIDs []int64, value int, mode string) (int, error) { + return len(userIDs), nil +} + func (s *stubAdminService) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]service.APIKey, int64, error) { return s.apiKeys, int64(len(s.apiKeys)), nil } diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 0cec89aa..e6742c5e 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -994,17 +994,27 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { response.BadRequest(c, "Custom menu item label is too long (max 50 characters)") return } - if strings.TrimSpace(item.URL) == "" { - response.BadRequest(c, "Custom menu item URL is required") - return - } - if len(item.URL) > maxMenuItemURLLen { - response.BadRequest(c, "Custom menu item URL is too long (max 2048 characters)") - return - } - if err := config.ValidateAbsoluteHTTPURL(strings.TrimSpace(item.URL)); err != nil { - response.BadRequest(c, "Custom menu item URL must be an absolute http(s) URL") - return + urlTrimmed := strings.TrimSpace(item.URL) + if strings.HasPrefix(urlTrimmed, "md:") { + // Markdown page mode: URL = "md:" + slug := strings.TrimPrefix(urlTrimmed, "md:") + if slug == "" { + response.BadRequest(c, "Custom menu item markdown slug cannot be empty (use md:slug format)") + return + } + } else { + if urlTrimmed == "" { + response.BadRequest(c, "Custom menu item URL is required (use md:slug for markdown pages)") + return + } + if len(item.URL) > maxMenuItemURLLen { + response.BadRequest(c, "Custom menu item URL is too long (max 2048 characters)") + return + } + if err := config.ValidateAbsoluteHTTPURL(urlTrimmed); err != nil { + response.BadRequest(c, "Custom menu item URL must be an absolute http(s) URL or md:") + return + } } if item.Visibility != "user" && item.Visibility != "admin" { response.BadRequest(c, "Custom menu item visibility must be 'user' or 'admin'") diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index a297c56c..db35472e 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -477,3 +477,63 @@ func (h *UserHandler) GetUserRPMStatus(c *gin.Context) { response.Success(c, status) } + +// BatchUpdateConcurrency 批量修改用户并发数 +// POST /api/v1/admin/users/batch-concurrency +type BatchUpdateConcurrencyRequest struct { + UserIDs []int64 `json:"user_ids"` + All bool `json:"all"` + Concurrency int `json:"concurrency"` + Mode string `json:"mode" binding:"required,oneof=set add"` +} + +func (h *UserHandler) BatchUpdateConcurrency(c *gin.Context) { + var req BatchUpdateConcurrencyRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + if !req.All && len(req.UserIDs) == 0 { + response.BadRequest(c, "user_ids is required unless all=true") + return + } + if len(req.UserIDs) > 500 { + response.BadRequest(c, "user_ids cannot exceed 500") + return + } + + var userIDs []int64 + if req.All { + // Fetch all user IDs via pagination + page := 1 + const pageSize = 500 + for { + users, _, err := h.adminService.ListUsers(c.Request.Context(), page, pageSize, service.UserListFilters{}, "id", "asc") + if err != nil { + response.ErrorFrom(c, err) + return + } + for _, u := range users { + userIDs = append(userIDs, u.ID) + } + if len(users) < pageSize { + break + } + page++ + } + } else { + userIDs = req.UserIDs + } + + if len(userIDs) == 0 { + response.Success(c, gin.H{"affected": 0}) + return + } + + affected, err := h.adminService.BatchUpdateConcurrency(c.Request.Context(), userIDs, req.Concurrency, req.Mode) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, gin.H{"affected": affected}) +} diff --git a/backend/internal/handler/auth_oauth_pending_flow_test.go b/backend/internal/handler/auth_oauth_pending_flow_test.go index ffe9ff5f..b598eae1 100644 --- a/backend/internal/handler/auth_oauth_pending_flow_test.go +++ b/backend/internal/handler/auth_oauth_pending_flow_test.go @@ -2798,6 +2798,14 @@ func (r *oauthPendingFlowUserRepo) UpdateConcurrency(context.Context, int64, int panic("unexpected UpdateConcurrency call") } +func (r *oauthPendingFlowUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { + panic("unexpected BatchSetConcurrency call") +} + +func (r *oauthPendingFlowUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { + panic("unexpected BatchAddConcurrency call") +} + func (r *oauthPendingFlowUserRepo) GetLatestUsedAtByUserIDs(context.Context, []int64) (map[int64]*time.Time, error) { return map[int64]*time.Time{}, nil } diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 0bc834fe..8eef5c33 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -11,6 +11,7 @@ type CustomMenuItem struct { Label string `json:"label"` IconSVG string `json:"icon_svg"` URL string `json:"url"` + PageSlug string `json:"page_slug,omitempty"` Visibility string `json:"visibility"` // "user" or "admin" SortOrder int `json:"sort_order"` } diff --git a/backend/internal/handler/page_handler.go b/backend/internal/handler/page_handler.go new file mode 100644 index 00000000..8c29e971 --- /dev/null +++ b/backend/internal/handler/page_handler.go @@ -0,0 +1,127 @@ +package handler + +import ( + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/gin-gonic/gin" +) + +var validSlugPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`) + +const maxPageFileSize = 1 << 20 // 1MB + +type PageHandler struct { + pagesDir string +} + +func NewPageHandler(dataDir string) *PageHandler { + pagesDir := filepath.Join(dataDir, "pages") + _ = os.MkdirAll(pagesDir, 0755) + return &PageHandler{pagesDir: pagesDir} +} + +// GetPageContent serves raw markdown content for a given slug. +// GET /api/v1/pages/:slug +func (h *PageHandler) GetPageContent(c *gin.Context) { + slug := c.Param("slug") + if !validSlugPattern.MatchString(slug) || len(slug) > 64 { + response.BadRequest(c, "Invalid page slug") + return + } + + filePath := filepath.Join(h.pagesDir, slug+".md") + cleaned := filepath.Clean(filePath) + if !strings.HasPrefix(cleaned, filepath.Clean(h.pagesDir)) { + response.BadRequest(c, "Invalid page slug") + return + } + + info, err := os.Stat(cleaned) + if err != nil || info.IsDir() { + c.JSON(http.StatusNotFound, gin.H{"error": "page not found"}) + return + } + if info.Size() > maxPageFileSize { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "page too large"}) + return + } + + content, err := os.ReadFile(cleaned) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read page"}) + return + } + + c.Data(http.StatusOK, "text/markdown; charset=utf-8", content) +} + +// ListPages returns available page slugs. +// GET /api/v1/pages +func (h *PageHandler) ListPages(c *gin.Context) { + entries, err := os.ReadDir(h.pagesDir) + if err != nil { + response.Success(c, []string{}) + return + } + + slugs := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if strings.HasSuffix(name, ".md") { + slugs = append(slugs, strings.TrimSuffix(name, ".md")) + } + } + response.Success(c, slugs) +} + +// ServePageImage serves images from data/pages/{slug}/ directory. +// GET /api/v1/pages/:slug/images/*filename +func (h *PageHandler) ServePageImage(c *gin.Context) { + slug := c.Param("slug") + filename := c.Param("filename") + filename = strings.TrimPrefix(filename, "/") + + if !validSlugPattern.MatchString(slug) || len(slug) > 64 { + c.Status(http.StatusNotFound) + return + } + if filename == "" || strings.Contains(filename, "..") || strings.Contains(filename, "/") || strings.Contains(filename, "\\") { + c.Status(http.StatusNotFound) + return + } + + imagesDir := filepath.Join(h.pagesDir, slug) + filePath := filepath.Join(imagesDir, filename) + cleaned := filepath.Clean(filePath) + if !strings.HasPrefix(cleaned, filepath.Clean(imagesDir)) { + c.Status(http.StatusNotFound) + return + } + + info, err := os.Stat(cleaned) + if err != nil || info.IsDir() { + c.Status(http.StatusNotFound) + return + } + + c.File(cleaned) +} + +// RegisterPageRoutes registers page routes on a router group. +func RegisterPageRoutes(v1 *gin.RouterGroup, dataDir string) { + h := NewPageHandler(dataDir) + pages := v1.Group("/pages") + { + pages.GET("", h.ListPages) + pages.GET("/:slug", h.GetPageContent) + pages.GET("/:slug/images/*filename", h.ServePageImage) + } +} diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index 8a864b51..ffca86dc 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -87,6 +87,8 @@ func (s *userHandlerRepoStub) ListWithFilters(context.Context, pagination.Pagina func (s *userHandlerRepoStub) UpdateBalance(context.Context, int64, float64) error { return nil } func (s *userHandlerRepoStub) DeductBalance(context.Context, int64, float64) error { return nil } func (s *userHandlerRepoStub) UpdateConcurrency(context.Context, int64, int) error { return nil } +func (s *userHandlerRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (s *userHandlerRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } func (s *userHandlerRepoStub) ExistsByEmail(context.Context, string) (bool, error) { return false, nil } func (s *userHandlerRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { return 0, nil diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index d1f10cbd..1566756d 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -737,6 +737,37 @@ func (r *userRepository) UpdateConcurrency(ctx context.Context, id int64, amount return nil } +func (r *userRepository) BatchSetConcurrency(ctx context.Context, userIDs []int64, value int) (int, error) { + if len(userIDs) == 0 { + return 0, nil + } + if value < 0 { + value = 0 + } + res, err := r.sql.ExecContext(ctx, + "UPDATE users SET concurrency = $1, updated_at = NOW() WHERE id = ANY($2) AND deleted_at IS NULL", + value, pq.Array(userIDs)) + if err != nil { + return 0, fmt.Errorf("batch set concurrency: %w", err) + } + affected, _ := res.RowsAffected() + return int(affected), nil +} + +func (r *userRepository) BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error) { + if len(userIDs) == 0 { + return 0, nil + } + res, err := r.sql.ExecContext(ctx, + "UPDATE users SET concurrency = GREATEST(concurrency + $1, 0), updated_at = NOW() WHERE id = ANY($2) AND deleted_at IS NULL", + delta, pq.Array(userIDs)) + if err != nil { + return 0, fmt.Errorf("batch add concurrency: %w", err) + } + affected, _ := res.RowsAffected() + return int(affected), nil +} + func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool, error) { return r.client.User.Query().Where(userEmailLookupPredicate(email)).Exist(ctx) } diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 34f560fc..63cd3661 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1123,7 +1123,7 @@ func newContractDeps(t *testing.T) *contractDeps { subscriptionService := service.NewSubscriptionService(groupRepo, userSubRepo, nil, nil, cfg) subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService) - redeemService := service.NewRedeemService(redeemRepo, userRepo, subscriptionService, nil, nil, nil, nil) + redeemService := service.NewRedeemService(redeemRepo, userRepo, subscriptionService, nil, nil, nil, nil, nil) redeemHandler := handler.NewRedeemHandler(redeemService) settingRepo := newStubSettingRepo() @@ -1294,6 +1294,9 @@ func (r *stubUserRepo) UpdateConcurrency(ctx context.Context, id int64, amount i return errors.New("not implemented") } +func (r *stubUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (r *stubUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } + func (r *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) { return false, errors.New("not implemented") } diff --git a/backend/internal/server/middleware/admin_auth_test.go b/backend/internal/server/middleware/admin_auth_test.go index dde92dfd..3fbbb716 100644 --- a/backend/internal/server/middleware/admin_auth_test.go +++ b/backend/internal/server/middleware/admin_auth_test.go @@ -198,6 +198,9 @@ func (s *stubUserRepo) UpdateConcurrency(ctx context.Context, id int64, amount i panic("unexpected UpdateConcurrency call") } +func (s *stubUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (s *stubUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } + func (s *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) { panic("unexpected ExistsByEmail call") } diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index a507b6f8..9d424369 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -112,4 +112,6 @@ func registerRoutes( routes.RegisterAdminRoutes(v1, h, adminAuth) routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg) routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, settingService) + + handler.RegisterPageRoutes(v1, cfg.Pricing.DataDir) } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 20f5d619..b5696e56 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -228,6 +228,7 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) { users.GET("/:id/balance-history", h.Admin.User.GetBalanceHistory) users.POST("/:id/replace-group", h.Admin.User.ReplaceGroup) users.GET("/:id/rpm-status", h.Admin.User.GetUserRPMStatus) + users.POST("/batch-concurrency", h.Admin.User.BatchUpdateConcurrency) // User attribute values users.GET("/:id/attributes", h.Admin.UserAttribute.GetUserAttributes) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 793d60d8..eb5994d5 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -33,6 +33,7 @@ type AdminService interface { UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*User, error) DeleteUser(ctx context.Context, id int64) error UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) + BatchUpdateConcurrency(ctx context.Context, userIDs []int64, value int, mode string) (int, error) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]APIKey, int64, error) GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error) GetUserRPMStatus(ctx context.Context, userID int64) (*UserRPMStatus, error) @@ -817,6 +818,39 @@ func (s *adminServiceImpl) DeleteUser(ctx context.Context, id int64) error { return nil } +func (s *adminServiceImpl) BatchUpdateConcurrency(ctx context.Context, userIDs []int64, value int, mode string) (int, error) { + cleaned := make([]int64, 0, len(userIDs)) + for _, uid := range userIDs { + if uid > 0 { + cleaned = append(cleaned, uid) + } + } + if len(cleaned) == 0 { + return 0, nil + } + + var affected int + var err error + switch mode { + case "set": + affected, err = s.userRepo.BatchSetConcurrency(ctx, cleaned, value) + case "add": + affected, err = s.userRepo.BatchAddConcurrency(ctx, cleaned, value) + default: + return 0, errors.New("invalid mode: must be 'set' or 'add'") + } + if err != nil { + return 0, err + } + + if s.authCacheInvalidator != nil { + for _, uid := range cleaned { + s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, uid) + } + } + return affected, nil +} + func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) { user, err := s.userRepo.GetByID(ctx, userID) if err != nil { diff --git a/backend/internal/service/admin_service_apikey_test.go b/backend/internal/service/admin_service_apikey_test.go index fcde5cbf..3b3dbc21 100644 --- a/backend/internal/service/admin_service_apikey_test.go +++ b/backend/internal/service/admin_service_apikey_test.go @@ -68,6 +68,9 @@ func (s *userRepoStubForGroupUpdate) DeductBalance(context.Context, int64, float func (s *userRepoStubForGroupUpdate) UpdateConcurrency(context.Context, int64, int) error { panic("unexpected") } + +func (s *userRepoStubForGroupUpdate) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (s *userRepoStubForGroupUpdate) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } func (s *userRepoStubForGroupUpdate) ExistsByEmail(context.Context, string) (bool, error) { panic("unexpected") } diff --git a/backend/internal/service/admin_service_delete_test.go b/backend/internal/service/admin_service_delete_test.go index fe9e7701..a9492a1d 100644 --- a/backend/internal/service/admin_service_delete_test.go +++ b/backend/internal/service/admin_service_delete_test.go @@ -131,6 +131,9 @@ func (s *userRepoStub) UpdateConcurrency(ctx context.Context, id int64, amount i panic("unexpected UpdateConcurrency call") } +func (s *userRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (s *userRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } + func (s *userRepoStub) ExistsByEmail(ctx context.Context, email string) (bool, error) { if s.existsErr != nil { return false, s.existsErr diff --git a/backend/internal/service/admin_service_email_identity_sync_test.go b/backend/internal/service/admin_service_email_identity_sync_test.go index 2232c9c3..c791b747 100644 --- a/backend/internal/service/admin_service_email_identity_sync_test.go +++ b/backend/internal/service/admin_service_email_identity_sync_test.go @@ -113,6 +113,9 @@ func (s *emailSyncRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) return 0, nil } +func (s *emailSyncRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (s *emailSyncRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } + func (s *emailSyncRepoStub) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil } func (s *emailSyncRepoStub) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error { diff --git a/backend/internal/service/auth_service_email_bind_test.go b/backend/internal/service/auth_service_email_bind_test.go index ea2308f7..8f03f857 100644 --- a/backend/internal/service/auth_service_email_bind_test.go +++ b/backend/internal/service/auth_service_email_bind_test.go @@ -820,6 +820,9 @@ func (s *emailBindUserRepoStub) ExistsByEmail(_ context.Context, email string) ( return ok, nil } +func (s *emailBindUserRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (s *emailBindUserRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } + func (s *emailBindUserRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { return 0, nil } diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go index 4ae6d134..f96684a4 100644 --- a/backend/internal/service/payment_fulfillment.go +++ b/backend/internal/service/payment_fulfillment.go @@ -282,7 +282,7 @@ func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) e case redeemActionRedeem: // Code exists but unused — skip creation, proceed to redeem } - if _, err := s.redeemService.Redeem(ctx, o.UserID, o.RechargeCode); err != nil { + if _, err := s.redeemService.Redeem(ContextSkipRedeemAffiliate(ctx), o.UserID, o.RechargeCode); err != nil { return fmt.Errorf("redeem balance: %w", err) } if err := s.applyAffiliateRebateForOrder(ctx, o); err != nil { diff --git a/backend/internal/service/payment_order_lifecycle_test.go b/backend/internal/service/payment_order_lifecycle_test.go index 8dfd2e7e..d8595715 100644 --- a/backend/internal/service/payment_order_lifecycle_test.go +++ b/backend/internal/service/payment_order_lifecycle_test.go @@ -208,6 +208,7 @@ func TestVerifyOrderByOutTradeNoBackfillsTradeNoFromPaidQuery(t *testing.T) { nil, client, nil, + nil, ) registry := payment.NewRegistry() provider := &paymentOrderLifecycleQueryProvider{ @@ -308,6 +309,7 @@ func TestVerifyOrderByOutTradeNoRetriesZeroAmountPaidQueryOnce(t *testing.T) { nil, client, nil, + nil, ) registry := payment.NewRegistry() provider := &paymentOrderLifecycleQueryProvider{ @@ -398,6 +400,7 @@ func TestVerifyOrderByOutTradeNoRejectsPaidQueryWithZeroAmount(t *testing.T) { nil, client, nil, + nil, ) registry := payment.NewRegistry() provider := &paymentOrderLifecycleQueryProvider{ @@ -496,6 +499,7 @@ func TestVerifyOrderByOutTradeNoUsesOutTradeNoWhenPaymentTradeNoAlreadyExistsFor nil, client, nil, + nil, ) registry := payment.NewRegistry() provider := &paymentOrderLifecycleQueryProvider{ diff --git a/backend/internal/service/redeem_service.go b/backend/internal/service/redeem_service.go index 9ced6201..dcf293c5 100644 --- a/backend/internal/service/redeem_service.go +++ b/backend/internal/service/redeem_service.go @@ -11,6 +11,7 @@ import ( dbent "github.com/Wei-Shaw/sub2api/ent" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" ) @@ -28,6 +29,15 @@ const ( redeemLockDuration = 10 * time.Second // 锁超时时间,防止死锁 ) +type ctxKeySkipRedeemAffiliate struct{} + +// ContextSkipRedeemAffiliate returns a context that suppresses the redeem-level +// affiliate rebate. Used by payment fulfillment which handles rebate separately +// via applyAffiliateRebateForOrder (with audit-log deduplication). +func ContextSkipRedeemAffiliate(ctx context.Context) context.Context { + return context.WithValue(ctx, ctxKeySkipRedeemAffiliate{}, true) +} + // RedeemCache defines cache operations for redeem service type RedeemCache interface { GetRedeemAttemptCount(ctx context.Context, userID int64) (int, error) @@ -80,6 +90,7 @@ type RedeemService struct { billingCacheService *BillingCacheService entClient *dbent.Client authCacheInvalidator APIKeyAuthCacheInvalidator + affiliateService *AffiliateService } // NewRedeemService 创建兑换码服务实例 @@ -91,6 +102,7 @@ func NewRedeemService( billingCacheService *BillingCacheService, entClient *dbent.Client, authCacheInvalidator APIKeyAuthCacheInvalidator, + affiliateService *AffiliateService, ) *RedeemService { return &RedeemService{ redeemRepo: redeemRepo, @@ -100,6 +112,7 @@ func NewRedeemService( billingCacheService: billingCacheService, entClient: entClient, authCacheInvalidator: authCacheInvalidator, + affiliateService: affiliateService, } } @@ -369,6 +382,11 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) ( // 事务提交成功后失效缓存 s.invalidateRedeemCaches(ctx, userID, redeemCode) + // 余额类正数兑换码触发邀请返利(best-effort,失败不影响兑换结果) + if redeemCode.Type == RedeemTypeBalance && redeemCode.Value > 0 { + s.tryAccrueAffiliateRebateForRedeem(ctx, userID, redeemCode.Value) + } + // 重新获取更新后的兑换码 redeemCode, err = s.redeemRepo.GetByID(ctx, redeemCode.ID) if err != nil { @@ -418,6 +436,26 @@ func (s *RedeemService) invalidateRedeemCaches(ctx context.Context, userID int64 } } +func (s *RedeemService) tryAccrueAffiliateRebateForRedeem(ctx context.Context, userID int64, amount float64) { + if ctx.Value(ctxKeySkipRedeemAffiliate{}) != nil { + return + } + if s.affiliateService == nil { + return + } + if !s.affiliateService.IsEnabled(ctx) { + return + } + rebate, err := s.affiliateService.AccrueInviteRebate(ctx, userID, amount) + if err != nil { + logger.LegacyPrintf("service.redeem", "[Redeem] affiliate rebate failed for user %d amount %.2f: %v", userID, amount, err) + return + } + if rebate > 0 { + logger.LegacyPrintf("service.redeem", "[Redeem] affiliate rebate accrued %.8f for inviter of user %d", rebate, userID) + } +} + // GetByID 根据ID获取兑换码 func (s *RedeemService) GetByID(ctx context.Context, id int64) (*RedeemCode, error) { code, err := s.redeemRepo.GetByID(ctx, id) diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index a7279e6a..f84e6f0a 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -96,6 +96,8 @@ type UserRepository interface { UpdateBalance(ctx context.Context, id int64, amount float64) error DeductBalance(ctx context.Context, id int64, amount float64) error UpdateConcurrency(ctx context.Context, id int64, amount int) error + BatchSetConcurrency(ctx context.Context, userIDs []int64, value int) (int, error) + BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error) ExistsByEmail(ctx context.Context, email string) (bool, error) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) // AddGroupToAllowedGroups 将指定分组增量添加到用户的 allowed_groups(幂等,冲突忽略) diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go index ff55c2a5..775dd602 100644 --- a/backend/internal/service/user_service_test.go +++ b/backend/internal/service/user_service_test.go @@ -199,6 +199,9 @@ func (m *mockUserRepo) ExistsByEmail(context.Context, string) (bool, error) { re func (m *mockUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { return 0, nil } + +func (m *mockUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (m *mockUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } func (m *mockUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil } func (m *mockUserRepo) ListUserAuthIdentities(context.Context, int64) ([]UserAuthIdentityRecord, error) { out := make([]UserAuthIdentityRecord, len(m.identities)) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 79530c99..e510b84e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -168,6 +168,7 @@ export interface CustomMenuItem { label: string icon_svg: string url: string + page_slug?: string visibility: 'user' | 'admin' sort_order: number } diff --git a/frontend/src/views/user/CustomPageView.vue b/frontend/src/views/user/CustomPageView.vue index ce930d96..a49a2a3a 100644 --- a/frontend/src/views/user/CustomPageView.vue +++ b/frontend/src/views/user/CustomPageView.vue @@ -27,6 +27,56 @@ + +
+ + + + + + + +
+
+ +
+
diff --git a/frontend/src/components/auth/GitHubMark.vue b/frontend/src/components/auth/GitHubMark.vue new file mode 100644 index 00000000..a790e622 --- /dev/null +++ b/frontend/src/components/auth/GitHubMark.vue @@ -0,0 +1,7 @@ + diff --git a/frontend/src/components/auth/GoogleMark.vue b/frontend/src/components/auth/GoogleMark.vue new file mode 100644 index 00000000..a848a811 --- /dev/null +++ b/frontend/src/components/auth/GoogleMark.vue @@ -0,0 +1,8 @@ + diff --git a/frontend/src/components/auth/__tests__/EmailOAuthButtons.spec.ts b/frontend/src/components/auth/__tests__/EmailOAuthButtons.spec.ts new file mode 100644 index 00000000..dbda48e6 --- /dev/null +++ b/frontend/src/components/auth/__tests__/EmailOAuthButtons.spec.ts @@ -0,0 +1,102 @@ +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue' + +const routeState = vi.hoisted(() => ({ + query: {} as Record, +})) + +const locationState = vi.hoisted(() => ({ + current: { href: 'http://localhost/register?aff=AFF123' } as { href: string }, +})) + +vi.mock('vue-router', () => ({ + useRoute: () => routeState, +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string, params?: Record) => { + if (key === 'auth.emailOAuth.signIn') { + return `使用 ${params?.providerName ?? ''} 登录` + } + return key + }, + }), +})) + +describe('EmailOAuthButtons', () => { + beforeEach(() => { + routeState.query = { redirect: '/billing?plan=pro', aff: 'AFF123' } + locationState.current = { href: 'http://localhost/register?aff=AFF123' } + Object.defineProperty(window, 'location', { + configurable: true, + value: locationState.current, + }) + window.localStorage.clear() + window.sessionStorage.clear() + }) + + it('passes the affiliate code to the email oauth start URL', async () => { + const wrapper = mount(EmailOAuthButtons, { + props: { + githubEnabled: true, + googleEnabled: false, + }, + global: { + stubs: { + GitHubMark: true, + GoogleMark: true, + }, + }, + }) + + await wrapper.get('button').trigger('click') + + expect(locationState.current.href).toBe( + '/api/v1/auth/oauth/github/start?redirect=%2Fbilling%3Fplan%3Dpro&aff_code=AFF123' + ) + expect(window.sessionStorage.getItem('oauth_aff_code')).toBe('AFF123') + }) + + it('uses a full-width descriptive button when only GitHub is enabled', () => { + const wrapper = mount(EmailOAuthButtons, { + props: { + githubEnabled: true, + googleEnabled: false, + }, + global: { + stubs: { + GitHubMark: true, + GoogleMark: true, + }, + }, + }) + + expect(wrapper.find('.grid').classes()).not.toContain('sm:grid-cols-2') + expect(wrapper.get('button').text()).toContain('使用 GitHub 登录') + }) + + it('uses compact labels and two columns when GitHub and Google are both enabled', () => { + const wrapper = mount(EmailOAuthButtons, { + props: { + githubEnabled: true, + googleEnabled: true, + }, + global: { + stubs: { + GitHubMark: true, + GoogleMark: true, + }, + }, + }) + + expect(wrapper.find('.grid').classes()).toContain('sm:grid-cols-2') + const buttons = wrapper.findAll('button') + expect(buttons).toHaveLength(2) + expect(buttons[0].text()).toContain('GitHub') + expect(buttons[0].text()).not.toContain('使用 GitHub 登录') + expect(buttons[1].text()).toContain('Google') + expect(buttons[1].text()).not.toContain('使用 Google 登录') + }) +}) diff --git a/frontend/src/components/user/profile/ProfileInfoCard.vue b/frontend/src/components/user/profile/ProfileInfoCard.vue index 37ee8a55..2c190715 100644 --- a/frontend/src/components/user/profile/ProfileInfoCard.vue +++ b/frontend/src/components/user/profile/ProfileInfoCard.vue @@ -263,7 +263,9 @@ const providerLabels = computed>(() => ({ email: t('profile.authBindings.providers.email'), linuxdo: t('profile.authBindings.providers.linuxdo'), oidc: t('profile.authBindings.providers.oidc', { providerName: props.oidcProviderName }), - wechat: t('profile.authBindings.providers.wechat') + wechat: t('profile.authBindings.providers.wechat'), + github: 'GitHub', + google: 'Google' })) function formatCurrency(value: number): string { @@ -272,7 +274,13 @@ function formatCurrency(value: number): string { function normalizeProvider(value: string): UserAuthProvider | null { const normalized = value.trim().toLowerCase() - if (normalized === 'email' || normalized === 'linuxdo' || normalized === 'wechat') { + if ( + normalized === 'email' || + normalized === 'linuxdo' || + normalized === 'wechat' || + normalized === 'github' || + normalized === 'google' + ) { return normalized } if (normalized === 'oidc' || normalized.startsWith('oidc:') || normalized.startsWith('oidc/')) { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 5f968ac7..9ad4518d 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -472,6 +472,9 @@ export default { completing: 'Completing registration…', completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.' }, + emailOAuth: { + signIn: 'Continue with {providerName}' + }, oidc: { signIn: 'Continue with {providerName}', callbackTitle: 'Signing you in with {providerName}', @@ -531,6 +534,8 @@ export default { oauth: { callbackTitle: 'OAuth Callback', callbackHint: 'Copy the code and state back to the admin authorization flow when needed.', + invalidCallbackTitle: 'Invalid sign-in callback', + invalidCallbackHint: 'This page does not contain a valid authorization result. Return to the login page and start quick sign-in again.', code: 'Code', state: 'State', fullUrl: 'Full URL' diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index a37a9786..7b4d1866 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -471,6 +471,9 @@ export default { completing: '正在完成注册...', completeRegistrationFailed: '注册失败,请检查邀请码后重试。' }, + emailOAuth: { + signIn: '使用 {providerName} 登录' + }, oidc: { signIn: '使用 {providerName} 登录', callbackTitle: '正在完成 {providerName} 登录', @@ -529,6 +532,8 @@ export default { oauth: { callbackTitle: 'OAuth 回调', callbackHint: '按需将授权码和状态值复制回后台授权流程。', + invalidCallbackTitle: '无效的登录回调', + invalidCallbackHint: '当前页面缺少有效的授权结果,请返回登录页重新发起快捷登录。', code: '授权码', state: '状态', fullUrl: '完整URL' diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 238f6a71..bee533a5 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -68,6 +68,7 @@ const routes: RouteRecordRaw[] = [ { path: '/auth/callback', name: 'OAuthCallback', + alias: '/auth/oauth/callback', component: () => import('@/views/auth/OAuthCallbackView.vue'), meta: { requiresAuth: false, diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index 876ab5c0..fc06f6a7 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -347,6 +347,8 @@ export const useAppStore = defineStore('app', () => { wechat_oauth_mobile_enabled: false, oidc_oauth_enabled: false, oidc_oauth_provider_name: 'OIDC', + github_oauth_enabled: false, + google_oauth_enabled: false, backend_mode_enabled: false, version: siteVersion.value, balance_low_notify_enabled: false, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 79530c99..6f0e9961 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -34,7 +34,7 @@ export interface NotifyEmailEntry { // ==================== User & Auth Types ==================== -export type UserAuthProvider = 'email' | 'linuxdo' | 'oidc' | 'wechat' +export type UserAuthProvider = 'email' | 'linuxdo' | 'oidc' | 'wechat' | 'github' | 'google' export interface UserAuthBindingStatus { bound?: boolean @@ -208,6 +208,8 @@ export interface PublicSettings { wechat_oauth_mobile_enabled?: boolean oidc_oauth_enabled: boolean oidc_oauth_provider_name: string + github_oauth_enabled: boolean + google_oauth_enabled: boolean backend_mode_enabled: boolean version: string balance_low_notify_enabled: boolean diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index e32dd30e..cc980442 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1752,6 +1752,232 @@
+ +
+
+

+ {{ localText("邮箱快捷登录", "Email OAuth Sign-in") }} +

+

+ {{ + localText( + "开启 GitHub 或 Google 邮箱授权登录后,系统会读取已验证邮箱,存在则直接登录,不存在则自动注册。", + "After GitHub or Google email OAuth is enabled, the system reads a verified email, signs in matching users, and auto-registers missing users.", + ) + }} +

+
+
+
+
+
+
+

+ GitHub +

+

+ {{ + localText( + "GitHub OAuth App 需要 read:user user:email 权限,回调地址填写下方后端地址。", + "GitHub OAuth App needs read:user user:email scopes. Use the backend callback URL below.", + ) + }} +

+
+ +
+ +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + + {{ githubOAuthRedirectUrlSuggestion }} + +
+
+ +
+ + +
+
+
+ +
+
+
+

+ Google +

+

+ {{ + localText( + "Google OAuth 客户端需要 openid email profile 范围,并在凭据里登记后端回调地址。", + "Google OAuth client needs openid email profile scopes and the backend callback URL registered in credentials.", + ) + }} +

+
+ +
+ +
+
+ {{ + localText( + "开通引导:Google Cloud Console → APIs & Services → OAuth consent screen 完成同意屏幕;Credentials → Create Credentials → OAuth client ID,类型选择 Web application,并把下面地址加入 Authorized redirect URIs。", + "Setup guide: Google Cloud Console → APIs & Services → OAuth consent screen, then Credentials → Create Credentials → OAuth client ID, choose Web application, and add the URL below to Authorized redirect URIs.", + ) + }} +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + + {{ googleOAuthRedirectUrlSuggestion }} + +
+
+ +
+ + +
+
+
+
+
+
+
locale.value.startsWith("zh")); function localText(zh: string, en: string): string { - return locale.value.startsWith("zh") ? zh : en; + return isZhLocale.value ? zh : en; } const paymentGuideHref = computed(() => @@ -5796,6 +6023,8 @@ type SettingsForm = Omit< wechat_connect_mp_enabled: boolean; wechat_connect_mobile_enabled: boolean; oidc_connect_client_secret: string; + github_oauth_client_secret: string; + google_oauth_client_secret: string; force_email_on_third_party_signup: boolean; openai_advanced_scheduler_enabled: boolean; }; @@ -5926,6 +6155,19 @@ const form = reactive({ oidc_connect_userinfo_email_path: "", oidc_connect_userinfo_id_path: "", oidc_connect_userinfo_username_path: "", + // GitHub / Google 邮箱快捷登录 + github_oauth_enabled: false, + github_oauth_client_id: "", + github_oauth_client_secret: "", + github_oauth_client_secret_configured: false, + github_oauth_redirect_url: "", + github_oauth_frontend_redirect_url: "/auth/oauth/callback", + google_oauth_enabled: false, + google_oauth_client_id: "", + google_oauth_client_secret: "", + google_oauth_client_secret_configured: false, + google_oauth_redirect_url: "", + google_oauth_frontend_redirect_url: "/auth/oauth/callback", // Model fallback enable_model_fallback: false, fallback_model_anthropic: "claude-3-5-sonnet-20241022", @@ -5991,6 +6233,22 @@ const authSourceDefaultsMeta = computed(() => [ title: t("admin.settings.authSourceDefaults.sources.wechat.title"), description: t("admin.settings.authSourceDefaults.sources.wechat.description"), }, + { + source: "github" as AuthSourceType, + title: "GitHub", + description: localText( + "通过 GitHub 已验证邮箱首次注册或首次绑定时应用。", + "Applied on first signup or first bind through a verified GitHub email.", + ), + }, + { + source: "google" as AuthSourceType, + title: "Google", + description: localText( + "通过 Google 已验证邮箱首次注册或首次绑定时应用。", + "Applied on first signup or first bind through a verified Google email.", + ), + }, ]); // Proxies for web search emulation ProxySelector @@ -6298,6 +6556,42 @@ async function setAndCopyLinuxdoRedirectUrl() { ); } +type EmailOAuthProvider = "github" | "google"; + +const githubOAuthRedirectUrlSuggestion = computed(() => { + if (typeof window === "undefined") return ""; + const origin = + window.location.origin || + `${window.location.protocol}//${window.location.host}`; + return `${origin}/api/v1/auth/oauth/github/callback`; +}); + +const googleOAuthRedirectUrlSuggestion = computed(() => { + if (typeof window === "undefined") return ""; + const origin = + window.location.origin || + `${window.location.protocol}//${window.location.host}`; + return `${origin}/api/v1/auth/oauth/google/callback`; +}); + +async function setAndCopyEmailOAuthRedirectUrl(provider: EmailOAuthProvider) { + const url = + provider === "github" + ? githubOAuthRedirectUrlSuggestion.value + : googleOAuthRedirectUrlSuggestion.value; + if (!url) return; + + if (provider === "github") { + form.github_oauth_redirect_url = url; + } else { + form.google_oauth_redirect_url = url; + } + await copyToClipboard( + url, + localText("回调地址已写入并复制。", "Callback URL set and copied."), + ); +} + const wechatRedirectUrlSuggestion = computed(() => { if (typeof window === "undefined") return ""; const origin = @@ -6488,6 +6782,8 @@ async function loadSettings() { smtpPasswordManuallyEdited.value = false; form.turnstile_secret_key = ""; form.linuxdo_connect_client_secret = ""; + form.github_oauth_client_secret = ""; + form.google_oauth_client_secret = ""; form.wechat_connect_app_secret = ""; form.wechat_connect_open_app_secret = ""; form.wechat_connect_mp_app_secret = ""; @@ -6846,6 +7142,20 @@ async function saveSettings() { oidc_connect_userinfo_id_path: form.oidc_connect_userinfo_id_path, oidc_connect_userinfo_username_path: form.oidc_connect_userinfo_username_path, + github_oauth_enabled: form.github_oauth_enabled, + github_oauth_client_id: form.github_oauth_client_id, + github_oauth_client_secret: + form.github_oauth_client_secret || undefined, + github_oauth_redirect_url: form.github_oauth_redirect_url, + github_oauth_frontend_redirect_url: + form.github_oauth_frontend_redirect_url, + google_oauth_enabled: form.google_oauth_enabled, + google_oauth_client_id: form.google_oauth_client_id, + google_oauth_client_secret: + form.google_oauth_client_secret || undefined, + google_oauth_redirect_url: form.google_oauth_redirect_url, + google_oauth_frontend_redirect_url: + form.google_oauth_frontend_redirect_url, enable_model_fallback: form.enable_model_fallback, fallback_model_anthropic: form.fallback_model_anthropic, fallback_model_openai: form.fallback_model_openai, @@ -6960,6 +7270,8 @@ async function saveSettings() { smtpPasswordManuallyEdited.value = false; form.turnstile_secret_key = ""; form.linuxdo_connect_client_secret = ""; + form.github_oauth_client_secret = ""; + form.google_oauth_client_secret = ""; form.wechat_connect_app_secret = ""; form.wechat_connect_open_app_secret = ""; form.wechat_connect_mp_app_secret = ""; diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index bfd1861f..915d9425 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -817,6 +817,24 @@ describe("admin SettingsView wechat connect controls", () => { ).toBe("/auth/wechat/callback"); }); + it("links GitHub OAuth Apps guide to GitHub developer settings", async () => { + getSettings.mockResolvedValueOnce({ + ...baseSettingsResponse, + github_oauth_enabled: true, + }); + + const wrapper = mountView(); + + await flushPromises(); + await openSecurityTab(wrapper); + + const link = wrapper.get('[data-testid="github-oauth-apps-guide-link"]'); + expect(link.text()).toContain("OAuth Apps"); + expect(link.attributes("href")).toBe("https://github.com/settings/developers"); + expect(link.attributes("target")).toBe("_blank"); + expect(link.attributes("rel")).toContain("noopener"); + }); + it("saves WeChat Connect fields using the backend contract and clears the secret after save", async () => { const wrapper = mountView(); diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue index 78ba4b9d..7d3846ef 100644 --- a/frontend/src/views/auth/LoginView.vue +++ b/frontend/src/views/auth/LoginView.vue @@ -10,33 +10,6 @@ {{ t('auth.signInToAccount') }}

- -
- - - -
-
- - {{ t('auth.oauthOrContinue') }} - -
-
-
-
@@ -144,6 +117,40 @@ {{ isLoading ? t('auth.signingIn') : t('auth.signIn') }} + +
+
+
+ + {{ t('auth.oauthOrContinue') }} + +
+
+ + + + + + +
@@ -180,6 +187,7 @@ import { AuthLayout } from '@/components/layout' import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue' import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue' import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue' +import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue' import TotpLoginModal from '@/components/auth/TotpLoginModal.vue' import Icon from '@/components/icons/Icon.vue' import TurnstileWidget from '@/components/TurnstileWidget.vue' @@ -210,6 +218,8 @@ const wechatOAuthEnabled = ref(false) const backendModeEnabled = ref(false) const oidcOAuthEnabled = ref(false) const oidcOAuthProviderName = ref('OIDC') +const githubOAuthEnabled = ref(false) +const googleOAuthEnabled = ref(false) const passwordResetEnabled = ref(false) // Turnstile @@ -237,6 +247,16 @@ const validationToastMessage = computed( () => errors.email || errors.password || errors.turnstile || '' ) +const showOAuthLogin = computed( + () => + !backendModeEnabled.value && + (linuxdoOAuthEnabled.value || + wechatOAuthEnabled.value || + oidcOAuthEnabled.value || + githubOAuthEnabled.value || + googleOAuthEnabled.value) +) + watch(validationToastMessage, (value, previousValue) => { if (value && value !== previousValue) { appStore.showError(value) @@ -263,6 +283,8 @@ onMounted(async () => { backendModeEnabled.value = settings.backend_mode_enabled oidcOAuthEnabled.value = settings.oidc_oauth_enabled oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC' + githubOAuthEnabled.value = settings.github_oauth_enabled + googleOAuthEnabled.value = settings.google_oauth_enabled backendModeEnabled.value = settings.backend_mode_enabled passwordResetEnabled.value = settings.password_reset_enabled } catch (error) { diff --git a/frontend/src/views/auth/OAuthCallbackView.vue b/frontend/src/views/auth/OAuthCallbackView.vue index 40b29c57..1bc617b7 100644 --- a/frontend/src/views/auth/OAuthCallbackView.vue +++ b/frontend/src/views/auth/OAuthCallbackView.vue @@ -1,7 +1,60 @@ diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index e32dd30e..e47392a4 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -4264,6 +4264,39 @@
+
+
+

+ {{ t('admin.settings.features.riskControl.title') }} +

+

+ {{ t('admin.settings.features.riskControl.description') }} +

+

+ + {{ t('admin.settings.features.riskControl.configureLink') }} + + +

+
+
+
+
+ +

+ {{ t('admin.settings.features.riskControl.enabledHint') }} +

+
+ +
+
+
+
@@ -5828,6 +5861,7 @@ const form = reactive({ backend_mode_enabled: false, hide_ccs_import_button: false, payment_enabled: false, + risk_control_enabled: false, payment_min_amount: 1, payment_max_amount: 10000, payment_daily_limit: 50000, @@ -6863,6 +6897,7 @@ async function saveSettings() { form.enable_anthropic_cache_ttl_1h_injection, // Payment configuration payment_enabled: form.payment_enabled, + risk_control_enabled: form.risk_control_enabled, payment_min_amount: Number(form.payment_min_amount) || 0, payment_max_amount: Number(form.payment_max_amount) || 0, payment_daily_limit: Number(form.payment_daily_limit) || 0, diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue index 78ba4b9d..3601c666 100644 --- a/frontend/src/views/auth/LoginView.vue +++ b/frontend/src/views/auth/LoginView.vue @@ -186,6 +186,7 @@ import TurnstileWidget from '@/components/TurnstileWidget.vue' import { useAuthStore, useAppStore } from '@/stores' import { getPublicSettings, isTotp2FARequired, isWeChatWebOAuthEnabled } from '@/api/auth' import type { TotpLoginResponse } from '@/types' +import { extractI18nErrorMessage } from '@/utils/apiError' import { clearAllAffiliateReferralCodes } from '@/utils/oauthAffiliate' const { t } = useI18n() @@ -369,16 +370,7 @@ async function handleLogin(): Promise { turnstileToken.value = '' } - // Handle login error - const err = error as { message?: string; response?: { data?: { detail?: string } } } - - if (err.response?.data?.detail) { - errorMessage.value = err.response.data.detail - } else if (err.message) { - errorMessage.value = err.message - } else { - errorMessage.value = t('auth.loginFailed') - } + errorMessage.value = extractI18nErrorMessage(error, t, 'auth.errors', t('auth.loginFailed')) // Also show error toast appStore.showError(errorMessage.value) From 989f87fe086b88830580847803f875aa2c9776bb Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 7 May 2026 09:46:45 +0800 Subject: [PATCH 13/25] fix: harden markdown page image paths --- backend/internal/handler/page_handler.go | 84 +++++++++++++-- backend/internal/handler/page_handler_test.go | 102 ++++++++++++++++++ .../service/content_moderation_test.go | 8 ++ frontend/src/views/user/CustomPageView.vue | 27 ++++- 4 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 backend/internal/handler/page_handler_test.go diff --git a/backend/internal/handler/page_handler.go b/backend/internal/handler/page_handler.go index a3e4f5d2..7d4d5078 100644 --- a/backend/internal/handler/page_handler.go +++ b/backend/internal/handler/page_handler.go @@ -3,6 +3,7 @@ package handler import ( "encoding/json" "net/http" + "net/url" "os" "path/filepath" "regexp" @@ -111,15 +112,9 @@ func (h *PageHandler) ServePageImage(c *gin.Context) { return } - if filename == "" || strings.Contains(filename, "..") || strings.Contains(filename, "/") || strings.Contains(filename, "\\") { - c.Status(http.StatusNotFound) - return - } - imagesDir := filepath.Join(h.pagesDir, slug) - filePath := filepath.Join(imagesDir, filename) - cleaned := filepath.Clean(filePath) - if !strings.HasPrefix(cleaned, filepath.Clean(imagesDir)) { + cleaned, ok := resolvePageImagePath(h.pagesDir, imagesDir, filename) + if !ok { c.Status(http.StatusNotFound) return } @@ -133,6 +128,79 @@ func (h *PageHandler) ServePageImage(c *gin.Context) { c.File(cleaned) } +func resolvePageImagePath(pagesDir, imagesDir, filename string) (string, bool) { + relPath, ok := cleanPageImageRelativePath(filename) + if !ok { + return "", false + } + + cleanedPagesDir := filepath.Clean(pagesDir) + cleanedImagesDir := filepath.Clean(imagesDir) + cleanedTarget := filepath.Clean(filepath.Join(cleanedImagesDir, relPath)) + if !isPathWithinBase(cleanedTarget, cleanedImagesDir) { + return "", false + } + + realPagesDir, err := filepath.EvalSymlinks(cleanedPagesDir) + if err != nil { + return "", false + } + realImagesDir, err := filepath.EvalSymlinks(cleanedImagesDir) + if err != nil || !isPathWithinBase(realImagesDir, realPagesDir) { + return "", false + } + realTarget, err := filepath.EvalSymlinks(cleanedTarget) + if err != nil || !isPathWithinBase(realTarget, realImagesDir) { + return "", false + } + return realTarget, true +} + +func cleanPageImageRelativePath(filename string) (string, bool) { + if filename == "" { + return "", false + } + if strings.HasPrefix(filename, "/") { + return "", false + } + decoded, err := url.PathUnescape(filename) + if err != nil { + return "", false + } + if decoded == "" || strings.HasPrefix(decoded, "/") || strings.Contains(decoded, "\\") || strings.ContainsRune(decoded, 0) { + return "", false + } + + parts := make([]string, 0) + for _, part := range strings.Split(decoded, "/") { + switch part { + case "", ".": + continue + case "..": + return "", false + default: + parts = append(parts, part) + } + } + if len(parts) == 0 { + return "", false + } + + relPath := filepath.Join(parts...) + if filepath.IsAbs(relPath) || filepath.VolumeName(relPath) != "" { + return "", false + } + return relPath, true +} + +func isPathWithinBase(path, base string) bool { + rel, err := filepath.Rel(filepath.Clean(base), filepath.Clean(path)) + if err != nil { + return false + } + return rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + // findSlugVisibility looks up the slug in custom_menu_items and returns (visibility, found). func (h *PageHandler) findSlugVisibility(c *gin.Context, slug string) (string, bool) { if h.settingService == nil { diff --git a/backend/internal/handler/page_handler_test.go b/backend/internal/handler/page_handler_test.go new file mode 100644 index 00000000..0a9f0d96 --- /dev/null +++ b/backend/internal/handler/page_handler_test.go @@ -0,0 +1,102 @@ +package handler + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCleanPageImageRelativePath(t *testing.T) { + tests := []struct { + name string + in string + want string + ok bool + }{ + {name: "single filename", in: "logo.png", want: "logo.png", ok: true}, + {name: "nested path", in: "images/logo.png", want: filepath.Join("images", "logo.png"), ok: true}, + {name: "dot prefix", in: "./logo.png", want: "logo.png", ok: true}, + {name: "url escaped slash", in: "images%2Flogo.png", want: filepath.Join("images", "logo.png"), ok: true}, + {name: "parent traversal", in: "../secret.png", ok: false}, + {name: "encoded parent traversal", in: "%2e%2e/secret.png", ok: false}, + {name: "backslash traversal", in: `images\secret.png`, ok: false}, + {name: "absolute path", in: "/etc/passwd", ok: false}, + {name: "encoded absolute path", in: "%2fetc/passwd", ok: false}, + {name: "encoded nul byte", in: "logo.png%00", ok: false}, + {name: "invalid escape", in: "logo.png%zz", ok: false}, + {name: "empty path", in: "", ok: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := cleanPageImageRelativePath(tt.in) + if ok != tt.ok { + t.Fatalf("ok = %v, want %v", ok, tt.ok) + } + if got != tt.want { + t.Fatalf("path = %q, want %q", got, tt.want) + } + }) + } +} + +func TestResolvePageImagePath(t *testing.T) { + root := t.TempDir() + pagesDir := filepath.Join(root, "pages") + base := filepath.Join(pagesDir, "guide") + if err := os.MkdirAll(filepath.Join(base, "images"), 0755); err != nil { + t.Fatalf("create images dir: %v", err) + } + if err := os.WriteFile(filepath.Join(base, "logo.png"), []byte("fake"), 0644); err != nil { + t.Fatalf("create direct image: %v", err) + } + if err := os.WriteFile(filepath.Join(base, "images", "logo.png"), []byte("fake"), 0644); err != nil { + t.Fatalf("create image: %v", err) + } + + got, ok := resolvePageImagePath(pagesDir, base, "logo.png") + if !ok { + t.Fatal("expected direct image path to be accepted") + } + want := filepath.Join(base, "logo.png") + if got != want { + t.Fatalf("path = %q, want %q", got, want) + } + + got, ok = resolvePageImagePath(pagesDir, base, "images/logo.png") + if !ok { + t.Fatal("expected nested image path to be accepted") + } + want = filepath.Join(base, "images", "logo.png") + if got != want { + t.Fatalf("path = %q, want %q", got, want) + } + + if got, ok := resolvePageImagePath(pagesDir, base, "../guide.md"); ok { + t.Fatalf("expected traversal to be rejected, got %q", got) + } +} + +func TestResolvePageImagePathRejectsSymlinkEscape(t *testing.T) { + root := t.TempDir() + pagesDir := filepath.Join(root, "pages") + base := filepath.Join(pagesDir, "guide") + outside := filepath.Join(root, "outside") + + if err := os.MkdirAll(base, 0755); err != nil { + t.Fatalf("create page dir: %v", err) + } + if err := os.MkdirAll(outside, 0755); err != nil { + t.Fatalf("create outside dir: %v", err) + } + if err := os.WriteFile(filepath.Join(outside, "secret.png"), []byte("secret"), 0644); err != nil { + t.Fatalf("create outside file: %v", err) + } + if err := os.Symlink(outside, filepath.Join(base, "images")); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + if got, ok := resolvePageImagePath(pagesDir, base, "images/secret.png"); ok { + t.Fatalf("expected symlink escape to be rejected, got %q", got) + } +} diff --git a/backend/internal/service/content_moderation_test.go b/backend/internal/service/content_moderation_test.go index 0c1a39c5..cc888f28 100644 --- a/backend/internal/service/content_moderation_test.go +++ b/backend/internal/service/content_moderation_test.go @@ -187,6 +187,14 @@ func (r *contentModerationTestUserRepo) UpdateConcurrency(ctx context.Context, i panic("unexpected UpdateConcurrency call") } +func (r *contentModerationTestUserRepo) BatchSetConcurrency(ctx context.Context, userIDs []int64, value int) (int, error) { + panic("unexpected BatchSetConcurrency call") +} + +func (r *contentModerationTestUserRepo) BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error) { + panic("unexpected BatchAddConcurrency call") +} + func (r *contentModerationTestUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) { panic("unexpected ExistsByEmail call") } diff --git a/frontend/src/views/user/CustomPageView.vue b/frontend/src/views/user/CustomPageView.vue index 0752d5e3..59764c63 100644 --- a/frontend/src/views/user/CustomPageView.vue +++ b/frontend/src/views/user/CustomPageView.vue @@ -197,6 +197,29 @@ function generateHeadingId(text: string, index: number): string { return base ? `${base}-${index}` : `heading-${index}` } +function isRelativeMarkdownAsset(src: string): boolean { + const trimmed = src.trim() + if (!trimmed || /^[a-z][a-z0-9+.-]*:/i.test(trimmed) || trimmed.startsWith('//') || trimmed.startsWith('/')) { + return false + } + const [pathPart] = trimmed.split(/([?#].*)/, 2) + return pathPart + .split('/') + .filter((part) => part && part !== '.') + .every((part) => part !== '..' && !part.includes('\\')) +} + +function buildPageImageUrl(slug: string, src: string): string { + const trimmed = src.trim() + const [pathPart, suffix = ''] = trimmed.split(/([?#].*)/, 2) + const encodedPath = pathPart + .split('/') + .filter((part) => part && part !== '.') + .map((part) => encodeURIComponent(part)) + .join('/') + return `/api/v1/pages/${encodeURIComponent(slug)}/images/${encodedPath}${suffix}` +} + async function fetchAndRenderMarkdown(slug: string) { loading.value = true tocItems.value = [] @@ -212,8 +235,8 @@ async function fetchAndRenderMarkdown(slug: string) { let raw = await resp.text() raw = raw.replace( - /!\[([^\]]*)\]\((?!https?:\/\/)([^)]+)\)/g, - (_, alt, src) => `![${alt}](/api/v1/pages/${slug}/images/${src})` + /!\[([^\]]*)\]\(([^)]+)\)/g, + (match, alt, src) => isRelativeMarkdownAsset(src) ? `![${alt}](${buildPageImageUrl(slug, src)})` : match ) const html = marked.parse(raw) as string From 501b7f2772b9d85a73c631cfa191ec1173dce946 Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 7 May 2026 10:24:29 +0800 Subject: [PATCH 14/25] fix: stabilize anthropic passthrough timeout error --- backend/internal/service/gateway_service.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index b9bd992e..3a003bd2 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -5343,6 +5343,12 @@ func (s *GatewayService) handleStreamingResponseAnthropicAPIKeyPassthrough( flusher.Flush() } if !sawTerminalEvent { + if clientDisconnected && streamInterval > 0 { + lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) + if time.Since(lastRead) >= streamInterval { + return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, fmt.Errorf("stream usage incomplete after timeout") + } + } return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: clientDisconnected}, fmt.Errorf("stream usage incomplete: missing terminal event") } return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: clientDisconnected}, nil From 7a9c1d7edde466db4aae24d934633d38afb84d2c Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 7 May 2026 11:07:33 +0800 Subject: [PATCH 15/25] feat(frontend): add account Codex image bridge control --- .../components/account/EditAccountModal.vue | 121 +++++++++++++++++- .../__tests__/EditAccountModal.spec.ts | 21 +++ frontend/src/i18n/locales/en.ts | 15 ++- frontend/src/i18n/locales/zh.ts | 15 ++- frontend/src/views/admin/AccountsView.vue | 86 ++++++++----- 5 files changed, 220 insertions(+), 38 deletions(-) diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 56874474..80f0b890 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1317,6 +1317,66 @@
+ +
+
+
+
+ +
+
+
+ + + {{ codexImageGenerationBridgeBadgeLabel }} + +
+

+ {{ t('admin.accounts.openai.codexImageGenerationBridgeDesc') }} +

+
+
+
+
+ +
+
+
+
+
('auto') const openaiOAuthResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF) const openaiAPIKeyResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF) const codexCLIOnlyEnabled = ref(false) +type CodexImageGenerationBridgeMode = 'inherit' | 'enabled' | 'disabled' +const codexImageGenerationBridgeMode = ref('inherit') const anthropicPassthroughEnabled = ref(false) const webSearchEmulationMode = ref('default') const webSearchGlobalEnabled = ref(false) @@ -2325,6 +2387,47 @@ const openaiResponsesWebSocketV2Mode = computed({ const openAIWSModeConcurrencyHintKey = computed(() => resolveOpenAIWSModeConcurrencyHintKey(openaiResponsesWebSocketV2Mode.value) ) +const codexImageGenerationBridgeOptions = computed>(() => [ + { + value: 'inherit', + label: t('admin.accounts.openai.codexImageGenerationBridgeInherit'), + description: t('admin.accounts.openai.codexImageGenerationBridgeInheritDesc') + }, + { + value: 'enabled', + label: t('admin.accounts.openai.codexImageGenerationBridgeEnabled'), + description: t('admin.accounts.openai.codexImageGenerationBridgeEnabledDesc') + }, + { + value: 'disabled', + label: t('admin.accounts.openai.codexImageGenerationBridgeDisabled'), + description: t('admin.accounts.openai.codexImageGenerationBridgeDisabledDesc') + } +]) +const codexImageGenerationBridgeBadgeLabel = computed(() => { + switch (codexImageGenerationBridgeMode.value) { + case 'enabled': + return t('admin.accounts.openai.codexImageGenerationBridgeBadgeEnabled') + case 'disabled': + return t('admin.accounts.openai.codexImageGenerationBridgeBadgeDisabled') + default: + return t('admin.accounts.openai.codexImageGenerationBridgeBadgeInherit') + } +}) +const codexImageGenerationBridgeBadgeClass = computed(() => { + switch (codexImageGenerationBridgeMode.value) { + case 'enabled': + return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300' + case 'disabled': + return 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300' + default: + return 'bg-slate-100 text-slate-600 dark:bg-dark-600 dark:text-slate-300' + } +}) const openAICompactModeOptions = computed(() => [ { value: 'auto', label: t('admin.accounts.openai.compactModeAuto') }, { value: 'force_on', label: t('admin.accounts.openai.compactModeForceOn') }, @@ -2344,7 +2447,7 @@ const openAICompactStatusKey = computed(() => { ? 'admin.accounts.openai.compactSupported' : 'admin.accounts.openai.compactUnsupported' } - return 'admin.accounts.openai.compactUnknown' + return 'admin.accounts.openai.compactAuto' }) // Computed: current preset mappings based on platform @@ -2483,11 +2586,20 @@ const syncFormFromAccount = (newAccount: Account | null) => { openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF codexCLIOnlyEnabled.value = false + codexImageGenerationBridgeMode.value = 'inherit' anthropicPassthroughEnabled.value = false webSearchEmulationMode.value = 'default' if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) { openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true openAICompactMode.value = (extra?.openai_compact_mode as OpenAICompactMode) || 'auto' + const codexImageGenerationBridgeValue = typeof extra?.codex_image_generation_bridge === 'boolean' + ? extra.codex_image_generation_bridge + : extra?.codex_image_generation_bridge_enabled + if (codexImageGenerationBridgeValue === true) { + codexImageGenerationBridgeMode.value = 'enabled' + } else if (codexImageGenerationBridgeValue === false) { + codexImageGenerationBridgeMode.value = 'disabled' + } openaiOAuthResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, { modeKey: 'openai_oauth_responses_websockets_v2_mode', enabledKey: 'openai_oauth_responses_websockets_v2_enabled', @@ -3610,6 +3722,13 @@ const handleSubmit = async () => { newExtra.openai_compact_mode = openAICompactMode.value } + delete newExtra.codex_image_generation_bridge_enabled + if (codexImageGenerationBridgeMode.value === 'inherit') { + delete newExtra.codex_image_generation_bridge + } else { + newExtra.codex_image_generation_bridge = codexImageGenerationBridgeMode.value === 'enabled' + } + if (props.account.type === 'oauth') { if (codexCLIOnlyEnabled.value) { newExtra.codex_cli_only = true diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index c4e2a9bc..04486154 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -216,4 +216,25 @@ describe('EditAccountModal', () => { 'gpt-5.4': 'gpt-5.4-openai-compact' }) }) + + it('submits account-level Codex image generation bridge override', async () => { + const account = buildAccount() + account.extra = { + codex_image_generation_bridge: false, + codex_image_generation_bridge_enabled: true + } + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + + await wrapper.get('button[data-testid="codex-image-bridge-enabled"]').trigger('click') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.codex_image_generation_bridge).toBe(true) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('codex_image_generation_bridge_enabled') + }) }) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index ca00f622..743f9415 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3136,6 +3136,18 @@ export default { codexCLIOnly: 'Codex official clients only', codexCLIOnlyDesc: 'Only applies to OpenAI OAuth. When enabled, only Codex official client families are allowed; when disabled, the gateway bypasses this restriction and keeps existing behavior.', + codexImageGenerationBridge: 'Codex image-generation bridge', + codexImageGenerationBridgeDesc: + 'Account policy takes precedence over channel and global settings. Only controls whether Codex requests through the /responses text endpoint receive the image_generation tool; standalone image-generation endpoints are unaffected.', + codexImageGenerationBridgeInherit: 'Follow channel', + codexImageGenerationBridgeInheritDesc: 'Do not write an account override; use the channel or global policy.', + codexImageGenerationBridgeEnabled: 'Force on', + codexImageGenerationBridgeEnabledDesc: 'Allow image tool injection for Codex /responses requests.', + codexImageGenerationBridgeDisabled: 'Force off', + codexImageGenerationBridgeDisabledDesc: 'Block image tool injection for Codex /responses requests.', + codexImageGenerationBridgeBadgeInherit: 'Channel policy', + codexImageGenerationBridgeBadgeEnabled: 'Account on', + codexImageGenerationBridgeBadgeDisabled: 'Account off', compactMode: 'Compact mode', compactModeDesc: 'Controls how this account participates in /responses/compact routing. Auto follows probe results, Force On always allows, Force Off always excludes.', @@ -3147,7 +3159,8 @@ export default { 'Only applies to /responses/compact. Use this when the upstream compact endpoint requires a special compact model.', compactSupported: 'Compact supported', compactUnsupported: 'Compact unsupported', - compactUnknown: 'Compact unknown', + compactAuto: 'Compact Auto', + compactUnknown: 'Compact Auto', compactLastChecked: 'Last compact probe', testMode: 'Test mode', testModeDefault: 'Default request', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 8b4f32f8..246f9832 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -3281,6 +3281,18 @@ export default { responsesWebsocketsV2PassthroughHint: '当前已开启自动透传:仅影响 HTTP 透传链路,不影响 WS mode。', codexCLIOnly: '仅允许 Codex 官方客户端', codexCLIOnlyDesc: '仅对 OpenAI OAuth 生效。开启后仅允许 Codex 官方客户端家族访问;关闭后完全绕过并保持原逻辑。', + codexImageGenerationBridge: 'Codex 图片生成桥接', + codexImageGenerationBridgeDesc: + '账号级策略优先于渠道和全局配置。仅控制 Codex 走 /responses 文本端点时是否注入 image_generation 工具;不影响独立图片生成接口。', + codexImageGenerationBridgeInherit: '跟随渠道', + codexImageGenerationBridgeInheritDesc: '不写入账号覆盖,继续使用渠道或全局策略。', + codexImageGenerationBridgeEnabled: '强制开启', + codexImageGenerationBridgeEnabledDesc: '允许 Codex /responses 请求获得图片工具注入。', + codexImageGenerationBridgeDisabled: '强制关闭', + codexImageGenerationBridgeDisabledDesc: '阻断 Codex /responses 的图片工具注入。', + codexImageGenerationBridgeBadgeInherit: '渠道策略', + codexImageGenerationBridgeBadgeEnabled: '账号开启', + codexImageGenerationBridgeBadgeDisabled: '账号关闭', compactMode: 'Compact 模式', compactModeDesc: '控制本账号在 /responses/compact 调度中的参与方式。Auto 跟随探测结果,Force On 强制允许,Force Off 强制排除。', @@ -3292,7 +3304,8 @@ export default { '仅在 /responses/compact 请求中生效。当上游 compact 端点需要特殊 compact 模型时使用。', compactSupported: '支持 Compact', compactUnsupported: '不支持 Compact', - compactUnknown: 'Compact 未知', + compactAuto: 'Compact Auto', + compactUnknown: 'Compact Auto', compactLastChecked: '最近探测', testMode: '测试模式', testModeDefault: '常规请求', diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 126e4a61..04c376cc 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -196,21 +196,27 @@ - + + + + From 57fd7998d369111ec561acbd20a92cf6f6ab380c Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 7 May 2026 18:56:11 +0800 Subject: [PATCH 20/25] fix(gateway): stop default redact thinking beta injection --- backend/internal/pkg/claude/constants.go | 2 +- backend/internal/service/gateway_beta_test.go | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index aa59ba64..351f2f8b 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -75,6 +75,7 @@ const CLICurrentVersion = "2.1.92" // - OAuth 账号 + 非 haiku:追加这整份列表,再按需保留 client 带来的 beta。 // - OAuth 账号 + haiku:Anthropic 对 haiku 不做 third-party 判定,使用 HaikuBetaHeader 即可。 // - API-key 账号:不要使用本函数,参见 APIKeyBetaHeader。 +// - 不默认加入 redact-thinking,避免上游抹除 thinking 内容;客户端显式传入时由合并逻辑保留。 func FullClaudeCodeMimicryBetas() []string { return []string{ BetaClaudeCode, @@ -82,7 +83,6 @@ func FullClaudeCodeMimicryBetas() []string { BetaInterleavedThinking, BetaPromptCachingScope, BetaEffort, - BetaRedactThinking, BetaContextManagement, BetaExtendedCacheTTL, } diff --git a/backend/internal/service/gateway_beta_test.go b/backend/internal/service/gateway_beta_test.go index ecaffe21..6919c148 100644 --- a/backend/internal/service/gateway_beta_test.go +++ b/backend/internal/service/gateway_beta_test.go @@ -124,6 +124,24 @@ func TestMergeAnthropicBetaDropping_DroppedBetas(t *testing.T) { require.Contains(t, got, "fast-mode-2026-02-01") } +func TestFullClaudeCodeMimicryBetas_DoesNotDefaultRedactThinking(t *testing.T) { + required := claude.FullClaudeCodeMimicryBetas() + + require.NotContains(t, required, claude.BetaRedactThinking) + require.Contains(t, required, claude.BetaClaudeCode) + require.Contains(t, required, claude.BetaOAuth) + require.Contains(t, required, claude.BetaInterleavedThinking) +} + +func TestMergeAnthropicBetaDropping_PreservesIncomingRedactThinking(t *testing.T) { + required := claude.FullClaudeCodeMimicryBetas() + incoming := claude.BetaRedactThinking + + got := mergeAnthropicBetaDropping(required, incoming, droppedBetaSet()) + + require.Contains(t, got, claude.BetaRedactThinking) +} + func TestDroppedBetaSet(t *testing.T) { // Base set contains DroppedBetas (now empty — filtering moved to configurable beta policy) base := droppedBetaSet() From 8a835b22bb9d629126127e8d2a42d9fc60928ff2 Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 7 May 2026 19:26:18 +0800 Subject: [PATCH 21/25] ci: fix lint and test failures --- backend/internal/server/api_contract_test.go | 42 ++++++++++++++------ backend/internal/service/setting_service.go | 6 +-- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index f516aa42..27358865 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -646,12 +646,21 @@ func TestAPIContracts(t *testing.T) { "registration_email_suffix_whitelist": [], "promo_code_enabled": true, "password_reset_enabled": false, - "frontend_url": "", - "totp_enabled": false, - "totp_encryption_key_configured": false, - "smtp_host": "smtp.example.com", - "smtp_port": 587, - "smtp_username": "user", + "frontend_url": "", + "totp_enabled": false, + "totp_encryption_key_configured": false, + "login_agreement_enabled": false, + "login_agreement_mode": "modal", + "login_agreement_updated_at": "2026-03-31", + "login_agreement_documents": [ + {"id": "terms", "title": "服务条款", "content_md": ""}, + {"id": "usage-policy", "title": "使用政策", "content_md": ""}, + {"id": "supported-regions", "title": "支持的国家和地区", "content_md": ""}, + {"id": "service-specific-terms", "title": "服务特定条款", "content_md": ""} + ], + "smtp_host": "smtp.example.com", + "smtp_port": 587, + "smtp_username": "user", "smtp_password_configured": true, "smtp_from_email": "no-reply@example.com", "smtp_from_name": "Sub2API", @@ -880,12 +889,21 @@ func TestAPIContracts(t *testing.T) { "promo_code_enabled": true, "password_reset_enabled": false, "frontend_url": "", - "invitation_code_enabled": false, - "totp_enabled": false, - "totp_encryption_key_configured": false, - "smtp_host": "", - "smtp_port": 587, - "smtp_username": "", + "invitation_code_enabled": false, + "totp_enabled": false, + "totp_encryption_key_configured": false, + "login_agreement_enabled": false, + "login_agreement_mode": "modal", + "login_agreement_updated_at": "2026-03-31", + "login_agreement_documents": [ + {"id": "terms", "title": "服务条款", "content_md": ""}, + {"id": "usage-policy", "title": "使用政策", "content_md": ""}, + {"id": "supported-regions", "title": "支持的国家和地区", "content_md": ""}, + {"id": "service-specific-terms", "title": "服务特定条款", "content_md": ""} + ], + "smtp_host": "", + "smtp_port": 587, + "smtp_username": "", "smtp_password_configured": false, "smtp_from_email": "", "smtp_from_name": "", diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 9dbbaa08..283a239b 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -249,16 +249,16 @@ func normalizeLoginAgreementDocumentID(raw string) string { lastSeparator := false for _, r := range raw { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { - b.WriteRune(r) + _, _ = b.WriteRune(r) lastSeparator = false continue } if r == '-' || r == '_' || r == ' ' || r == '.' || r == '/' { if !lastSeparator && b.Len() > 0 { if r == '_' { - b.WriteRune('_') + _, _ = b.WriteRune('_') } else { - b.WriteRune('-') + _, _ = b.WriteRune('-') } lastSeparator = true } From a466e80ed6e9f498108286203a8a9e3a2d75e58a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 11:42:30 +0000 Subject: [PATCH 22/25] chore: sync VERSION to 0.1.125 [skip ci] --- backend/cmd/server/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 98eb2715..5076ee80 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.124 +0.1.125 From fda1ed459d863fe883a091215da783a39e456faa Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 8 May 2026 11:36:09 +0800 Subject: [PATCH 23/25] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20OAuth=20?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E5=AF=BC=E5=85=A5=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/admin/account_codex_import.go | 1045 +++++++++++++++++ .../admin/account_codex_import_test.go | 344 ++++++ backend/internal/server/routes/admin.go | 1 + .../openai_oauth_service_refresh_test.go | 44 + .../internal/service/openai_token_provider.go | 6 + .../service/openai_token_provider_test.go | 30 +- backend/internal/service/token_refresher.go | 4 + frontend/src/api/admin/accounts.ts | 8 + .../components/account/CreateAccountModal.vue | 111 ++ .../account/OAuthAuthorizationFlow.vue | 114 +- .../admin/account/AccountTableActions.vue | 3 +- frontend/src/composables/useAccountOAuth.ts | 2 +- frontend/src/i18n/locales/en.ts | 15 + frontend/src/i18n/locales/zh.ts | 15 + frontend/src/types/index.ts | 45 + frontend/src/views/admin/AccountsView.vue | 187 ++- 16 files changed, 1900 insertions(+), 74 deletions(-) create mode 100644 backend/internal/handler/admin/account_codex_import.go create mode 100644 backend/internal/handler/admin/account_codex_import_test.go diff --git a/backend/internal/handler/admin/account_codex_import.go b/backend/internal/handler/admin/account_codex_import.go new file mode 100644 index 00000000..59fe30a0 --- /dev/null +++ b/backend/internal/handler/admin/account_codex_import.go @@ -0,0 +1,1045 @@ +package admin + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +const codexImportClockSkewSeconds int64 = 120 + +type CodexSessionImportRequest struct { + Content string `json:"content"` + Contents []string `json:"contents"` + Name string `json:"name"` + Notes *string `json:"notes"` + GroupIDs []int64 `json:"group_ids"` + ProxyID *int64 `json:"proxy_id"` + Concurrency *int `json:"concurrency"` + Priority *int `json:"priority"` + RateMultiplier *float64 `json:"rate_multiplier"` + LoadFactor *int `json:"load_factor"` + ExpiresAt *int64 `json:"expires_at"` + AutoPauseOnExpired *bool `json:"auto_pause_on_expired"` + CredentialExtras map[string]any `json:"credential_extras"` + Extra map[string]any `json:"extra"` + UpdateExisting *bool `json:"update_existing"` + SkipDefaultGroupBind *bool `json:"skip_default_group_bind"` + ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` +} + +type CodexSessionImportResult struct { + Total int `json:"total"` + Created int `json:"created"` + Updated int `json:"updated"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` + Items []CodexSessionImportItem `json:"items,omitempty"` + Warnings []CodexSessionImportMessage `json:"warnings,omitempty"` + Errors []CodexSessionImportMessage `json:"errors,omitempty"` +} + +type CodexSessionImportItem struct { + Index int `json:"index"` + Name string `json:"name,omitempty"` + Action string `json:"action"` + AccountID int64 `json:"account_id,omitempty"` + Message string `json:"message,omitempty"` +} + +type CodexSessionImportMessage struct { + Index int `json:"index"` + Name string `json:"name,omitempty"` + Message string `json:"message"` +} + +type codexImportEntry struct { + Index int + Value any +} + +type codexImportAccount struct { + Name string + AccessToken string + RefreshToken string + IDToken string + Email string + AccountID string + UserID string + PlanType string + Organization string + Credentials map[string]any + Extra map[string]any + TokenExpiresAt *time.Time + IdentityKeys []string + WarningTexts []string +} + +type codexJWTClaims struct { + Sub string `json:"sub"` + Email string `json:"email"` + Exp int64 `json:"exp"` + Iat int64 `json:"iat"` + OpenAIAuth *codexJWTOpenAIClaims `json:"https://api.openai.com/auth,omitempty"` +} + +type codexJWTOpenAIClaims struct { + ChatGPTAccountID string `json:"chatgpt_account_id"` + ChatGPTUserID string `json:"chatgpt_user_id"` + ChatGPTPlanType string `json:"chatgpt_plan_type"` + UserID string `json:"user_id"` + POID string `json:"poid"` + Organizations []openai.OrganizationClaim `json:"organizations"` +} + +type codexAccountIndex struct { + accountsByKey map[string]service.Account +} + +func (h *AccountHandler) ImportCodexSession(c *gin.Context) { + var req CodexSessionImportRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + if req.Concurrency != nil && *req.Concurrency < 0 { + response.BadRequest(c, "concurrency must be >= 0") + return + } + if req.Priority != nil && *req.Priority < 0 { + response.BadRequest(c, "priority must be >= 0") + return + } + if req.RateMultiplier != nil && *req.RateMultiplier < 0 { + response.BadRequest(c, "rate_multiplier must be >= 0") + return + } + if req.LoadFactor != nil && *req.LoadFactor > 10000 { + response.BadRequest(c, "load_factor must be <= 10000") + return + } + + entries, err := parseCodexSessionImportEntries(req) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + if len(entries) == 0 { + response.BadRequest(c, "请输入 accessToken 或 Codex session JSON") + return + } + + executeAdminIdempotentJSON(c, "admin.accounts.import_codex_session", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) { + return h.importCodexSessions(ctx, req, entries) + }) +} + +func (h *AccountHandler) importCodexSessions(ctx context.Context, req CodexSessionImportRequest, entries []codexImportEntry) (CodexSessionImportResult, error) { + result := CodexSessionImportResult{ + Total: len(entries), + Items: make([]CodexSessionImportItem, 0, len(entries)), + } + + existingAccounts, err := h.listAccountsFiltered(ctx, service.PlatformOpenAI, service.AccountTypeOAuth, "", "", 0, "", "created_at", "desc") + if err != nil { + return result, err + } + index := buildCodexAccountIndex(existingAccounts) + + updateExisting := true + if req.UpdateExisting != nil { + updateExisting = *req.UpdateExisting + } + concurrency := 3 + if req.Concurrency != nil { + concurrency = *req.Concurrency + } + priority := 50 + if req.Priority != nil { + priority = *req.Priority + } + credentialExtras := sanitizeCodexImportCredentialExtras(req.CredentialExtras) + skipDefaultGroupBind := false + if req.SkipDefaultGroupBind != nil { + skipDefaultGroupBind = *req.SkipDefaultGroupBind + } + skipMixedChannelCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk + + seenIdentity := map[string]int{} + for _, entry := range entries { + item, err := normalizeCodexImportEntry(entry) + if err != nil { + result.Failed++ + result.Items = append(result.Items, CodexSessionImportItem{ + Index: entry.Index, + Action: "failed", + Message: err.Error(), + }) + result.Errors = append(result.Errors, CodexSessionImportMessage{ + Index: entry.Index, + Message: err.Error(), + }) + continue + } + accountName := buildCodexCreateAccountName(req.Name, item, entry.Index, len(entries)) + effectiveExpiresAt, credentialExpiresAt, autoPauseOnExpired, expiryWarnings, expiryErr := resolveCodexImportExpiry(req, item) + if expiryErr != nil { + result.Failed++ + result.Items = append(result.Items, CodexSessionImportItem{ + Index: entry.Index, + Name: accountName, + Action: "failed", + Message: expiryErr.Error(), + }) + result.Errors = append(result.Errors, CodexSessionImportMessage{ + Index: entry.Index, + Name: accountName, + Message: expiryErr.Error(), + }) + continue + } + for _, warning := range expiryWarnings { + item.WarningTexts = append(item.WarningTexts, warning) + } + if credentialExpiresAt != nil { + item.Credentials["expires_at"] = credentialExpiresAt.Format(time.RFC3339) + } + credentials := mergeCodexImportMap(item.Credentials, credentialExtras) + extra := mergeCodexImportMap(req.Extra, item.Extra) + for _, warning := range item.WarningTexts { + result.Warnings = append(result.Warnings, CodexSessionImportMessage{ + Index: entry.Index, + Name: accountName, + Message: warning, + }) + } + + if duplicateIndex, ok := firstSeenCodexIdentity(seenIdentity, item.IdentityKeys); ok { + message := fmt.Sprintf("与第 %d 条导入项重复,已跳过", duplicateIndex) + result.Skipped++ + result.Items = append(result.Items, CodexSessionImportItem{ + Index: entry.Index, + Name: accountName, + Action: "skipped", + Message: message, + }) + result.Warnings = append(result.Warnings, CodexSessionImportMessage{ + Index: entry.Index, + Name: accountName, + Message: message, + }) + continue + } + markCodexIdentitySeen(seenIdentity, item.IdentityKeys, entry.Index) + + if existing := index.Find(item.IdentityKeys); existing != nil && updateExisting { + mergedCredentials := mergeCodexImportCredentials(existing.Credentials, credentials, item) + mergedExtra := mergeCodexImportMap(existing.Extra, extra) + updateInput := &service.UpdateAccountInput{ + Credentials: mergedCredentials, + Extra: mergedExtra, + Concurrency: req.Concurrency, + Priority: req.Priority, + RateMultiplier: req.RateMultiplier, + LoadFactor: req.LoadFactor, + ExpiresAt: effectiveExpiresAt, + AutoPauseOnExpired: autoPauseOnExpired, + } + if req.ProxyID != nil { + updateInput.ProxyID = req.ProxyID + } + if len(req.GroupIDs) > 0 { + groupIDs := append([]int64(nil), req.GroupIDs...) + updateInput.GroupIDs = &groupIDs + updateInput.SkipMixedChannelCheck = skipMixedChannelCheck + } + updated, updateErr := h.adminService.UpdateAccount(ctx, existing.ID, updateInput) + if updateErr != nil { + result.Failed++ + result.Items = append(result.Items, CodexSessionImportItem{ + Index: entry.Index, + Name: accountName, + Action: "failed", + Message: updateErr.Error(), + }) + result.Errors = append(result.Errors, CodexSessionImportMessage{ + Index: entry.Index, + Name: accountName, + Message: updateErr.Error(), + }) + continue + } + if h.tokenCacheInvalidator != nil && updated != nil { + _ = h.tokenCacheInvalidator.InvalidateToken(ctx, updated) + } + result.Updated++ + accountID := existing.ID + if updated != nil { + accountID = updated.ID + index.Add(*updated) + } + result.Items = append(result.Items, CodexSessionImportItem{ + Index: entry.Index, + Name: accountName, + Action: "updated", + AccountID: accountID, + }) + continue + } + + account, createErr := h.adminService.CreateAccount(ctx, &service.CreateAccountInput{ + Name: accountName, + Notes: req.Notes, + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Credentials: credentials, + Extra: extra, + ProxyID: req.ProxyID, + Concurrency: concurrency, + Priority: priority, + RateMultiplier: req.RateMultiplier, + LoadFactor: req.LoadFactor, + GroupIDs: req.GroupIDs, + ExpiresAt: effectiveExpiresAt, + AutoPauseOnExpired: autoPauseOnExpired, + SkipDefaultGroupBind: skipDefaultGroupBind, + SkipMixedChannelCheck: skipMixedChannelCheck, + }) + if createErr != nil { + result.Failed++ + result.Items = append(result.Items, CodexSessionImportItem{ + Index: entry.Index, + Name: accountName, + Action: "failed", + Message: createErr.Error(), + }) + result.Errors = append(result.Errors, CodexSessionImportMessage{ + Index: entry.Index, + Name: accountName, + Message: createErr.Error(), + }) + continue + } + if account != nil { + index.Add(*account) + } + result.Created++ + accountID := int64(0) + if account != nil { + accountID = account.ID + } + result.Items = append(result.Items, CodexSessionImportItem{ + Index: entry.Index, + Name: accountName, + Action: "created", + AccountID: accountID, + }) + } + + return result, nil +} + +func parseCodexSessionImportEntries(req CodexSessionImportRequest) ([]codexImportEntry, error) { + contents := make([]string, 0, 1+len(req.Contents)) + if strings.TrimSpace(req.Content) != "" { + contents = append(contents, req.Content) + } + for _, content := range req.Contents { + if strings.TrimSpace(content) != "" { + contents = append(contents, content) + } + } + + var entries []codexImportEntry + for _, content := range contents { + values, err := parseCodexSessionImportContent(content) + if err != nil { + return nil, err + } + for _, value := range values { + entries = append(entries, codexImportEntry{ + Index: len(entries) + 1, + Value: value, + }) + } + } + return entries, nil +} + +func parseCodexSessionImportContent(content string) ([]any, error) { + trimmed := strings.TrimSpace(content) + if trimmed == "" { + return nil, nil + } + + if looksLikeJSON(trimmed) { + values, err := decodeCodexJSONStream(trimmed) + if err != nil { + if strings.Contains(trimmed, "\n") { + if lineValues, lineErr := parseCodexSessionImportLines(trimmed); lineErr == nil { + return lineValues, nil + } + } + return nil, fmt.Errorf("JSON 解析失败: %w", err) + } + return flattenCodexImportValues(values), nil + } + + return parseCodexSessionImportLines(trimmed) +} + +func parseCodexSessionImportLines(content string) ([]any, error) { + values := make([]any, 0) + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if looksLikeJSON(line) { + lineValues, err := decodeCodexJSONStream(line) + if err != nil { + return nil, fmt.Errorf("第 %d 行 JSON 解析失败: %w", len(values)+1, err) + } + values = append(values, flattenCodexImportValues(lineValues)...) + continue + } + values = append(values, line) + } + return values, nil +} + +func decodeCodexJSONStream(content string) ([]any, error) { + decoder := json.NewDecoder(strings.NewReader(content)) + decoder.UseNumber() + values := make([]any, 0, 1) + for { + var value any + err := decoder.Decode(&value) + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, err + } + values = append(values, value) + } + if len(values) == 0 { + return nil, errors.New("空 JSON 内容") + } + return values, nil +} + +func flattenCodexImportValues(values []any) []any { + out := make([]any, 0, len(values)) + var appendValue func(any) + appendValue = func(value any) { + if arr, ok := value.([]any); ok { + for _, item := range arr { + appendValue(item) + } + return + } + out = append(out, value) + } + for _, value := range values { + appendValue(value) + } + return out +} + +func normalizeCodexImportEntry(entry codexImportEntry) (*codexImportAccount, error) { + now := time.Now().UTC() + item := &codexImportAccount{ + Credentials: map[string]any{}, + Extra: map[string]any{ + "import_source": "codex_session", + "imported_at": now.Format(time.RFC3339), + }, + } + + switch raw := entry.Value.(type) { + case string: + item.AccessToken = strings.TrimSpace(raw) + case map[string]any: + item.AccessToken = firstCodexString(raw, + []string{"tokens", "access_token"}, + []string{"tokens", "accessToken"}, + []string{"access_token"}, + []string{"accessToken"}, + []string{"token"}, + ) + item.RefreshToken = firstCodexString(raw, + []string{"tokens", "refresh_token"}, + []string{"tokens", "refreshToken"}, + []string{"refresh_token"}, + []string{"refreshToken"}, + ) + item.IDToken = firstCodexString(raw, + []string{"tokens", "id_token"}, + []string{"tokens", "idToken"}, + []string{"id_token"}, + []string{"idToken"}, + ) + item.Email = firstCodexString(raw, []string{"email"}, []string{"user", "email"}) + item.AccountID = firstCodexString(raw, + []string{"chatgpt_account_id"}, + []string{"chatgptAccountId"}, + []string{"account_id"}, + []string{"accountId"}, + []string{"account", "id"}, + []string{"account", "account_id"}, + []string{"account", "chatgpt_account_id"}, + ) + item.UserID = firstCodexString(raw, + []string{"chatgpt_user_id"}, + []string{"chatgptUserId"}, + []string{"user_id"}, + []string{"userId"}, + []string{"user", "id"}, + ) + item.PlanType = firstCodexString(raw, + []string{"plan_type"}, + []string{"planType"}, + []string{"account", "plan_type"}, + []string{"account", "planType"}, + ) + item.Organization = firstCodexString(raw, + []string{"organization_id"}, + []string{"organizationId"}, + []string{"org_id"}, + []string{"orgId"}, + ) + item.Name = firstCodexString(raw, []string{"name"}, []string{"user", "name"}) + authProvider := firstCodexString(raw, []string{"auth_provider"}, []string{"authProvider"}) + if authProvider != "" { + item.Extra["auth_provider"] = authProvider + } + if sessionToken := firstCodexString(raw, []string{"session_token"}, []string{"sessionToken"}); sessionToken != "" { + item.Extra["session_token_present"] = true + item.WarningTexts = append(item.WarningTexts, "sessionToken 已忽略,不会作为 OAuth refresh_token 存储") + } + if sessionExpiresAt, ok := codexTimeAt(raw, []string{"expires"}); ok { + item.Extra["session_expires_at"] = sessionExpiresAt.Format(time.RFC3339) + } + if tokenExpiresAt, ok := firstCodexTime(raw, + []string{"tokens", "expires_at"}, + []string{"tokens", "expiresAt"}, + []string{"expires_at"}, + []string{"expiresAt"}, + ); ok { + if tokenExpiresAt.Unix() <= now.Unix()-codexImportClockSkewSeconds { + return nil, fmt.Errorf("access_token 已过期: %s", tokenExpiresAt.Format(time.RFC3339)) + } + item.TokenExpiresAt = &tokenExpiresAt + item.Credentials["expires_at"] = tokenExpiresAt.Format(time.RFC3339) + } + copyCodexExtraString(raw, item.Extra, "user_image", []string{"user", "image"}) + copyCodexExtraString(raw, item.Extra, "user_picture", []string{"user", "picture"}) + copyCodexExtraString(raw, item.Extra, "account_structure", []string{"account", "structure"}) + copyCodexExtraString(raw, item.Extra, "account_residency_region", []string{"account", "residencyRegion"}) + copyCodexExtraString(raw, item.Extra, "compute_residency", []string{"account", "computeResidency"}) + default: + return nil, fmt.Errorf("第 %d 条格式不支持", entry.Index) + } + + if item.AccessToken == "" { + return nil, errors.New("缺少 accessToken/access_token") + } + item.Credentials["access_token"] = item.AccessToken + if item.RefreshToken != "" { + item.Credentials["refresh_token"] = item.RefreshToken + item.Credentials["client_id"] = openai.ClientID + } + if item.IDToken != "" { + item.Credentials["id_token"] = item.IDToken + enrichCodexImportAccountFromJWT(item, item.IDToken, false, now) + } + if err := enrichCodexImportAccountFromJWT(item, item.AccessToken, true, now); err != nil { + return nil, err + } + if _, ok := item.Credentials["expires_at"]; !ok { + item.WarningTexts = append(item.WarningTexts, "无法从 accessToken 解析过期时间,导入后需自行确认令牌有效性") + } + if item.RefreshToken == "" { + item.WarningTexts = append(item.WarningTexts, "未包含 refresh_token,accessToken 过期后无法自动续期") + } + + setCodexCredentialIfNotEmpty(item.Credentials, "email", item.Email) + setCodexCredentialIfNotEmpty(item.Credentials, "chatgpt_account_id", item.AccountID) + setCodexCredentialIfNotEmpty(item.Credentials, "chatgpt_user_id", item.UserID) + setCodexCredentialIfNotEmpty(item.Credentials, "organization_id", item.Organization) + setCodexCredentialIfNotEmpty(item.Credentials, "plan_type", item.PlanType) + + fingerprint := codexTokenFingerprint(item.AccessToken) + item.Extra["access_token_sha256"] = fingerprint + item.IdentityKeys = buildCodexIdentityKeys(item.AccountID, item.UserID, item.Email, item.AccessToken) + item.Name = buildCodexImportAccountName(item, entry.Index) + + return item, nil +} + +func enrichCodexImportAccountFromJWT(item *codexImportAccount, token string, validateExpiry bool, now time.Time) error { + claims, err := decodeCodexJWTClaims(token) + if err != nil { + if validateExpiry { + item.WarningTexts = append(item.WarningTexts, "accessToken 不是可解析 JWT,无法校验过期时间和账号身份") + } + return nil + } + if validateExpiry && claims.Exp > 0 { + if now.Unix() > claims.Exp+codexImportClockSkewSeconds { + return fmt.Errorf("access_token 已过期: %s", time.Unix(claims.Exp, 0).UTC().Format(time.RFC3339)) + } + expiresAt := time.Unix(claims.Exp, 0).UTC() + item.TokenExpiresAt = &expiresAt + item.Credentials["expires_at"] = expiresAt.Format(time.RFC3339) + } + if item.Email == "" { + item.Email = strings.TrimSpace(claims.Email) + } + if claims.OpenAIAuth == nil { + if item.UserID == "" { + item.UserID = strings.TrimSpace(claims.Sub) + } + return nil + } + if item.AccountID == "" { + item.AccountID = strings.TrimSpace(claims.OpenAIAuth.ChatGPTAccountID) + } + if item.UserID == "" { + item.UserID = strings.TrimSpace(claims.OpenAIAuth.ChatGPTUserID) + } + if item.UserID == "" { + item.UserID = strings.TrimSpace(claims.OpenAIAuth.UserID) + } + if item.PlanType == "" { + item.PlanType = strings.TrimSpace(claims.OpenAIAuth.ChatGPTPlanType) + } + if item.Organization == "" { + item.Organization = strings.TrimSpace(claims.OpenAIAuth.POID) + } + if item.Organization == "" { + for _, org := range claims.OpenAIAuth.Organizations { + if org.IsDefault { + item.Organization = org.ID + break + } + } + } + if item.Organization == "" && len(claims.OpenAIAuth.Organizations) > 0 { + item.Organization = claims.OpenAIAuth.Organizations[0].ID + } + if item.UserID == "" { + item.UserID = strings.TrimSpace(claims.Sub) + } + return nil +} + +func decodeCodexJWTClaims(token string) (*codexJWTClaims, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid JWT format") + } + payload, err := decodeCodexJWTSegment(parts[1]) + if err != nil { + return nil, err + } + var claims codexJWTClaims + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, err + } + return &claims, nil +} + +func decodeCodexJWTSegment(segment string) ([]byte, error) { + if decoded, err := base64.RawURLEncoding.DecodeString(segment); err == nil { + return decoded, nil + } + if decoded, err := base64.RawStdEncoding.DecodeString(segment); err == nil { + return decoded, nil + } + padded := segment + if rem := len(padded) % 4; rem > 0 { + padded += strings.Repeat("=", 4-rem) + } + if decoded, err := base64.URLEncoding.DecodeString(padded); err == nil { + return decoded, nil + } + return base64.StdEncoding.DecodeString(padded) +} + +func buildCodexImportAccountName(item *codexImportAccount, index int) string { + for _, candidate := range []string{item.Name, item.Email, item.AccountID, item.UserID} { + candidate = strings.TrimSpace(candidate) + if candidate != "" { + return candidate + } + } + return fmt.Sprintf("Codex 导入账号 %d", index) +} + +func buildCodexCreateAccountName(base string, item *codexImportAccount, index, total int) string { + base = strings.TrimSpace(base) + if base == "" { + if item == nil { + return fmt.Sprintf("Codex 导入账号 %d", index) + } + return item.Name + } + if total > 1 { + return fmt.Sprintf("%s #%d", base, index) + } + return base +} + +func resolveCodexImportExpiry(req CodexSessionImportRequest, item *codexImportAccount) (*int64, *time.Time, *bool, []string, error) { + if item == nil { + return nil, nil, nil, nil, errors.New("导入项为空") + } + + var requestExpiresAt *time.Time + if req.ExpiresAt != nil && *req.ExpiresAt > 0 { + t := time.Unix(*req.ExpiresAt, 0).UTC() + requestExpiresAt = &t + } + + var accountExpiresAt *time.Time + var credentialExpiresAt *time.Time + warnings := make([]string, 0, 2) + if item.RefreshToken == "" { + if item.TokenExpiresAt != nil { + tokenExpiresAt := item.TokenExpiresAt.UTC() + accountExpiresAt = &tokenExpiresAt + credentialExpiresAt = &tokenExpiresAt + } + if requestExpiresAt != nil { + accountExpiresAt = earlierCodexTime(accountExpiresAt, requestExpiresAt) + credentialExpiresAt = earlierCodexTime(credentialExpiresAt, requestExpiresAt) + } + if accountExpiresAt == nil { + return nil, nil, nil, nil, errors.New("未包含 refresh_token,且无法解析 accessToken 过期时间;请在第一步设置过期时间后再导入") + } + if accountExpiresAt.Unix() <= time.Now().UTC().Unix()-codexImportClockSkewSeconds { + return nil, nil, nil, nil, fmt.Errorf("过期时间已过期: %s", accountExpiresAt.Format(time.RFC3339)) + } + warnings = append(warnings, "未包含 refresh_token,已按 accessToken/账号过期时间设置自动停止调度") + if req.AutoPauseOnExpired != nil && !*req.AutoPauseOnExpired { + warnings = append(warnings, "未包含 refresh_token,已强制开启过期自动暂停") + } + autoPause := true + expiresAtUnix := accountExpiresAt.Unix() + return &expiresAtUnix, credentialExpiresAt, &autoPause, warnings, nil + } + + if requestExpiresAt != nil { + accountExpiresAt = requestExpiresAt + } + if item.TokenExpiresAt != nil { + tokenExpiresAt := item.TokenExpiresAt.UTC() + credentialExpiresAt = &tokenExpiresAt + } + var expiresAtUnix *int64 + if accountExpiresAt != nil { + v := accountExpiresAt.Unix() + expiresAtUnix = &v + } + return expiresAtUnix, credentialExpiresAt, req.AutoPauseOnExpired, warnings, nil +} + +func earlierCodexTime(current, candidate *time.Time) *time.Time { + if candidate == nil { + return current + } + if current == nil || candidate.Before(*current) { + t := candidate.UTC() + return &t + } + t := current.UTC() + return &t +} + +func sanitizeCodexImportCredentialExtras(input map[string]any) map[string]any { + if len(input) == 0 { + return nil + } + protected := map[string]struct{}{ + "access_token": {}, + "refresh_token": {}, + "id_token": {}, + "expires_at": {}, + "email": {}, + "chatgpt_account_id": {}, + "chatgpt_user_id": {}, + "organization_id": {}, + "plan_type": {}, + "client_id": {}, + } + out := make(map[string]any, len(input)) + for key, value := range input { + normalizedKey := strings.TrimSpace(key) + if normalizedKey == "" { + continue + } + if _, ok := protected[strings.ToLower(normalizedKey)]; ok { + continue + } + out[normalizedKey] = value + } + if len(out) == 0 { + return nil + } + return out +} + +func buildCodexIdentityKeys(accountID, userID, email, accessToken string) []string { + keys := make([]string, 0, 4) + accountID = strings.TrimSpace(accountID) + userID = strings.TrimSpace(userID) + if accountID != "" { + keys = append(keys, "account:"+accountID) + } + if userID != "" { + keys = append(keys, "user:"+userID) + } + if accountID == "" && userID == "" { + if email = strings.ToLower(strings.TrimSpace(email)); email != "" { + keys = append(keys, "email:"+email) + } + } + if accessToken = strings.TrimSpace(accessToken); accessToken != "" { + keys = append(keys, "access:"+codexTokenFingerprint(accessToken)) + } + return keys +} + +func buildCodexAccountIndex(accounts []service.Account) *codexAccountIndex { + index := &codexAccountIndex{accountsByKey: map[string]service.Account{}} + for _, account := range accounts { + index.Add(account) + } + return index +} + +func (i *codexAccountIndex) Add(account service.Account) { + if i == nil { + return + } + if i.accountsByKey == nil { + i.accountsByKey = map[string]service.Account{} + } + keys := buildCodexIdentityKeys( + codexCredentialString(account.Credentials, "chatgpt_account_id"), + codexCredentialString(account.Credentials, "chatgpt_user_id"), + codexCredentialString(account.Credentials, "email"), + codexCredentialString(account.Credentials, "access_token"), + ) + for _, key := range keys { + i.accountsByKey[key] = account + } +} + +func (i *codexAccountIndex) Find(keys []string) *service.Account { + if i == nil { + return nil + } + for _, key := range keys { + if account, ok := i.accountsByKey[key]; ok { + return &account + } + } + return nil +} + +func firstSeenCodexIdentity(seen map[string]int, keys []string) (int, bool) { + for _, key := range keys { + if index, ok := seen[key]; ok { + return index, true + } + } + return 0, false +} + +func markCodexIdentitySeen(seen map[string]int, keys []string, index int) { + for _, key := range keys { + seen[key] = index + } +} + +func mergeCodexImportMap(existing, incoming map[string]any) map[string]any { + out := make(map[string]any, len(existing)+len(incoming)) + for k, v := range existing { + out[k] = v + } + for k, v := range incoming { + out[k] = v + } + return out +} + +func mergeCodexImportCredentials(existing, incoming map[string]any, item *codexImportAccount) map[string]any { + out := mergeCodexImportMap(existing, incoming) + if item == nil { + return out + } + if strings.TrimSpace(item.RefreshToken) == "" { + delete(out, "refresh_token") + delete(out, "client_id") + } + if strings.TrimSpace(item.IDToken) == "" { + delete(out, "id_token") + } + return out +} + +func codexCredentialString(credentials map[string]any, key string) string { + if credentials == nil { + return "" + } + return codexStringValue(credentials[key]) +} + +func codexTokenFingerprint(token string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(token))) + return hex.EncodeToString(sum[:]) +} + +func looksLikeJSON(content string) bool { + if content == "" { + return false + } + switch content[0] { + case '{', '[': + return true + default: + return false + } +} + +func firstCodexString(obj map[string]any, paths ...[]string) string { + for _, path := range paths { + if value, ok := codexPathValue(obj, path); ok { + if str := codexStringValue(value); str != "" { + return str + } + } + } + return "" +} + +func copyCodexExtraString(obj map[string]any, extra map[string]any, key string, path []string) { + value := firstCodexString(obj, path) + if value != "" { + extra[key] = value + } +} + +func firstCodexTime(obj map[string]any, paths ...[]string) (time.Time, bool) { + for _, path := range paths { + if value, ok := codexTimeAt(obj, path); ok { + return value, true + } + } + return time.Time{}, false +} + +func codexTimeAt(obj map[string]any, path []string) (time.Time, bool) { + value, ok := codexPathValue(obj, path) + if !ok { + return time.Time{}, false + } + return parseCodexTimeValue(value) +} + +func codexPathValue(obj map[string]any, path []string) (any, bool) { + var current any = obj + for _, key := range path { + currentObj, ok := current.(map[string]any) + if !ok { + return nil, false + } + value, ok := currentObj[key] + if !ok { + return nil, false + } + current = value + } + return current, true +} + +func codexStringValue(value any) string { + switch v := value.(type) { + case string: + return strings.TrimSpace(v) + case json.Number: + return strings.TrimSpace(v.String()) + case float64: + return strings.TrimSpace(strconv.FormatFloat(v, 'f', -1, 64)) + case float32: + return strings.TrimSpace(strconv.FormatFloat(float64(v), 'f', -1, 32)) + case int: + return strconv.Itoa(v) + case int64: + return strconv.FormatInt(v, 10) + case int32: + return strconv.FormatInt(int64(v), 10) + default: + return "" + } +} + +func setCodexCredentialIfNotEmpty(credentials map[string]any, key, value string) { + value = strings.TrimSpace(value) + if value != "" { + credentials[key] = value + } +} + +func parseCodexTimeValue(value any) (time.Time, bool) { + switch v := value.(type) { + case string: + v = strings.TrimSpace(v) + if v == "" { + return time.Time{}, false + } + if parsed, err := time.Parse(time.RFC3339Nano, v); err == nil { + return parsed.UTC(), true + } + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + return codexUnixTime(n), true + } + case json.Number: + if n, err := v.Int64(); err == nil { + return codexUnixTime(n), true + } + if f, err := v.Float64(); err == nil { + return codexUnixTime(int64(f)), true + } + case float64: + return codexUnixTime(int64(v)), true + case int: + return codexUnixTime(int64(v)), true + case int64: + return codexUnixTime(v), true + } + return time.Time{}, false +} + +func codexUnixTime(value int64) time.Time { + if value > 1_000_000_000_000 { + return time.UnixMilli(value).UTC() + } + return time.Unix(value, 0).UTC() +} diff --git a/backend/internal/handler/admin/account_codex_import_test.go b/backend/internal/handler/admin/account_codex_import_test.go new file mode 100644 index 00000000..3cf0d2bb --- /dev/null +++ b/backend/internal/handler/admin/account_codex_import_test.go @@ -0,0 +1,344 @@ +package admin + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "testing" + "time" +) + +func TestParseCodexSessionImportEntriesSupportsRawTokenJSONAndArray(t *testing.T) { + token1 := "raw-access-token-1" + token2 := buildCodexImportTestJWT(t, time.Now().Add(time.Hour), map[string]any{ + "email": "json@example.com", + }) + token3 := "raw-access-token-3" + + req := CodexSessionImportRequest{ + Content: fmt.Sprintf("%s\n{\"accessToken\":%q}\n[%q]", token1, token2, token3), + } + + entries, err := parseCodexSessionImportEntries(req) + if err != nil { + t.Fatalf("parseCodexSessionImportEntries error = %v", err) + } + if len(entries) != 3 { + t.Fatalf("len(entries) = %d, want 3", len(entries)) + } + + first, err := normalizeCodexImportEntry(entries[0]) + if err != nil { + t.Fatalf("normalize raw token error = %v", err) + } + if first.Credentials["access_token"] != token1 { + t.Fatalf("raw token access_token = %v, want %s", first.Credentials["access_token"], token1) + } + + second, err := normalizeCodexImportEntry(entries[1]) + if err != nil { + t.Fatalf("normalize json token error = %v", err) + } + if second.Email != "json@example.com" { + t.Fatalf("email = %q, want json@example.com", second.Email) + } + + third, err := normalizeCodexImportEntry(entries[2]) + if err != nil { + t.Fatalf("normalize array token error = %v", err) + } + if third.Credentials["access_token"] != token3 { + t.Fatalf("array token access_token = %v, want %s", third.Credentials["access_token"], token3) + } +} + +func TestParseCodexSessionImportEntriesFallsBackToLineModeForMixedJSONAndToken(t *testing.T) { + req := CodexSessionImportRequest{ + Content: "{\"accessToken\":\"json-line-token\"}\nraw-line-token", + } + + entries, err := parseCodexSessionImportEntries(req) + if err != nil { + t.Fatalf("parseCodexSessionImportEntries error = %v", err) + } + if len(entries) != 2 { + t.Fatalf("len(entries) = %d, want 2", len(entries)) + } + + first, err := normalizeCodexImportEntry(entries[0]) + if err != nil { + t.Fatalf("normalize json line error = %v", err) + } + if first.Credentials["access_token"] != "json-line-token" { + t.Fatalf("json line access_token = %v, want json-line-token", first.Credentials["access_token"]) + } + + second, err := normalizeCodexImportEntry(entries[1]) + if err != nil { + t.Fatalf("normalize raw line error = %v", err) + } + if second.Credentials["access_token"] != "raw-line-token" { + t.Fatalf("raw line access_token = %v, want raw-line-token", second.Credentials["access_token"]) + } +} + +func TestNormalizeCodexSessionJSONExtractsCredentialsAndIgnoresSessionToken(t *testing.T) { + accessToken := buildCodexImportTestJWT(t, time.Now().Add(time.Hour), map[string]any{ + "email": "claim@example.com", + "https://api.openai.com/auth": map[string]any{ + "chatgpt_account_id": "acct-from-claim", + "chatgpt_user_id": "user-from-claim", + "chatgpt_plan_type": "plus", + "poid": "org-from-claim", + }, + }) + raw := map[string]any{ + "user": map[string]any{ + "id": "user-from-json", + "name": "Sup OO", + "email": "json@example.com", + "image": "https://example.com/avatar.png", + }, + "account": map[string]any{ + "id": "acct-from-json", + "planType": "free", + }, + "accessToken": accessToken, + "sessionToken": "secret-session-token", + "expires": "2026-08-05T13:40:42.836Z", + } + + item, err := normalizeCodexImportEntry(codexImportEntry{Index: 1, Value: raw}) + if err != nil { + t.Fatalf("normalizeCodexImportEntry error = %v", err) + } + if item.Credentials["access_token"] != accessToken { + t.Fatalf("access_token not stored") + } + if item.Credentials["email"] != "json@example.com" { + t.Fatalf("email = %v, want json@example.com", item.Credentials["email"]) + } + if item.Credentials["chatgpt_account_id"] != "acct-from-json" { + t.Fatalf("chatgpt_account_id = %v, want acct-from-json", item.Credentials["chatgpt_account_id"]) + } + if item.Credentials["chatgpt_user_id"] != "user-from-json" { + t.Fatalf("chatgpt_user_id = %v, want user-from-json", item.Credentials["chatgpt_user_id"]) + } + if item.Credentials["plan_type"] != "free" { + t.Fatalf("plan_type = %v, want free", item.Credentials["plan_type"]) + } + if _, ok := item.Credentials["session_token"]; ok { + t.Fatalf("session_token should not be written to credentials") + } + if item.Extra["session_token_present"] != true { + t.Fatalf("session_token_present = %v, want true", item.Extra["session_token_present"]) + } + if item.Extra["session_expires_at"] != "2026-08-05T13:40:42Z" { + t.Fatalf("session_expires_at = %v", item.Extra["session_expires_at"]) + } + if item.TokenExpiresAt == nil { + t.Fatalf("TokenExpiresAt should be parsed from accessToken") + } +} + +func TestMergeCodexImportCredentialsClearsStaleRefreshFieldsWhenIncomingHasNoRefreshToken(t *testing.T) { + existing := map[string]any{ + "access_token": "old-access-token", + "refresh_token": "old-refresh-token", + "client_id": "old-client-id", + "id_token": "old-id-token", + "model_mapping": map[string]any{"from": "existing"}, + "chatgpt_account_id": "acct-old", + "unrelated_existing": "keep", + } + incoming := map[string]any{ + "access_token": "new-access-token", + "expires_at": "2026-08-05T13:40:42Z", + "chatgpt_account_id": "acct-new", + } + item := &codexImportAccount{ + AccessToken: "new-access-token", + } + + merged := mergeCodexImportCredentials(existing, incoming, item) + + if merged["access_token"] != "new-access-token" { + t.Fatalf("access_token = %v, want new-access-token", merged["access_token"]) + } + if merged["chatgpt_account_id"] != "acct-new" { + t.Fatalf("chatgpt_account_id = %v, want acct-new", merged["chatgpt_account_id"]) + } + if _, ok := merged["refresh_token"]; ok { + t.Fatalf("refresh_token should be cleared") + } + if _, ok := merged["client_id"]; ok { + t.Fatalf("client_id should be cleared") + } + if _, ok := merged["id_token"]; ok { + t.Fatalf("id_token should be cleared") + } + if merged["unrelated_existing"] != "keep" { + t.Fatalf("unrelated_existing = %v, want keep", merged["unrelated_existing"]) + } + if _, ok := merged["model_mapping"]; !ok { + t.Fatalf("model_mapping should be preserved") + } +} + +func TestMergeCodexImportCredentialsKeepsRefreshFieldsWhenIncomingHasRefreshToken(t *testing.T) { + existing := map[string]any{ + "refresh_token": "old-refresh-token", + "client_id": "old-client-id", + "id_token": "old-id-token", + } + incoming := map[string]any{ + "access_token": "new-access-token", + "refresh_token": "new-refresh-token", + "client_id": "new-client-id", + "id_token": "new-id-token", + } + item := &codexImportAccount{ + AccessToken: "new-access-token", + RefreshToken: "new-refresh-token", + IDToken: "new-id-token", + } + + merged := mergeCodexImportCredentials(existing, incoming, item) + + if merged["refresh_token"] != "new-refresh-token" { + t.Fatalf("refresh_token = %v, want new-refresh-token", merged["refresh_token"]) + } + if merged["client_id"] != "new-client-id" { + t.Fatalf("client_id = %v, want new-client-id", merged["client_id"]) + } + if merged["id_token"] != "new-id-token" { + t.Fatalf("id_token = %v, want new-id-token", merged["id_token"]) + } +} + +func TestNormalizeCodexImportRejectsExpiredAccessToken(t *testing.T) { + expiredToken := buildCodexImportTestJWT(t, time.Now().Add(-time.Hour), map[string]any{}) + + _, err := normalizeCodexImportEntry(codexImportEntry{Index: 1, Value: expiredToken}) + if err == nil { + t.Fatal("normalizeCodexImportEntry error = nil, want expired token error") + } + if !strings.Contains(err.Error(), "已过期") { + t.Fatalf("error = %v, want expired token message", err) + } +} + +func TestResolveCodexImportExpiryForNoRefreshTokenUsesTokenExpiry(t *testing.T) { + tokenExpiresAt := time.Now().Add(time.Hour).UTC() + item := &codexImportAccount{ + AccessToken: "access-token", + Credentials: map[string]any{"access_token": "access-token"}, + TokenExpiresAt: &tokenExpiresAt, + WarningTexts: []string{}, + } + disabled := false + req := CodexSessionImportRequest{AutoPauseOnExpired: &disabled} + + accountExpiresAt, credentialExpiresAt, autoPause, warnings, err := resolveCodexImportExpiry(req, item) + if err != nil { + t.Fatalf("resolveCodexImportExpiry error = %v", err) + } + if accountExpiresAt == nil || *accountExpiresAt != tokenExpiresAt.Unix() { + t.Fatalf("account expires_at = %v, want %d", accountExpiresAt, tokenExpiresAt.Unix()) + } + if credentialExpiresAt == nil || credentialExpiresAt.Unix() != tokenExpiresAt.Unix() { + t.Fatalf("credential expires_at = %v, want %s", credentialExpiresAt, tokenExpiresAt) + } + if autoPause == nil || !*autoPause { + t.Fatalf("autoPause = %v, want true", autoPause) + } + if len(warnings) == 0 { + t.Fatalf("warnings should not be empty") + } +} + +func TestResolveCodexImportExpiryForNoRefreshTokenRequiresExpiry(t *testing.T) { + item := &codexImportAccount{ + AccessToken: "opaque-access-token", + Credentials: map[string]any{"access_token": "opaque-access-token"}, + WarningTexts: []string{}, + } + + _, _, _, _, err := resolveCodexImportExpiry(CodexSessionImportRequest{}, item) + if err == nil { + t.Fatal("resolveCodexImportExpiry error = nil, want missing expiry error") + } + if !strings.Contains(err.Error(), "无法解析 accessToken 过期时间") { + t.Fatalf("error = %v, want missing expiry message", err) + } +} + +func TestResolveCodexImportExpiryForNoRefreshTokenUsesEarlierRequestExpiry(t *testing.T) { + tokenExpiresAt := time.Now().Add(2 * time.Hour).UTC() + requestExpiresAt := time.Now().Add(time.Hour).UTC() + item := &codexImportAccount{ + AccessToken: "access-token", + Credentials: map[string]any{"access_token": "access-token"}, + TokenExpiresAt: &tokenExpiresAt, + WarningTexts: []string{}, + } + reqUnix := requestExpiresAt.Unix() + req := CodexSessionImportRequest{ExpiresAt: &reqUnix} + + accountExpiresAt, credentialExpiresAt, _, _, err := resolveCodexImportExpiry(req, item) + if err != nil { + t.Fatalf("resolveCodexImportExpiry error = %v", err) + } + if accountExpiresAt == nil || *accountExpiresAt != requestExpiresAt.Unix() { + t.Fatalf("account expires_at = %v, want %d", accountExpiresAt, requestExpiresAt.Unix()) + } + if credentialExpiresAt == nil || credentialExpiresAt.Unix() != requestExpiresAt.Unix() { + t.Fatalf("credential expires_at = %v, want %s", credentialExpiresAt, requestExpiresAt) + } +} + +func TestCodexIdentityKeysPreferStrongIdentifiers(t *testing.T) { + keys := buildCodexIdentityKeys("acct-1", "user-1", "same@example.com", "token") + for _, key := range keys { + if strings.HasPrefix(key, "email:") { + t.Fatalf("strong identity should not include email fallback: %v", keys) + } + } + + keys = buildCodexIdentityKeys("", "", "same@example.com", "token") + hasEmail := false + for _, key := range keys { + if key == "email:same@example.com" { + hasEmail = true + } + } + if !hasEmail { + t.Fatalf("weak identity should include email fallback: %v", keys) + } +} + +func buildCodexImportTestJWT(t *testing.T, exp time.Time, extraClaims map[string]any) string { + t.Helper() + header := map[string]any{ + "alg": "none", + "typ": "JWT", + } + claims := map[string]any{ + "sub": "user-from-sub", + "exp": exp.Unix(), + "iat": time.Now().Unix(), + } + for k, v := range extraClaims { + claims[k] = v + } + headerBytes, err := json.Marshal(header) + if err != nil { + t.Fatalf("marshal header: %v", err) + } + claimBytes, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + return base64.RawURLEncoding.EncodeToString(headerBytes) + "." + base64.RawURLEncoding.EncodeToString(claimBytes) + "." +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 5eb0d34b..6e1059bc 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -282,6 +282,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) { accounts.GET("/:id", h.Admin.Account.GetByID) accounts.POST("", h.Admin.Account.Create) accounts.POST("/check-mixed-channel", h.Admin.Account.CheckMixedChannel) + accounts.POST("/import/codex-session", h.Admin.Account.ImportCodexSession) accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS) accounts.POST("/sync/crs/preview", h.Admin.Account.PreviewFromCRS) accounts.PUT("/:id", h.Admin.Account.Update) diff --git a/backend/internal/service/openai_oauth_service_refresh_test.go b/backend/internal/service/openai_oauth_service_refresh_test.go index a31eb8cb..84b68ea6 100644 --- a/backend/internal/service/openai_oauth_service_refresh_test.go +++ b/backend/internal/service/openai_oauth_service_refresh_test.go @@ -52,3 +52,47 @@ func TestOpenAIOAuthService_RefreshAccountToken_NoRefreshTokenUsesExistingAccess require.Equal(t, "client-id-1", info.ClientID) require.Zero(t, atomic.LoadInt32(&client.refreshCalls), "existing access token should be reused without calling refresh") } + +func TestOpenAITokenRefresher_NeedsRefresh_SkipsAccountWithoutRefreshToken(t *testing.T) { + refresher := NewOpenAITokenRefresher(nil, nil) + expiresAt := time.Now().Add(time.Minute).UTC().Format(time.RFC3339) + + withoutRT := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "access-token", + "expires_at": expiresAt, + }, + } + require.False(t, refresher.NeedsRefresh(withoutRT, 5*time.Minute)) + + withRT := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "access-token", + "refresh_token": "refresh-token", + "expires_at": expiresAt, + }, + } + require.True(t, refresher.NeedsRefresh(withRT, 5*time.Minute)) +} + +func TestOpenAITokenProvider_NoRefreshTokenExpiredAccessTokenReturnsError(t *testing.T) { + provider := NewOpenAITokenProvider(nil, nil, nil) + expiresAt := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339) + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "expired-access-token", + "expires_at": expiresAt, + }, + } + + token, err := provider.GetAccessToken(context.Background(), account) + require.Error(t, err) + require.Empty(t, token) + require.Contains(t, err.Error(), "refresh_token is missing") +} diff --git a/backend/internal/service/openai_token_provider.go b/backend/internal/service/openai_token_provider.go index e438588e..a680d451 100644 --- a/backend/internal/service/openai_token_provider.go +++ b/backend/internal/service/openai_token_provider.go @@ -152,6 +152,12 @@ func (p *OpenAITokenProvider) GetAccessToken(ctx context.Context, account *Accou // 2) Refresh if needed (pre-expiry skew). expiresAt := account.GetCredentialAsTime("expires_at") needsRefresh := expiresAt == nil || time.Until(*expiresAt) <= openAITokenRefreshSkew + if needsRefresh && strings.TrimSpace(account.GetOpenAIRefreshToken()) == "" { + if expiresAt != nil && !time.Now().Before(*expiresAt) { + return "", errors.New("openai access_token expired and refresh_token is missing") + } + needsRefresh = false + } refreshFailed := false if needsRefresh && p.refreshAPI != nil && p.executor != nil { diff --git a/backend/internal/service/openai_token_provider_test.go b/backend/internal/service/openai_token_provider_test.go index e81fb465..4b69db8a 100644 --- a/backend/internal/service/openai_token_provider_test.go +++ b/backend/internal/service/openai_token_provider_test.go @@ -424,8 +424,9 @@ func TestOpenAITokenProvider_CacheGetError(t *testing.T) { Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ - "access_token": "fallback-token", - "expires_at": expiresAt, + "access_token": "fallback-token", + "refresh_token": "refresh-token", + "expires_at": expiresAt, }, } @@ -650,8 +651,9 @@ func TestOpenAITokenProvider_Real_LockFailedWait(t *testing.T) { Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ - "access_token": "fallback-token", - "expires_at": expiresAt, + "access_token": "fallback-token", + "refresh_token": "refresh-token", + "expires_at": expiresAt, }, } @@ -819,8 +821,9 @@ func TestOpenAITokenProvider_Real_LockRace_PollingHitsCache(t *testing.T) { Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ - "access_token": "fallback-token", - "expires_at": expiresAt, + "access_token": "fallback-token", + "refresh_token": "refresh-token", + "expires_at": expiresAt, }, } @@ -848,8 +851,9 @@ func TestOpenAITokenProvider_Real_LockRace_ContextCanceled(t *testing.T) { Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ - "access_token": "fallback-token", - "expires_at": expiresAt, + "access_token": "fallback-token", + "refresh_token": "refresh-token", + "expires_at": expiresAt, }, } @@ -875,8 +879,9 @@ func TestOpenAITokenProvider_RuntimeMetrics_LockWaitHitAndSnapshot(t *testing.T) Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ - "access_token": "fallback-token", - "expires_at": expiresAt, + "access_token": "fallback-token", + "refresh_token": "refresh-token", + "expires_at": expiresAt, }, } cacheKey := OpenAITokenCacheKey(account) @@ -911,8 +916,9 @@ func TestOpenAITokenProvider_RuntimeMetrics_LockAcquireFailure(t *testing.T) { Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ - "access_token": "fallback-token", - "expires_at": expiresAt, + "access_token": "fallback-token", + "refresh_token": "refresh-token", + "expires_at": expiresAt, }, } diff --git a/backend/internal/service/token_refresher.go b/backend/internal/service/token_refresher.go index 916c2267..823f9812 100644 --- a/backend/internal/service/token_refresher.go +++ b/backend/internal/service/token_refresher.go @@ -2,6 +2,7 @@ package service import ( "context" + "strings" "time" ) @@ -95,6 +96,9 @@ func (r *OpenAITokenRefresher) CanRefresh(account *Account) bool { // NeedsRefresh 检查token是否需要刷新 // expires_at 缺失且处于限流状态时需要刷新,防止限流期间 token 静默过期 func (r *OpenAITokenRefresher) NeedsRefresh(account *Account, refreshWindow time.Duration) bool { + if strings.TrimSpace(account.GetOpenAIRefreshToken()) == "" { + return false + } expiresAt := account.GetCredentialAsTime("expires_at") if expiresAt == nil { return account.IsRateLimited() diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 8a127793..00ed4087 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -16,6 +16,8 @@ import type { TempUnschedulableStatus, AdminDataPayload, AdminDataImportResult, + CodexSessionImportRequest, + CodexSessionImportResult, CheckMixedChannelRequest, CheckMixedChannelResponse } from '@/types' @@ -547,6 +549,11 @@ export async function importData(payload: { return data } +export async function importCodexSession(payload: CodexSessionImportRequest): Promise { + const { data } = await apiClient.post('/admin/accounts/import/codex-session', payload) + return data +} + /** * Get Antigravity default model mapping from backend * @returns Default model mapping (from -> to) @@ -663,6 +670,7 @@ export const accountsAPI = { syncFromCrs, exportData, importData, + importCodexSession, getAntigravityDefaultModelMapping, batchClearError, batchRefresh, diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index d38c31c5..9ef6c9d2 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2765,6 +2765,7 @@ :show-mobile-refresh-token-option="form.platform === 'openai'" :show-session-token-option="false" :show-access-token-option="false" + :show-codex-session-import-option="form.platform === 'openai'" :platform="form.platform" :show-project-id="geminiOAuthType === 'code_assist'" @generate-url="handleGenerateUrl" @@ -2772,6 +2773,7 @@ @validate-refresh-token="handleValidateRefreshToken" @validate-mobile-refresh-token="handleOpenAIValidateMobileRT" @validate-session-token="handleValidateSessionToken" + @import-codex-session="handleOpenAIImportCodexSession" />
@@ -3119,6 +3121,7 @@ import type { AccountType, CheckMixedChannelResponse, CreateAccountRequest, + CodexSessionImportMessage, OpenAICompactMode } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' @@ -3152,6 +3155,7 @@ interface OAuthFlowExposed { sessionKey: string refreshToken: string sessionToken: string + codexSession: string inputMethod: AuthInputMethod reset: () => void } @@ -4631,6 +4635,113 @@ const handleOpenAIExchange = async (authCode: string) => { // OpenAI Mobile RT client_id const OPENAI_MOBILE_RT_CLIENT_ID = 'app_LlGpXReQgckcGGUo2JrYvtJK' +const buildOpenAICodexImportCredentialExtras = (): Record | null => { + const credentials: Record = {} + if (!isOpenAIModelRestrictionDisabled.value) { + const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value) + if (modelMapping) { + credentials.model_mapping = modelMapping + } + } + + const compactModelMapping = buildOpenAICompactModelMapping() + if (compactModelMapping) { + credentials.compact_model_mapping = compactModelMapping + } + + if (!applyTempUnschedConfig(credentials)) { + return null + } + return credentials +} + +const formatCodexImportMessages = (messages?: CodexSessionImportMessage[]) => { + return (messages || []) + .map((item) => { + const name = item.name ? ` ${item.name}` : '' + return `#${item.index}${name}: ${item.message}` + }) + .join('\n') +} + +const handleOpenAIImportCodexSession = async (content: string) => { + const oauthClient = openaiOAuth + const trimmed = content.trim() + if (!trimmed) { + oauthClient.error.value = t('admin.accounts.oauth.openai.codexSessionEmpty') + return + } + + const credentialExtras = buildOpenAICodexImportCredentialExtras() + if (credentialExtras === null) { + return + } + + oauthClient.loading.value = true + oauthClient.error.value = '' + + try { + const extra = buildOpenAIExtra() + const result = await adminAPI.accounts.importCodexSession({ + content: trimmed, + name: form.name, + notes: form.notes || null, + proxy_id: form.proxy_id, + concurrency: form.concurrency, + load_factor: form.load_factor ?? undefined, + priority: form.priority, + rate_multiplier: form.rate_multiplier, + group_ids: form.group_ids, + expires_at: form.expires_at, + auto_pause_on_expired: autoPauseOnExpired.value, + credential_extras: Object.keys(credentialExtras).length > 0 ? credentialExtras : undefined, + extra, + update_existing: true + }) + + const successCount = result.created + result.updated + const params = { + created: result.created, + updated: result.updated, + skipped: result.skipped, + failed: result.failed + } + + if (successCount > 0 && result.failed === 0) { + appStore.showSuccess(t('admin.accounts.oauth.openai.codexSessionImportSuccess', params)) + emit('created') + handleClose() + return + } + + const errorText = formatCodexImportMessages(result.errors) + const warningText = formatCodexImportMessages(result.warnings) + oauthClient.error.value = [errorText, warningText].filter(Boolean).join('\n') + + if (result.failed === 0) { + appStore.showWarning(t('admin.accounts.oauth.openai.codexSessionImportSuccess', params)) + return + } + + if (successCount > 0) { + appStore.showWarning(t('admin.accounts.oauth.openai.codexSessionImportPartial', params)) + emit('created') + return + } + + appStore.showError(t('admin.accounts.oauth.openai.codexSessionImportFailed')) + } catch (error: any) { + oauthClient.error.value = + error.response?.data?.detail || + error.response?.data?.message || + error.message || + t('admin.accounts.oauth.openai.codexSessionImportFailed') + appStore.showError(oauthClient.error.value) + } finally { + oauthClient.loading.value = false + } +} + // OpenAI RT 批量验证和创建(共享逻辑) const handleOpenAIBatchRT = async (refreshTokenInput: string, clientId?: string) => { const oauthClient = openaiOAuth diff --git a/frontend/src/components/account/OAuthAuthorizationFlow.vue b/frontend/src/components/account/OAuthAuthorizationFlow.vue index 08c67494..9526e878 100644 --- a/frontend/src/components/account/OAuthAuthorizationFlow.vue +++ b/frontend/src/components/account/OAuthAuthorizationFlow.vue @@ -81,6 +81,17 @@ t('admin.accounts.oauth.openai.accessTokenAuth', '手动输入 AT') }} + @@ -168,6 +179,85 @@ + +
+
+

+ {{ t('admin.accounts.oauth.openai.codexSessionDesc') }} +

+ +
+ + +

+ {{ t('admin.accounts.oauth.openai.codexSessionHint') }} +

+
+ +
+

+ {{ error }} +

+
+ + +
+
+
(), { showMobileRefreshTokenOption: false, showSessionTokenOption: false, showAccessTokenOption: false, + showCodexSessionImportOption: false, platform: 'anthropic', showProjectId: true }) @@ -591,6 +683,7 @@ const emit = defineEmits<{ 'validate-mobile-refresh-token': [refreshToken: string] 'validate-session-token': [sessionToken: string] 'import-access-token': [accessToken: string] + 'import-codex-session': [content: string] 'update:inputMethod': [method: AuthInputMethod] }>() @@ -630,12 +723,13 @@ const authCodeInput = ref('') const sessionKeyInput = ref('') const refreshTokenInput = ref('') const sessionTokenInput = ref('') +const codexSessionInput = ref('') const showHelpDialog = ref(false) const oauthState = ref('') const projectId = ref('') // Computed: show method selection when either cookie or refresh token option is enabled -const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption || props.showMobileRefreshTokenOption || props.showSessionTokenOption || props.showAccessTokenOption) +const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption || props.showMobileRefreshTokenOption || props.showSessionTokenOption || props.showAccessTokenOption || props.showCodexSessionImportOption) // Clipboard const { copied, copyToClipboard } = useClipboard() @@ -656,6 +750,16 @@ const parsedRefreshTokenCount = computed(() => { .filter((rt) => rt).length }) +const parsedCodexSessionCount = computed(() => { + const trimmed = codexSessionInput.value.trim() + if (!trimmed) return 0 + if (trimmed.startsWith('{') || trimmed.startsWith('[')) return 1 + return trimmed + .split('\n') + .map((item) => item.trim()) + .filter((item) => item).length +}) + // Watchers watch(inputMethod, (newVal) => { emit('update:inputMethod', newVal) @@ -727,6 +831,12 @@ const handleValidateRefreshToken = () => { } } +const handleImportCodexSession = () => { + if (codexSessionInput.value.trim()) { + emit('import-codex-session', codexSessionInput.value.trim()) + } +} + // Expose methods and state defineExpose({ authCode: authCodeInput, @@ -735,6 +845,7 @@ defineExpose({ sessionKey: sessionKeyInput, refreshToken: refreshTokenInput, sessionToken: sessionTokenInput, + codexSession: codexSessionInput, inputMethod, reset: () => { authCodeInput.value = '' @@ -743,6 +854,7 @@ defineExpose({ sessionKeyInput.value = '' refreshTokenInput.value = '' sessionTokenInput.value = '' + codexSessionInput.value = '' inputMethod.value = 'manual' showHelpDialog.value = false } diff --git a/frontend/src/components/admin/account/AccountTableActions.vue b/frontend/src/components/admin/account/AccountTableActions.vue index ee521f83..6874625b 100644 --- a/frontend/src/components/admin/account/AccountTableActions.vue +++ b/frontend/src/components/admin/account/AccountTableActions.vue @@ -5,7 +5,6 @@ - @@ -17,7 +16,7 @@ import { useI18n } from 'vue-i18n' import Icon from '@/components/icons/Icon.vue' defineProps(['loading']) -defineEmits(['refresh', 'sync', 'create']) +defineEmits(['refresh', 'create']) const { t } = useI18n() diff --git a/frontend/src/composables/useAccountOAuth.ts b/frontend/src/composables/useAccountOAuth.ts index 564e7d95..ab4c640a 100644 --- a/frontend/src/composables/useAccountOAuth.ts +++ b/frontend/src/composables/useAccountOAuth.ts @@ -3,7 +3,7 @@ import { useAppStore } from '@/stores/app' import { adminAPI } from '@/api/admin' export type AddMethod = 'oauth' | 'setup-token' -export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token' | 'mobile_refresh_token' | 'session_token' | 'access_token' +export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token' | 'mobile_refresh_token' | 'session_token' | 'access_token' | 'codex_session' export interface OAuthState { authUrl: string diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 90bf23f7..d18a895c 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2777,6 +2777,11 @@ export default { dataExportSelected: 'Export Selected', dataExportIncludeProxies: 'Include proxies linked to the exported accounts', dataImport: 'Import', + moreActions: 'More Actions', + dataActions: 'Data', + toolActions: 'Tools', + viewColumns: 'Columns', + selectedCount: '{count} selected', dataExportConfirmMessage: 'The exported data contains sensitive account and proxy information. Store it securely.', dataExportConfirm: 'Confirm Export', dataExported: 'Data exported successfully', @@ -3470,6 +3475,16 @@ export default { refreshTokenAuth: 'Manual RT Input', refreshTokenDesc: 'Enter your existing OpenAI Refresh Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.', refreshTokenPlaceholder: 'Paste your OpenAI Refresh Token...\nSupports multiple, one per line', + codexSessionAuth: 'Codex JSON / AT Batch Input', + codexSessionDesc: 'Paste Codex JSON or an accessToken. Accounts use the step 1 settings.', + codexSessionInputLabel: 'Codex JSON or accessToken', + codexSessionPlaceholder: 'Multiple lines supported, one token or JSON per line', + codexSessionHint: 'sessionToken will not be saved as refresh_token. Without refresh_token, the account expires with the accessToken expiry; import is rejected if the expiry cannot be parsed and step 1 has no expiration.', + codexSessionImportAndCreate: 'Import & Create Account', + codexSessionEmpty: 'Please enter Codex JSON or accessToken', + codexSessionImportFailed: 'Failed to import Codex account', + codexSessionImportSuccess: 'Import completed: created {created}, updated {updated}, skipped {skipped}', + codexSessionImportPartial: 'Partial success: created {created}, updated {updated}, skipped {skipped}, failed {failed}', sessionTokenAuth: 'Manual ST Input', sessionTokenDesc: 'Enter your existing Session Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.', sessionTokenPlaceholder: 'Paste your Session Token...\nSupports multiple, one per line', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 87482f9d..4f473f94 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -2853,6 +2853,11 @@ export default { dataExportSelected: '导出选中', dataExportIncludeProxies: '导出代理(导出账号关联的代理)', dataImport: '导入', + moreActions: '更多操作', + dataActions: '数据操作', + toolActions: '工具', + viewColumns: '列显示', + selectedCount: '已选 {count}', dataExportConfirmMessage: '导出的数据包含账号与代理的敏感信息,请妥善保存。', dataExportConfirm: '确认导出', dataExported: '数据导出成功', @@ -3605,6 +3610,16 @@ export default { refreshTokenAuth: '手动输入 RT', refreshTokenDesc: '输入您已有的 OpenAI Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。', refreshTokenPlaceholder: '粘贴您的 OpenAI Refresh Token...\n支持多个,每行一个', + codexSessionAuth: 'Codex JSON / AT 批量输入', + codexSessionDesc: '粘贴 Codex JSON 或 accessToken,按第一步配置创建账号。', + codexSessionInputLabel: 'Codex JSON 或 accessToken', + codexSessionPlaceholder: '支持多行,每行一个 token 或 JSON', + codexSessionHint: 'sessionToken 不会作为 refresh_token 保存;未包含 refresh_token 时会按 accessToken 过期时间设置账号过期,无法解析且第一步未设置过期时间时会拒绝导入。', + codexSessionImportAndCreate: '导入并创建账号', + codexSessionEmpty: '请输入 Codex JSON 或 accessToken', + codexSessionImportFailed: 'Codex 账号导入失败', + codexSessionImportSuccess: '导入完成:新增 {created},更新 {updated},跳过 {skipped}', + codexSessionImportPartial: '部分成功:新增 {created},更新 {updated},跳过 {skipped},失败 {failed}', sessionTokenAuth: '手动输入 ST', sessionTokenDesc: '输入您已有的 Session Token,支持批量输入(每行一个),系统将自动验证并创建账号。', sessionTokenPlaceholder: '粘贴您的 Session Token...\n支持多个,每行一个', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 328b7c04..ec7d0636 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1105,6 +1105,51 @@ export interface AdminDataImportResult { errors?: AdminDataImportError[] } +export interface CodexSessionImportRequest { + content?: string + contents?: string[] + name?: string + notes?: string | null + group_ids?: number[] + proxy_id?: number | null + concurrency?: number + priority?: number + rate_multiplier?: number + load_factor?: number | null + expires_at?: number | null + auto_pause_on_expired?: boolean + credential_extras?: Record + extra?: Record + update_existing?: boolean + skip_default_group_bind?: boolean + confirm_mixed_channel_risk?: boolean +} + +export interface CodexSessionImportMessage { + index: number + name?: string + message: string +} + +export interface CodexSessionImportItem { + index: number + name?: string + action: 'created' | 'updated' | 'skipped' | 'failed' + account_id?: number + message?: string +} + +export interface CodexSessionImportResult { + total: number + created: number + updated: number + skipped: number + failed: number + items?: CodexSessionImportItem[] + warnings?: CodexSessionImportMessage[] + errors?: CodexSessionImportMessage[] +} + // ==================== Usage & Redeem Types ==================== export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' | 'invitation' diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 04c376cc..c2159f6f 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -14,7 +14,6 @@ -
(null) const menu = reactive<{show:boolean, acc:Account|null, pos:{top:number, left:number}|null}>({ show: false, acc: null, pos: null }) const exportingData = ref(false) -// Column settings -const showColumnDropdown = ref(false) -const columnDropdownRef = ref(null) +// Account tools dropdown +const showAccountToolsDropdown = ref(false) +const accountToolsDropdownRef = ref(null) const hiddenColumns = reactive>(new Set()) const DEFAULT_HIDDEN_COLUMNS = ['today_stats', 'proxy', 'notes', 'priority', 'rate_multiplier'] const HIDDEN_COLUMNS_KEY = 'account-hidden-columns' @@ -820,7 +851,8 @@ const isAnyModalOpen = computed(() => { showTest.value || showStats.value || showSchedulePanel.value || - showErrorPassthrough.value + showErrorPassthrough.value || + showTLSFingerprintProfiles.value ) }) @@ -931,6 +963,35 @@ const handleManualRefresh = async () => { usageManualRefreshToken.value += 1 } +const closeAccountToolsDropdown = () => { + showAccountToolsDropdown.value = false +} + +const openSyncFromCrs = () => { + closeAccountToolsDropdown() + showSync.value = true +} + +const openImportData = () => { + closeAccountToolsDropdown() + showImportData.value = true +} + +const openExportDataDialogFromMenu = () => { + closeAccountToolsDropdown() + openExportDataDialog() +} + +const openErrorPassthrough = () => { + closeAccountToolsDropdown() + showErrorPassthrough.value = true +} + +const openTLSFingerprintProfiles = () => { + closeAccountToolsDropdown() + showTLSFingerprintProfiles.value = true +} + const syncPendingListChanges = async () => { hasPendingListSync.value = false await load() @@ -944,7 +1005,7 @@ const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn( if (document.hidden) return if (loading.value || autoRefreshFetching.value) return if (isAnyModalOpen.value) return - if (menu.show) return + if (menu.show || showAccountToolsDropdown.value || showAutoRefreshDropdown.value) return if (inAutoRefreshSilentWindow()) { autoRefreshCountdown.value = Math.max( 0, @@ -1572,11 +1633,11 @@ const handleScroll = () => { menu.show = false } -// 点击外部关闭列设置下拉菜单 +// 点击外部关闭顶部下拉菜单 const handleClickOutside = (event: MouseEvent) => { const target = event.target as HTMLElement - if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) { - showColumnDropdown.value = false + if (accountToolsDropdownRef.value && !accountToolsDropdownRef.value.contains(target)) { + showAccountToolsDropdown.value = false } if (autoRefreshDropdownRef.value && !autoRefreshDropdownRef.value.contains(target)) { showAutoRefreshDropdown.value = false @@ -1608,3 +1669,13 @@ onUnmounted(() => { document.removeEventListener('click', handleClickOutside) }) + + From 33db04fb75d68028d7d7e48a52577c6d2bde67ed Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 8 May 2026 14:33:26 +0800 Subject: [PATCH 24/25] =?UTF-8?q?chore:=20=E4=BF=AE=E5=A4=8D=20CI=20?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E4=B8=8E=20lint=20=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/backend-ci.yml | 4 +-- .github/workflows/release.yml | 2 +- .github/workflows/security-scan.yml | 2 +- Dockerfile | 2 +- backend/Dockerfile | 2 +- backend/go.mod | 13 ++++----- backend/go.sum | 28 ++++++------------- .../handler/admin/account_codex_import.go | 8 +++--- deploy/Dockerfile | 2 +- 9 files changed, 25 insertions(+), 38 deletions(-) diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index f8b22ee7..15ff97fe 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -20,7 +20,7 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.26.2' + go version | grep -q 'go1.26.3' - name: Unit tests working-directory: backend run: make test-unit @@ -60,7 +60,7 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.26.2' + go version | grep -q 'go1.26.3' - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26ed8524..80bc9850 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,7 +115,7 @@ jobs: - name: Verify Go version run: | - go version | grep -q 'go1.26.2' + go version | grep -q 'go1.26.3' # Docker setup for GoReleaser - name: Set up QEMU diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 600fd2fa..ef8e59e5 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -23,7 +23,7 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.26.2' + go version | grep -q 'go1.26.3' - name: Run govulncheck working-directory: backend run: | diff --git a/Dockerfile b/Dockerfile index 890bda0b..7befb464 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ # ============================================================================= ARG NODE_IMAGE=node:24-alpine -ARG GOLANG_IMAGE=golang:1.26.2-alpine +ARG GOLANG_IMAGE=golang:1.26.3-alpine ARG ALPINE_IMAGE=alpine:3.21 ARG POSTGRES_IMAGE=postgres:18-alpine ARG GOPROXY=https://goproxy.cn,direct diff --git a/backend/Dockerfile b/backend/Dockerfile index aeb20fdb..f153d686 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25.7-alpine +FROM golang:1.26.3-alpine WORKDIR /app diff --git a/backend/go.mod b/backend/go.mod index 982bf91b..7a4f436f 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module github.com/Wei-Shaw/sub2api -go 1.26.2 +go 1.26.3 require ( entgo.io/ent v0.14.5 @@ -20,6 +20,7 @@ require ( github.com/google/wire v0.7.0 github.com/gorilla/websocket v1.5.3 github.com/imroc/req/v3 v3.57.0 + github.com/klauspost/compress v1.18.2 github.com/lib/pq v1.10.9 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pquerna/otp v1.5.0 @@ -39,11 +40,11 @@ require ( github.com/wechatpay-apiv3/wechatpay-go v0.2.21 github.com/zeromicro/go-zero v1.9.4 go.uber.org/zap v1.24.0 - golang.org/x/crypto v0.49.0 + golang.org/x/crypto v0.50.0 golang.org/x/image v0.39.0 - golang.org/x/net v0.52.0 + golang.org/x/net v0.53.0 golang.org/x/sync v0.20.0 - golang.org/x/term v0.41.0 + golang.org/x/term v0.42.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.44.3 @@ -104,13 +105,11 @@ require ( github.com/goccy/go-json v0.10.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect - github.com/google/subcommands v1.2.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl/v2 v2.18.1 // indirect github.com/icholy/digest v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/leodido/go-urn v1.2.4 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect @@ -174,7 +173,7 @@ require ( golang.org/x/arch v0.3.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/mod v0.34.0 // indirect - golang.org/x/sys v0.42.0 // indirect + golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect golang.org/x/tools v0.43.0 // indirect google.golang.org/grpc v1.75.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 0f366ee1..e16a9fc0 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -162,8 +162,6 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= -github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= @@ -183,8 +181,6 @@ github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI= github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -220,8 +216,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -255,8 +249,6 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -286,8 +278,6 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= @@ -320,8 +310,6 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= @@ -413,16 +401,16 @@ go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -434,10 +422,10 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= diff --git a/backend/internal/handler/admin/account_codex_import.go b/backend/internal/handler/admin/account_codex_import.go index 59fe30a0..0c599522 100644 --- a/backend/internal/handler/admin/account_codex_import.go +++ b/backend/internal/handler/admin/account_codex_import.go @@ -211,9 +211,7 @@ func (h *AccountHandler) importCodexSessions(ctx context.Context, req CodexSessi }) continue } - for _, warning := range expiryWarnings { - item.WarningTexts = append(item.WarningTexts, warning) - } + item.WarningTexts = append(item.WarningTexts, expiryWarnings...) if credentialExpiresAt != nil { item.Credentials["expires_at"] = credentialExpiresAt.Format(time.RFC3339) } @@ -565,7 +563,9 @@ func normalizeCodexImportEntry(entry codexImportEntry) (*codexImportAccount, err } if item.IDToken != "" { item.Credentials["id_token"] = item.IDToken - enrichCodexImportAccountFromJWT(item, item.IDToken, false, now) + if err := enrichCodexImportAccountFromJWT(item, item.IDToken, false, now); err != nil { + return nil, err + } } if err := enrichCodexImportAccountFromJWT(item, item.AccessToken, true, now); err != nil { return nil, err diff --git a/deploy/Dockerfile b/deploy/Dockerfile index b0b6036c..a947158f 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -7,7 +7,7 @@ # ============================================================================= ARG NODE_IMAGE=node:24-alpine -ARG GOLANG_IMAGE=golang:1.26.2-alpine +ARG GOLANG_IMAGE=golang:1.26.3-alpine ARG ALPINE_IMAGE=alpine:3.20 ARG GOPROXY=https://goproxy.cn,direct ARG GOSUMDB=sum.golang.google.cn From dbc8ae658cfc1c012160752582925e45115e2f3a Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 8 May 2026 20:00:06 +0800 Subject: [PATCH 25/25] chore: update sponsors --- README.md | 4 ++-- README_CN.md | 4 ++-- README_JA.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 718730c6..bdb09d15 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,8 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot -silkapi -Thanks to SilkAPI for sponsoring this project! SilkAPI is a relay service built on Sub2API, specializing in providing high-speed and stable Codex API relay. +silkapi +Thanks to SilkAPI for sponsoring this project! SilkAPI is a relay service built on Sub2API, specializing in providing high-speed and stable Codex API relay. diff --git a/README_CN.md b/README_CN.md index 24600e0e..e13f86de 100644 --- a/README_CN.md +++ b/README_CN.md @@ -71,8 +71,8 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的 -silkapi -感谢 丝绸API 赞助了本项目! 丝绸API 是基于 Sub2API 搭建的中转服务,专注于提供 Codex 高速稳定API中转。 +silkapi +感谢 丝绸API 赞助了本项目! 丝绸API 是基于 Sub2API 搭建的中转服务,专注于提供 Codex 高速稳定API中转。 diff --git a/README_JA.md b/README_JA.md index 1e89610c..73331a07 100644 --- a/README_JA.md +++ b/README_JA.md @@ -71,8 +71,8 @@ Sub2API は、AI 製品のサブスクリプションから API クォータを -silkapi -SilkAPI のご支援に感謝します!SilkAPI は Sub2API をベースに構築された中継サービスで、高速かつ安定した Codex API 中継の提供に特化しています。 +silkapi +SilkAPI のご支援に感謝します!SilkAPI は Sub2API をベースに構築された中継サービスで、高速かつ安定した Codex API 中継の提供に特化しています。