diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 3ea8860a..e5624f86 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -19,7 +19,7 @@ jobs: cache: true - name: Verify Go version run: | - go version | grep -q 'go1.25.5' + go version | grep -q 'go1.25.6' - name: Unit tests working-directory: backend run: make test-unit @@ -38,7 +38,7 @@ jobs: cache: true - name: Verify Go version run: | - go version | grep -q 'go1.25.5' + go version | grep -q 'go1.25.6' - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 73ca35d9..f45c1a0b 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.25.5' + go version | grep -q 'go1.25.6' # Docker setup for GoReleaser - name: Set up QEMU @@ -222,8 +222,9 @@ jobs: REPO="${{ github.repository }}" GHCR_IMAGE="ghcr.io/${REPO,,}" # ${,,} converts to lowercase - # 获取 tag message 内容 + # 获取 tag message 内容并转义 Markdown 特殊字符 TAG_MESSAGE='${{ steps.tag_message.outputs.message }}' + TAG_MESSAGE=$(echo "$TAG_MESSAGE" | sed 's/\([_*`\[]\)/\\\1/g') # 限制消息长度(Telegram 消息限制 4096 字符,预留空间给头尾固定内容) if [ ${#TAG_MESSAGE} -gt 3500 ]; then diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 160a0df9..dfb8e37e 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -22,7 +22,7 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.25.5' + go version | grep -q 'go1.25.6' - name: Run govulncheck working-directory: backend run: | diff --git a/Dockerfile b/Dockerfile index b3320300..3d4b5094 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ # ============================================================================= ARG NODE_IMAGE=node:24-alpine -ARG GOLANG_IMAGE=golang:1.25.5-alpine +ARG GOLANG_IMAGE=golang:1.25.6-alpine ARG ALPINE_IMAGE=alpine:3.20 ARG GOPROXY=https://goproxy.cn,direct ARG GOSUMDB=sum.golang.google.cn diff --git a/README.md b/README.md index fa965e6f..14656332 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ English | [中文](README_CN.md) ## Demo -Try Sub2API online: **https://v2.pincc.ai/** +Try Sub2API online: **https://demo.sub2api.org/** Demo credentials (shared demo environment; **not** created automatically for self-hosted installs): @@ -128,7 +128,7 @@ curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install --- -### Method 2: Docker Compose +### Method 2: Docker Compose (Recommended) Deploy with Docker Compose, including PostgreSQL and Redis containers. @@ -137,87 +137,157 @@ Deploy with Docker Compose, including PostgreSQL and Redis containers. - Docker 20.10+ - Docker Compose v2+ -#### Installation Steps +#### Quick Start (One-Click Deployment) + +Use the automated deployment script for easy setup: + +```bash +# Create deployment directory +mkdir -p sub2api-deploy && cd sub2api-deploy + +# Download and run deployment preparation script +curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/docker-deploy.sh | bash + +# Start services +docker-compose -f docker-compose.local.yml up -d + +# View logs +docker-compose -f docker-compose.local.yml logs -f sub2api +``` + +**What the script does:** +- Downloads `docker-compose.local.yml` and `.env.example` +- Generates secure credentials (JWT_SECRET, TOTP_ENCRYPTION_KEY, POSTGRES_PASSWORD) +- Creates `.env` file with auto-generated secrets +- Creates data directories (uses local directories for easy backup/migration) +- Displays generated credentials for your reference + +#### Manual Deployment + +If you prefer manual setup: ```bash # 1. Clone the repository git clone https://github.com/Wei-Shaw/sub2api.git -cd sub2api +cd sub2api/deploy -# 2. Enter the deploy directory -cd deploy - -# 3. Copy environment configuration +# 2. Copy environment configuration cp .env.example .env -# 4. Edit configuration (set your passwords) +# 3. Edit configuration (generate secure passwords) nano .env ``` **Required configuration in `.env`:** ```bash -# PostgreSQL password (REQUIRED - change this!) +# PostgreSQL password (REQUIRED) POSTGRES_PASSWORD=your_secure_password_here +# JWT Secret (RECOMMENDED - keeps users logged in after restart) +JWT_SECRET=your_jwt_secret_here + +# TOTP Encryption Key (RECOMMENDED - preserves 2FA after restart) +TOTP_ENCRYPTION_KEY=your_totp_key_here + # Optional: Admin account ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=your_admin_password # Optional: Custom port SERVER_PORT=8080 +``` -# Optional: Security configuration -# Enable URL allowlist validation (false to skip allowlist checks, only basic format validation) -SECURITY_URL_ALLOWLIST_ENABLED=false +**Generate secure secrets:** +```bash +# Generate JWT_SECRET +openssl rand -hex 32 -# Allow insecure HTTP URLs when allowlist is disabled (default: false, requires https) -# ⚠️ WARNING: Enabling this allows HTTP (plaintext) URLs which can expose API keys -# Only recommended for: -# - Development/testing environments -# - Internal networks with trusted endpoints -# - When using local test servers (http://localhost) -# PRODUCTION: Keep this false or use HTTPS URLs only -SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=false +# Generate TOTP_ENCRYPTION_KEY +openssl rand -hex 32 -# Allow private IP addresses for upstream/pricing/CRS (for internal deployments) -SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=false +# Generate POSTGRES_PASSWORD +openssl rand -hex 32 ``` ```bash +# 4. Create data directories (for local version) +mkdir -p data postgres_data redis_data + # 5. Start all services +# Option A: Local directory version (recommended - easy migration) +docker-compose -f docker-compose.local.yml up -d + +# Option B: Named volumes version (simple setup) docker-compose up -d # 6. Check status -docker-compose ps +docker-compose -f docker-compose.local.yml ps # 7. View logs -docker-compose logs -f sub2api +docker-compose -f docker-compose.local.yml logs -f sub2api ``` +#### Deployment Versions + +| Version | Data Storage | Migration | Best For | +|---------|-------------|-----------|----------| +| **docker-compose.local.yml** | Local directories | ✅ Easy (tar entire directory) | Production, frequent backups | +| **docker-compose.yml** | Named volumes | ⚠️ Requires docker commands | Simple setup | + +**Recommendation:** Use `docker-compose.local.yml` (deployed by script) for easier data management. + #### Access Open `http://YOUR_SERVER_IP:8080` in your browser. +If admin password was auto-generated, find it in logs: +```bash +docker-compose -f docker-compose.local.yml logs sub2api | grep "admin password" +``` + #### Upgrade ```bash # Pull latest image and recreate container -docker-compose pull -docker-compose up -d +docker-compose -f docker-compose.local.yml pull +docker-compose -f docker-compose.local.yml up -d +``` + +#### Easy Migration (Local Directory Version) + +When using `docker-compose.local.yml`, migrate to a new server easily: + +```bash +# On source server +docker-compose -f docker-compose.local.yml down +cd .. +tar czf sub2api-complete.tar.gz sub2api-deploy/ + +# Transfer to new server +scp sub2api-complete.tar.gz user@new-server:/path/ + +# On new server +tar xzf sub2api-complete.tar.gz +cd sub2api-deploy/ +docker-compose -f docker-compose.local.yml up -d ``` #### Useful Commands ```bash # Stop all services -docker-compose down +docker-compose -f docker-compose.local.yml down # Restart -docker-compose restart +docker-compose -f docker-compose.local.yml restart # View all logs -docker-compose logs -f +docker-compose -f docker-compose.local.yml logs -f + +# Remove all data (caution!) +docker-compose -f docker-compose.local.yml down +rm -rf data/ postgres_data/ redis_data/ ``` --- diff --git a/README_CN.md b/README_CN.md index 707f0201..a16adc72 100644 --- a/README_CN.md +++ b/README_CN.md @@ -135,7 +135,7 @@ curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install --- -### 方式二:Docker Compose +### 方式二:Docker Compose(推荐) 使用 Docker Compose 部署,包含 PostgreSQL 和 Redis 容器。 @@ -144,87 +144,157 @@ curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install - Docker 20.10+ - Docker Compose v2+ -#### 安装步骤 +#### 快速开始(一键部署) + +使用自动化部署脚本快速搭建: + +```bash +# 创建部署目录 +mkdir -p sub2api-deploy && cd sub2api-deploy + +# 下载并运行部署准备脚本 +curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/docker-deploy.sh | bash + +# 启动服务 +docker-compose -f docker-compose.local.yml up -d + +# 查看日志 +docker-compose -f docker-compose.local.yml logs -f sub2api +``` + +**脚本功能:** +- 下载 `docker-compose.local.yml` 和 `.env.example` +- 自动生成安全凭证(JWT_SECRET、TOTP_ENCRYPTION_KEY、POSTGRES_PASSWORD) +- 创建 `.env` 文件并填充自动生成的密钥 +- 创建数据目录(使用本地目录,便于备份和迁移) +- 显示生成的凭证供你记录 + +#### 手动部署 + +如果你希望手动配置: ```bash # 1. 克隆仓库 git clone https://github.com/Wei-Shaw/sub2api.git -cd sub2api +cd sub2api/deploy -# 2. 进入 deploy 目录 -cd deploy - -# 3. 复制环境配置文件 +# 2. 复制环境配置文件 cp .env.example .env -# 4. 编辑配置(设置密码等) +# 3. 编辑配置(生成安全密码) nano .env ``` **`.env` 必须配置项:** ```bash -# PostgreSQL 密码(必须修改!) +# PostgreSQL 密码(必需) POSTGRES_PASSWORD=your_secure_password_here +# JWT 密钥(推荐 - 重启后保持用户登录状态) +JWT_SECRET=your_jwt_secret_here + +# TOTP 加密密钥(推荐 - 重启后保留双因素认证) +TOTP_ENCRYPTION_KEY=your_totp_key_here + # 可选:管理员账号 ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=your_admin_password # 可选:自定义端口 SERVER_PORT=8080 +``` -# 可选:安全配置 -# 启用 URL 白名单验证(false 则跳过白名单检查,仅做基本格式校验) -SECURITY_URL_ALLOWLIST_ENABLED=false +**生成安全密钥:** +```bash +# 生成 JWT_SECRET +openssl rand -hex 32 -# 关闭白名单时,是否允许 http:// URL(默认 false,只允许 https://) -# ⚠️ 警告:允许 HTTP 会暴露 API 密钥(明文传输) -# 仅建议在以下场景使用: -# - 开发/测试环境 -# - 内部可信网络 -# - 本地测试服务器(http://localhost) -# 生产环境:保持 false 或仅使用 HTTPS URL -SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=false +# 生成 TOTP_ENCRYPTION_KEY +openssl rand -hex 32 -# 是否允许私有 IP 地址用于上游/定价/CRS(内网部署时使用) -SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=false +# 生成 POSTGRES_PASSWORD +openssl rand -hex 32 ``` ```bash +# 4. 创建数据目录(本地版) +mkdir -p data postgres_data redis_data + # 5. 启动所有服务 +# 选项 A:本地目录版(推荐 - 易于迁移) +docker-compose -f docker-compose.local.yml up -d + +# 选项 B:命名卷版(简单设置) docker-compose up -d # 6. 查看状态 -docker-compose ps +docker-compose -f docker-compose.local.yml ps # 7. 查看日志 -docker-compose logs -f sub2api +docker-compose -f docker-compose.local.yml logs -f sub2api ``` +#### 部署版本对比 + +| 版本 | 数据存储 | 迁移便利性 | 适用场景 | +|------|---------|-----------|---------| +| **docker-compose.local.yml** | 本地目录 | ✅ 简单(打包整个目录) | 生产环境、频繁备份 | +| **docker-compose.yml** | 命名卷 | ⚠️ 需要 docker 命令 | 简单设置 | + +**推荐:** 使用 `docker-compose.local.yml`(脚本部署)以便更轻松地管理数据。 + #### 访问 在浏览器中打开 `http://你的服务器IP:8080` +如果管理员密码是自动生成的,在日志中查找: +```bash +docker-compose -f docker-compose.local.yml logs sub2api | grep "admin password" +``` + #### 升级 ```bash # 拉取最新镜像并重建容器 -docker-compose pull -docker-compose up -d +docker-compose -f docker-compose.local.yml pull +docker-compose -f docker-compose.local.yml up -d +``` + +#### 轻松迁移(本地目录版) + +使用 `docker-compose.local.yml` 时,可以轻松迁移到新服务器: + +```bash +# 源服务器 +docker-compose -f docker-compose.local.yml down +cd .. +tar czf sub2api-complete.tar.gz sub2api-deploy/ + +# 传输到新服务器 +scp sub2api-complete.tar.gz user@new-server:/path/ + +# 新服务器 +tar xzf sub2api-complete.tar.gz +cd sub2api-deploy/ +docker-compose -f docker-compose.local.yml up -d ``` #### 常用命令 ```bash # 停止所有服务 -docker-compose down +docker-compose -f docker-compose.local.yml down # 重启 -docker-compose restart +docker-compose -f docker-compose.local.yml restart # 查看所有日志 -docker-compose logs -f +docker-compose -f docker-compose.local.yml logs -f + +# 删除所有数据(谨慎!) +docker-compose -f docker-compose.local.yml down +rm -rf data/ postgres_data/ redis_data/ ``` --- diff --git a/backend/cmd/jwtgen/main.go b/backend/cmd/jwtgen/main.go index c461198b..139a3a39 100644 --- a/backend/cmd/jwtgen/main.go +++ b/backend/cmd/jwtgen/main.go @@ -33,7 +33,7 @@ func main() { }() userRepo := repository.NewUserRepository(client, sqlDB) - authService := service.NewAuthService(userRepo, cfg, nil, nil, nil, nil, nil) + authService := service.NewAuthService(userRepo, nil, cfg, nil, nil, nil, nil, nil) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 79e0dd8a..a2d633db 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.46 +0.1.61 diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 1e9e440e..c55ea844 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -71,6 +71,7 @@ func provideCleanup( schedulerSnapshot *service.SchedulerSnapshotService, tokenRefresh *service.TokenRefreshService, accountExpiry *service.AccountExpiryService, + subscriptionExpiry *service.SubscriptionExpiryService, usageCleanup *service.UsageCleanupService, pricing *service.PricingService, emailQueue *service.EmailQueueService, @@ -145,6 +146,10 @@ func provideCleanup( accountExpiry.Stop() return nil }}, + {"SubscriptionExpiryService", func() error { + subscriptionExpiry.Stop() + return nil + }}, {"PricingService", func() error { pricing.Stop() return nil diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index dd0eb0d9..6422ea20 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -43,6 +43,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { return nil, err } userRepository := repository.NewUserRepository(client, db) + redeemCodeRepository := repository.NewRedeemCodeRepository(client) settingRepository := repository.NewSettingRepository(client) settingService := service.NewSettingService(settingRepository, configConfig) redisClient := repository.ProvideRedis(configConfig) @@ -61,20 +62,29 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { apiKeyService := service.NewAPIKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, apiKeyCache, configConfig) apiKeyAuthCacheInvalidator := service.ProvideAPIKeyAuthCacheInvalidator(apiKeyService) promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator) - authService := service.NewAuthService(userRepository, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService) + authService := service.NewAuthService(userRepository, redeemCodeRepository, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService) userService := service.NewUserService(userRepository, apiKeyAuthCacheInvalidator) - authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService) + subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService) + redeemCache := repository.NewRedeemCache(redisClient) + redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator) + secretEncryptor, err := repository.NewAESEncryptor(configConfig) + if err != nil { + return nil, err + } + totpCache := repository.NewTotpCache(redisClient) + totpService := service.NewTotpService(userRepository, secretEncryptor, totpCache, settingService, emailService, emailQueueService) + authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, redeemService, totpService) userHandler := handler.NewUserHandler(userService) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) usageLogRepository := repository.NewUsageLogRepository(client, db) usageService := service.NewUsageService(usageLogRepository, userRepository, client, apiKeyAuthCacheInvalidator) usageHandler := handler.NewUsageHandler(usageService, apiKeyService) - redeemCodeRepository := repository.NewRedeemCodeRepository(client) - subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService) - redeemCache := repository.NewRedeemCache(redisClient) - redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator) redeemHandler := handler.NewRedeemHandler(redeemService) subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService) + announcementRepository := repository.NewAnnouncementRepository(client) + announcementReadRepository := repository.NewAnnouncementReadRepository(client) + announcementService := service.NewAnnouncementService(announcementRepository, announcementReadRepository, userRepository, userSubscriptionRepository) + announcementHandler := handler.NewAnnouncementHandler(announcementService) dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db) dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig) dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig) @@ -123,6 +133,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig) sessionLimitCache := repository.ProvideSessionLimitCache(redisClient, configConfig) accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, compositeTokenCacheInvalidator) + adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService) oAuthHandler := admin.NewOAuthHandler(oAuthService) openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService) geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService) @@ -162,15 +173,16 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { userAttributeValueRepository := repository.NewUserAttributeValueRepository(client) userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository) userAttributeHandler := admin.NewUserAttributeHandler(userAttributeService) - adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler) - gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, configConfig) + adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler) + gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, configConfig) openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, configConfig) soraDirectClient := service.NewSoraDirectClient(configConfig, httpUpstream, openAITokenProvider) soraMediaStorage := service.ProvideSoraMediaStorage(configConfig) soraGatewayService := service.NewSoraGatewayService(soraDirectClient, soraMediaStorage, rateLimitService, configConfig) soraGatewayHandler := handler.NewSoraGatewayHandler(gatewayService, soraGatewayService, concurrencyService, billingCacheService, configConfig) handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo) - handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, soraGatewayHandler, handlerSettingHandler) + totpHandler := handler.NewTotpHandler(totpService) + handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, soraGatewayHandler, handlerSettingHandler, totpHandler) jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService) adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService) apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig) @@ -182,9 +194,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { opsCleanupService := service.ProvideOpsCleanupService(opsRepository, db, redisClient, configConfig) opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig) soraMediaCleanupService := service.ProvideSoraMediaCleanupService(soraMediaStorage, configConfig) - tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, soraAccountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, configConfig) + tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, soraAccountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig) accountExpiryService := service.ProvideAccountExpiryService(accountRepository) - v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, soraMediaCleanupService, schedulerSnapshotService, tokenRefreshService, accountExpiryService, usageCleanupService, pricingService, emailQueueService, billingCacheService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService) + subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository) + v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, soraMediaCleanupService, schedulerSnapshotService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, usageCleanupService, pricingService, emailQueueService, billingCacheService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService) application := &Application{ Server: httpServer, Cleanup: v, @@ -218,6 +231,7 @@ func provideCleanup( schedulerSnapshot *service.SchedulerSnapshotService, tokenRefresh *service.TokenRefreshService, accountExpiry *service.AccountExpiryService, + subscriptionExpiry *service.SubscriptionExpiryService, usageCleanup *service.UsageCleanupService, pricing *service.PricingService, emailQueue *service.EmailQueueService, @@ -291,6 +305,10 @@ func provideCleanup( accountExpiry.Stop() return nil }}, + {"SubscriptionExpiryService", func() error { + subscriptionExpiry.Stop() + return nil + }}, {"PricingService", func() error { pricing.Stop() return nil diff --git a/backend/ent/announcement.go b/backend/ent/announcement.go new file mode 100644 index 00000000..93d7a375 --- /dev/null +++ b/backend/ent/announcement.go @@ -0,0 +1,249 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "entgo.io/ent" + "entgo.io/ent/dialect/sql" + "github.com/Wei-Shaw/sub2api/ent/announcement" + "github.com/Wei-Shaw/sub2api/internal/domain" +) + +// Announcement is the model entity for the Announcement schema. +type Announcement struct { + config `json:"-"` + // ID of the ent. + ID int64 `json:"id,omitempty"` + // 公告标题 + Title string `json:"title,omitempty"` + // 公告内容(支持 Markdown) + Content string `json:"content,omitempty"` + // 状态: draft, active, archived + Status string `json:"status,omitempty"` + // 展示条件(JSON 规则) + Targeting domain.AnnouncementTargeting `json:"targeting,omitempty"` + // 开始展示时间(为空表示立即生效) + StartsAt *time.Time `json:"starts_at,omitempty"` + // 结束展示时间(为空表示永久生效) + EndsAt *time.Time `json:"ends_at,omitempty"` + // 创建人用户ID(管理员) + CreatedBy *int64 `json:"created_by,omitempty"` + // 更新人用户ID(管理员) + UpdatedBy *int64 `json:"updated_by,omitempty"` + // CreatedAt holds the value of the "created_at" field. + CreatedAt time.Time `json:"created_at,omitempty"` + // UpdatedAt holds the value of the "updated_at" field. + UpdatedAt time.Time `json:"updated_at,omitempty"` + // Edges holds the relations/edges for other nodes in the graph. + // The values are being populated by the AnnouncementQuery when eager-loading is set. + Edges AnnouncementEdges `json:"edges"` + selectValues sql.SelectValues +} + +// AnnouncementEdges holds the relations/edges for other nodes in the graph. +type AnnouncementEdges struct { + // Reads holds the value of the reads edge. + Reads []*AnnouncementRead `json:"reads,omitempty"` + // loadedTypes holds the information for reporting if a + // type was loaded (or requested) in eager-loading or not. + loadedTypes [1]bool +} + +// ReadsOrErr returns the Reads value or an error if the edge +// was not loaded in eager-loading. +func (e AnnouncementEdges) ReadsOrErr() ([]*AnnouncementRead, error) { + if e.loadedTypes[0] { + return e.Reads, nil + } + return nil, &NotLoadedError{edge: "reads"} +} + +// scanValues returns the types for scanning values from sql.Rows. +func (*Announcement) scanValues(columns []string) ([]any, error) { + values := make([]any, len(columns)) + for i := range columns { + switch columns[i] { + case announcement.FieldTargeting: + values[i] = new([]byte) + case announcement.FieldID, announcement.FieldCreatedBy, announcement.FieldUpdatedBy: + values[i] = new(sql.NullInt64) + case announcement.FieldTitle, announcement.FieldContent, announcement.FieldStatus: + values[i] = new(sql.NullString) + case announcement.FieldStartsAt, announcement.FieldEndsAt, announcement.FieldCreatedAt, announcement.FieldUpdatedAt: + values[i] = new(sql.NullTime) + default: + values[i] = new(sql.UnknownType) + } + } + return values, nil +} + +// assignValues assigns the values that were returned from sql.Rows (after scanning) +// to the Announcement fields. +func (_m *Announcement) assignValues(columns []string, values []any) error { + if m, n := len(values), len(columns); m < n { + return fmt.Errorf("mismatch number of scan values: %d != %d", m, n) + } + for i := range columns { + switch columns[i] { + case announcement.FieldID: + value, ok := values[i].(*sql.NullInt64) + if !ok { + return fmt.Errorf("unexpected type %T for field id", value) + } + _m.ID = int64(value.Int64) + case announcement.FieldTitle: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field title", values[i]) + } else if value.Valid { + _m.Title = value.String + } + case announcement.FieldContent: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field content", values[i]) + } else if value.Valid { + _m.Content = value.String + } + case announcement.FieldStatus: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field status", values[i]) + } else if value.Valid { + _m.Status = value.String + } + case announcement.FieldTargeting: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field targeting", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.Targeting); err != nil { + return fmt.Errorf("unmarshal field targeting: %w", err) + } + } + case announcement.FieldStartsAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field starts_at", values[i]) + } else if value.Valid { + _m.StartsAt = new(time.Time) + *_m.StartsAt = value.Time + } + case announcement.FieldEndsAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field ends_at", values[i]) + } else if value.Valid { + _m.EndsAt = new(time.Time) + *_m.EndsAt = value.Time + } + case announcement.FieldCreatedBy: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field created_by", values[i]) + } else if value.Valid { + _m.CreatedBy = new(int64) + *_m.CreatedBy = value.Int64 + } + case announcement.FieldUpdatedBy: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field updated_by", values[i]) + } else if value.Valid { + _m.UpdatedBy = new(int64) + *_m.UpdatedBy = value.Int64 + } + case announcement.FieldCreatedAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field created_at", values[i]) + } else if value.Valid { + _m.CreatedAt = value.Time + } + case announcement.FieldUpdatedAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field updated_at", values[i]) + } else if value.Valid { + _m.UpdatedAt = value.Time + } + default: + _m.selectValues.Set(columns[i], values[i]) + } + } + return nil +} + +// Value returns the ent.Value that was dynamically selected and assigned to the Announcement. +// This includes values selected through modifiers, order, etc. +func (_m *Announcement) Value(name string) (ent.Value, error) { + return _m.selectValues.Get(name) +} + +// QueryReads queries the "reads" edge of the Announcement entity. +func (_m *Announcement) QueryReads() *AnnouncementReadQuery { + return NewAnnouncementClient(_m.config).QueryReads(_m) +} + +// Update returns a builder for updating this Announcement. +// Note that you need to call Announcement.Unwrap() before calling this method if this Announcement +// was returned from a transaction, and the transaction was committed or rolled back. +func (_m *Announcement) Update() *AnnouncementUpdateOne { + return NewAnnouncementClient(_m.config).UpdateOne(_m) +} + +// Unwrap unwraps the Announcement entity that was returned from a transaction after it was closed, +// so that all future queries will be executed through the driver which created the transaction. +func (_m *Announcement) Unwrap() *Announcement { + _tx, ok := _m.config.driver.(*txDriver) + if !ok { + panic("ent: Announcement is not a transactional entity") + } + _m.config.driver = _tx.drv + return _m +} + +// String implements the fmt.Stringer. +func (_m *Announcement) String() string { + var builder strings.Builder + builder.WriteString("Announcement(") + builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID)) + builder.WriteString("title=") + builder.WriteString(_m.Title) + builder.WriteString(", ") + builder.WriteString("content=") + builder.WriteString(_m.Content) + builder.WriteString(", ") + builder.WriteString("status=") + builder.WriteString(_m.Status) + builder.WriteString(", ") + builder.WriteString("targeting=") + builder.WriteString(fmt.Sprintf("%v", _m.Targeting)) + builder.WriteString(", ") + if v := _m.StartsAt; v != nil { + builder.WriteString("starts_at=") + builder.WriteString(v.Format(time.ANSIC)) + } + builder.WriteString(", ") + if v := _m.EndsAt; v != nil { + builder.WriteString("ends_at=") + builder.WriteString(v.Format(time.ANSIC)) + } + builder.WriteString(", ") + if v := _m.CreatedBy; v != nil { + builder.WriteString("created_by=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") + if v := _m.UpdatedBy; v != nil { + builder.WriteString("updated_by=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") + builder.WriteString("created_at=") + builder.WriteString(_m.CreatedAt.Format(time.ANSIC)) + builder.WriteString(", ") + builder.WriteString("updated_at=") + builder.WriteString(_m.UpdatedAt.Format(time.ANSIC)) + builder.WriteByte(')') + return builder.String() +} + +// Announcements is a parsable slice of Announcement. +type Announcements []*Announcement diff --git a/backend/ent/announcement/announcement.go b/backend/ent/announcement/announcement.go new file mode 100644 index 00000000..4f34ee05 --- /dev/null +++ b/backend/ent/announcement/announcement.go @@ -0,0 +1,164 @@ +// Code generated by ent, DO NOT EDIT. + +package announcement + +import ( + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" +) + +const ( + // Label holds the string label denoting the announcement type in the database. + Label = "announcement" + // FieldID holds the string denoting the id field in the database. + FieldID = "id" + // FieldTitle holds the string denoting the title field in the database. + FieldTitle = "title" + // FieldContent holds the string denoting the content field in the database. + FieldContent = "content" + // FieldStatus holds the string denoting the status field in the database. + FieldStatus = "status" + // FieldTargeting holds the string denoting the targeting field in the database. + FieldTargeting = "targeting" + // FieldStartsAt holds the string denoting the starts_at field in the database. + FieldStartsAt = "starts_at" + // FieldEndsAt holds the string denoting the ends_at field in the database. + FieldEndsAt = "ends_at" + // FieldCreatedBy holds the string denoting the created_by field in the database. + FieldCreatedBy = "created_by" + // FieldUpdatedBy holds the string denoting the updated_by field in the database. + FieldUpdatedBy = "updated_by" + // FieldCreatedAt holds the string denoting the created_at field in the database. + FieldCreatedAt = "created_at" + // FieldUpdatedAt holds the string denoting the updated_at field in the database. + FieldUpdatedAt = "updated_at" + // EdgeReads holds the string denoting the reads edge name in mutations. + EdgeReads = "reads" + // Table holds the table name of the announcement in the database. + Table = "announcements" + // ReadsTable is the table that holds the reads relation/edge. + ReadsTable = "announcement_reads" + // ReadsInverseTable is the table name for the AnnouncementRead entity. + // It exists in this package in order to avoid circular dependency with the "announcementread" package. + ReadsInverseTable = "announcement_reads" + // ReadsColumn is the table column denoting the reads relation/edge. + ReadsColumn = "announcement_id" +) + +// Columns holds all SQL columns for announcement fields. +var Columns = []string{ + FieldID, + FieldTitle, + FieldContent, + FieldStatus, + FieldTargeting, + FieldStartsAt, + FieldEndsAt, + FieldCreatedBy, + FieldUpdatedBy, + FieldCreatedAt, + FieldUpdatedAt, +} + +// ValidColumn reports if the column name is valid (part of the table columns). +func ValidColumn(column string) bool { + for i := range Columns { + if column == Columns[i] { + return true + } + } + return false +} + +var ( + // TitleValidator is a validator for the "title" field. It is called by the builders before save. + TitleValidator func(string) error + // ContentValidator is a validator for the "content" field. It is called by the builders before save. + ContentValidator func(string) error + // DefaultStatus holds the default value on creation for the "status" field. + DefaultStatus string + // StatusValidator is a validator for the "status" field. It is called by the builders before save. + StatusValidator func(string) error + // DefaultCreatedAt holds the default value on creation for the "created_at" field. + DefaultCreatedAt func() time.Time + // DefaultUpdatedAt holds the default value on creation for the "updated_at" field. + DefaultUpdatedAt func() time.Time + // UpdateDefaultUpdatedAt holds the default value on update for the "updated_at" field. + UpdateDefaultUpdatedAt func() time.Time +) + +// OrderOption defines the ordering options for the Announcement queries. +type OrderOption func(*sql.Selector) + +// ByID orders the results by the id field. +func ByID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldID, opts...).ToFunc() +} + +// ByTitle orders the results by the title field. +func ByTitle(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldTitle, opts...).ToFunc() +} + +// ByContent orders the results by the content field. +func ByContent(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldContent, opts...).ToFunc() +} + +// ByStatus orders the results by the status field. +func ByStatus(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldStatus, opts...).ToFunc() +} + +// ByStartsAt orders the results by the starts_at field. +func ByStartsAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldStartsAt, opts...).ToFunc() +} + +// ByEndsAt orders the results by the ends_at field. +func ByEndsAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldEndsAt, opts...).ToFunc() +} + +// ByCreatedBy orders the results by the created_by field. +func ByCreatedBy(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldCreatedBy, opts...).ToFunc() +} + +// ByUpdatedBy orders the results by the updated_by field. +func ByUpdatedBy(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUpdatedBy, opts...).ToFunc() +} + +// ByCreatedAt orders the results by the created_at field. +func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldCreatedAt, opts...).ToFunc() +} + +// ByUpdatedAt orders the results by the updated_at field. +func ByUpdatedAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUpdatedAt, opts...).ToFunc() +} + +// ByReadsCount orders the results by reads count. +func ByReadsCount(opts ...sql.OrderTermOption) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborsCount(s, newReadsStep(), opts...) + } +} + +// ByReads orders the results by reads terms. +func ByReads(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborTerms(s, newReadsStep(), append([]sql.OrderTerm{term}, terms...)...) + } +} +func newReadsStep() *sqlgraph.Step { + return sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.To(ReadsInverseTable, FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, ReadsTable, ReadsColumn), + ) +} diff --git a/backend/ent/announcement/where.go b/backend/ent/announcement/where.go new file mode 100644 index 00000000..d3cad2a5 --- /dev/null +++ b/backend/ent/announcement/where.go @@ -0,0 +1,624 @@ +// Code generated by ent, DO NOT EDIT. + +package announcement + +import ( + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "github.com/Wei-Shaw/sub2api/ent/predicate" +) + +// ID filters vertices based on their ID field. +func ID(id int64) predicate.Announcement { + return predicate.Announcement(sql.FieldEQ(FieldID, id)) +} + +// IDEQ applies the EQ predicate on the ID field. +func IDEQ(id int64) predicate.Announcement { + return predicate.Announcement(sql.FieldEQ(FieldID, id)) +} + +// IDNEQ applies the NEQ predicate on the ID field. +func IDNEQ(id int64) predicate.Announcement { + return predicate.Announcement(sql.FieldNEQ(FieldID, id)) +} + +// IDIn applies the In predicate on the ID field. +func IDIn(ids ...int64) predicate.Announcement { + return predicate.Announcement(sql.FieldIn(FieldID, ids...)) +} + +// IDNotIn applies the NotIn predicate on the ID field. +func IDNotIn(ids ...int64) predicate.Announcement { + return predicate.Announcement(sql.FieldNotIn(FieldID, ids...)) +} + +// IDGT applies the GT predicate on the ID field. +func IDGT(id int64) predicate.Announcement { + return predicate.Announcement(sql.FieldGT(FieldID, id)) +} + +// IDGTE applies the GTE predicate on the ID field. +func IDGTE(id int64) predicate.Announcement { + return predicate.Announcement(sql.FieldGTE(FieldID, id)) +} + +// IDLT applies the LT predicate on the ID field. +func IDLT(id int64) predicate.Announcement { + return predicate.Announcement(sql.FieldLT(FieldID, id)) +} + +// IDLTE applies the LTE predicate on the ID field. +func IDLTE(id int64) predicate.Announcement { + return predicate.Announcement(sql.FieldLTE(FieldID, id)) +} + +// Title applies equality check predicate on the "title" field. It's identical to TitleEQ. +func Title(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldEQ(FieldTitle, v)) +} + +// Content applies equality check predicate on the "content" field. It's identical to ContentEQ. +func Content(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldEQ(FieldContent, v)) +} + +// Status applies equality check predicate on the "status" field. It's identical to StatusEQ. +func Status(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldEQ(FieldStatus, v)) +} + +// StartsAt applies equality check predicate on the "starts_at" field. It's identical to StartsAtEQ. +func StartsAt(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldEQ(FieldStartsAt, v)) +} + +// EndsAt applies equality check predicate on the "ends_at" field. It's identical to EndsAtEQ. +func EndsAt(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldEQ(FieldEndsAt, v)) +} + +// CreatedBy applies equality check predicate on the "created_by" field. It's identical to CreatedByEQ. +func CreatedBy(v int64) predicate.Announcement { + return predicate.Announcement(sql.FieldEQ(FieldCreatedBy, v)) +} + +// UpdatedBy applies equality check predicate on the "updated_by" field. It's identical to UpdatedByEQ. +func UpdatedBy(v int64) predicate.Announcement { + return predicate.Announcement(sql.FieldEQ(FieldUpdatedBy, v)) +} + +// CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ. +func CreatedAt(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldEQ(FieldCreatedAt, v)) +} + +// UpdatedAt applies equality check predicate on the "updated_at" field. It's identical to UpdatedAtEQ. +func UpdatedAt(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldEQ(FieldUpdatedAt, v)) +} + +// TitleEQ applies the EQ predicate on the "title" field. +func TitleEQ(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldEQ(FieldTitle, v)) +} + +// TitleNEQ applies the NEQ predicate on the "title" field. +func TitleNEQ(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldNEQ(FieldTitle, v)) +} + +// TitleIn applies the In predicate on the "title" field. +func TitleIn(vs ...string) predicate.Announcement { + return predicate.Announcement(sql.FieldIn(FieldTitle, vs...)) +} + +// TitleNotIn applies the NotIn predicate on the "title" field. +func TitleNotIn(vs ...string) predicate.Announcement { + return predicate.Announcement(sql.FieldNotIn(FieldTitle, vs...)) +} + +// TitleGT applies the GT predicate on the "title" field. +func TitleGT(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldGT(FieldTitle, v)) +} + +// TitleGTE applies the GTE predicate on the "title" field. +func TitleGTE(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldGTE(FieldTitle, v)) +} + +// TitleLT applies the LT predicate on the "title" field. +func TitleLT(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldLT(FieldTitle, v)) +} + +// TitleLTE applies the LTE predicate on the "title" field. +func TitleLTE(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldLTE(FieldTitle, v)) +} + +// TitleContains applies the Contains predicate on the "title" field. +func TitleContains(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldContains(FieldTitle, v)) +} + +// TitleHasPrefix applies the HasPrefix predicate on the "title" field. +func TitleHasPrefix(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldHasPrefix(FieldTitle, v)) +} + +// TitleHasSuffix applies the HasSuffix predicate on the "title" field. +func TitleHasSuffix(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldHasSuffix(FieldTitle, v)) +} + +// TitleEqualFold applies the EqualFold predicate on the "title" field. +func TitleEqualFold(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldEqualFold(FieldTitle, v)) +} + +// TitleContainsFold applies the ContainsFold predicate on the "title" field. +func TitleContainsFold(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldContainsFold(FieldTitle, v)) +} + +// ContentEQ applies the EQ predicate on the "content" field. +func ContentEQ(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldEQ(FieldContent, v)) +} + +// ContentNEQ applies the NEQ predicate on the "content" field. +func ContentNEQ(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldNEQ(FieldContent, v)) +} + +// ContentIn applies the In predicate on the "content" field. +func ContentIn(vs ...string) predicate.Announcement { + return predicate.Announcement(sql.FieldIn(FieldContent, vs...)) +} + +// ContentNotIn applies the NotIn predicate on the "content" field. +func ContentNotIn(vs ...string) predicate.Announcement { + return predicate.Announcement(sql.FieldNotIn(FieldContent, vs...)) +} + +// ContentGT applies the GT predicate on the "content" field. +func ContentGT(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldGT(FieldContent, v)) +} + +// ContentGTE applies the GTE predicate on the "content" field. +func ContentGTE(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldGTE(FieldContent, v)) +} + +// ContentLT applies the LT predicate on the "content" field. +func ContentLT(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldLT(FieldContent, v)) +} + +// ContentLTE applies the LTE predicate on the "content" field. +func ContentLTE(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldLTE(FieldContent, v)) +} + +// ContentContains applies the Contains predicate on the "content" field. +func ContentContains(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldContains(FieldContent, v)) +} + +// ContentHasPrefix applies the HasPrefix predicate on the "content" field. +func ContentHasPrefix(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldHasPrefix(FieldContent, v)) +} + +// ContentHasSuffix applies the HasSuffix predicate on the "content" field. +func ContentHasSuffix(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldHasSuffix(FieldContent, v)) +} + +// ContentEqualFold applies the EqualFold predicate on the "content" field. +func ContentEqualFold(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldEqualFold(FieldContent, v)) +} + +// ContentContainsFold applies the ContainsFold predicate on the "content" field. +func ContentContainsFold(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldContainsFold(FieldContent, v)) +} + +// StatusEQ applies the EQ predicate on the "status" field. +func StatusEQ(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldEQ(FieldStatus, v)) +} + +// StatusNEQ applies the NEQ predicate on the "status" field. +func StatusNEQ(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldNEQ(FieldStatus, v)) +} + +// StatusIn applies the In predicate on the "status" field. +func StatusIn(vs ...string) predicate.Announcement { + return predicate.Announcement(sql.FieldIn(FieldStatus, vs...)) +} + +// StatusNotIn applies the NotIn predicate on the "status" field. +func StatusNotIn(vs ...string) predicate.Announcement { + return predicate.Announcement(sql.FieldNotIn(FieldStatus, vs...)) +} + +// StatusGT applies the GT predicate on the "status" field. +func StatusGT(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldGT(FieldStatus, v)) +} + +// StatusGTE applies the GTE predicate on the "status" field. +func StatusGTE(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldGTE(FieldStatus, v)) +} + +// StatusLT applies the LT predicate on the "status" field. +func StatusLT(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldLT(FieldStatus, v)) +} + +// StatusLTE applies the LTE predicate on the "status" field. +func StatusLTE(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldLTE(FieldStatus, v)) +} + +// StatusContains applies the Contains predicate on the "status" field. +func StatusContains(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldContains(FieldStatus, v)) +} + +// StatusHasPrefix applies the HasPrefix predicate on the "status" field. +func StatusHasPrefix(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldHasPrefix(FieldStatus, v)) +} + +// StatusHasSuffix applies the HasSuffix predicate on the "status" field. +func StatusHasSuffix(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldHasSuffix(FieldStatus, v)) +} + +// StatusEqualFold applies the EqualFold predicate on the "status" field. +func StatusEqualFold(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldEqualFold(FieldStatus, v)) +} + +// StatusContainsFold applies the ContainsFold predicate on the "status" field. +func StatusContainsFold(v string) predicate.Announcement { + return predicate.Announcement(sql.FieldContainsFold(FieldStatus, v)) +} + +// TargetingIsNil applies the IsNil predicate on the "targeting" field. +func TargetingIsNil() predicate.Announcement { + return predicate.Announcement(sql.FieldIsNull(FieldTargeting)) +} + +// TargetingNotNil applies the NotNil predicate on the "targeting" field. +func TargetingNotNil() predicate.Announcement { + return predicate.Announcement(sql.FieldNotNull(FieldTargeting)) +} + +// StartsAtEQ applies the EQ predicate on the "starts_at" field. +func StartsAtEQ(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldEQ(FieldStartsAt, v)) +} + +// StartsAtNEQ applies the NEQ predicate on the "starts_at" field. +func StartsAtNEQ(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldNEQ(FieldStartsAt, v)) +} + +// StartsAtIn applies the In predicate on the "starts_at" field. +func StartsAtIn(vs ...time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldIn(FieldStartsAt, vs...)) +} + +// StartsAtNotIn applies the NotIn predicate on the "starts_at" field. +func StartsAtNotIn(vs ...time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldNotIn(FieldStartsAt, vs...)) +} + +// StartsAtGT applies the GT predicate on the "starts_at" field. +func StartsAtGT(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldGT(FieldStartsAt, v)) +} + +// StartsAtGTE applies the GTE predicate on the "starts_at" field. +func StartsAtGTE(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldGTE(FieldStartsAt, v)) +} + +// StartsAtLT applies the LT predicate on the "starts_at" field. +func StartsAtLT(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldLT(FieldStartsAt, v)) +} + +// StartsAtLTE applies the LTE predicate on the "starts_at" field. +func StartsAtLTE(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldLTE(FieldStartsAt, v)) +} + +// StartsAtIsNil applies the IsNil predicate on the "starts_at" field. +func StartsAtIsNil() predicate.Announcement { + return predicate.Announcement(sql.FieldIsNull(FieldStartsAt)) +} + +// StartsAtNotNil applies the NotNil predicate on the "starts_at" field. +func StartsAtNotNil() predicate.Announcement { + return predicate.Announcement(sql.FieldNotNull(FieldStartsAt)) +} + +// EndsAtEQ applies the EQ predicate on the "ends_at" field. +func EndsAtEQ(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldEQ(FieldEndsAt, v)) +} + +// EndsAtNEQ applies the NEQ predicate on the "ends_at" field. +func EndsAtNEQ(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldNEQ(FieldEndsAt, v)) +} + +// EndsAtIn applies the In predicate on the "ends_at" field. +func EndsAtIn(vs ...time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldIn(FieldEndsAt, vs...)) +} + +// EndsAtNotIn applies the NotIn predicate on the "ends_at" field. +func EndsAtNotIn(vs ...time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldNotIn(FieldEndsAt, vs...)) +} + +// EndsAtGT applies the GT predicate on the "ends_at" field. +func EndsAtGT(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldGT(FieldEndsAt, v)) +} + +// EndsAtGTE applies the GTE predicate on the "ends_at" field. +func EndsAtGTE(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldGTE(FieldEndsAt, v)) +} + +// EndsAtLT applies the LT predicate on the "ends_at" field. +func EndsAtLT(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldLT(FieldEndsAt, v)) +} + +// EndsAtLTE applies the LTE predicate on the "ends_at" field. +func EndsAtLTE(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldLTE(FieldEndsAt, v)) +} + +// EndsAtIsNil applies the IsNil predicate on the "ends_at" field. +func EndsAtIsNil() predicate.Announcement { + return predicate.Announcement(sql.FieldIsNull(FieldEndsAt)) +} + +// EndsAtNotNil applies the NotNil predicate on the "ends_at" field. +func EndsAtNotNil() predicate.Announcement { + return predicate.Announcement(sql.FieldNotNull(FieldEndsAt)) +} + +// CreatedByEQ applies the EQ predicate on the "created_by" field. +func CreatedByEQ(v int64) predicate.Announcement { + return predicate.Announcement(sql.FieldEQ(FieldCreatedBy, v)) +} + +// CreatedByNEQ applies the NEQ predicate on the "created_by" field. +func CreatedByNEQ(v int64) predicate.Announcement { + return predicate.Announcement(sql.FieldNEQ(FieldCreatedBy, v)) +} + +// CreatedByIn applies the In predicate on the "created_by" field. +func CreatedByIn(vs ...int64) predicate.Announcement { + return predicate.Announcement(sql.FieldIn(FieldCreatedBy, vs...)) +} + +// CreatedByNotIn applies the NotIn predicate on the "created_by" field. +func CreatedByNotIn(vs ...int64) predicate.Announcement { + return predicate.Announcement(sql.FieldNotIn(FieldCreatedBy, vs...)) +} + +// CreatedByGT applies the GT predicate on the "created_by" field. +func CreatedByGT(v int64) predicate.Announcement { + return predicate.Announcement(sql.FieldGT(FieldCreatedBy, v)) +} + +// CreatedByGTE applies the GTE predicate on the "created_by" field. +func CreatedByGTE(v int64) predicate.Announcement { + return predicate.Announcement(sql.FieldGTE(FieldCreatedBy, v)) +} + +// CreatedByLT applies the LT predicate on the "created_by" field. +func CreatedByLT(v int64) predicate.Announcement { + return predicate.Announcement(sql.FieldLT(FieldCreatedBy, v)) +} + +// CreatedByLTE applies the LTE predicate on the "created_by" field. +func CreatedByLTE(v int64) predicate.Announcement { + return predicate.Announcement(sql.FieldLTE(FieldCreatedBy, v)) +} + +// CreatedByIsNil applies the IsNil predicate on the "created_by" field. +func CreatedByIsNil() predicate.Announcement { + return predicate.Announcement(sql.FieldIsNull(FieldCreatedBy)) +} + +// CreatedByNotNil applies the NotNil predicate on the "created_by" field. +func CreatedByNotNil() predicate.Announcement { + return predicate.Announcement(sql.FieldNotNull(FieldCreatedBy)) +} + +// UpdatedByEQ applies the EQ predicate on the "updated_by" field. +func UpdatedByEQ(v int64) predicate.Announcement { + return predicate.Announcement(sql.FieldEQ(FieldUpdatedBy, v)) +} + +// UpdatedByNEQ applies the NEQ predicate on the "updated_by" field. +func UpdatedByNEQ(v int64) predicate.Announcement { + return predicate.Announcement(sql.FieldNEQ(FieldUpdatedBy, v)) +} + +// UpdatedByIn applies the In predicate on the "updated_by" field. +func UpdatedByIn(vs ...int64) predicate.Announcement { + return predicate.Announcement(sql.FieldIn(FieldUpdatedBy, vs...)) +} + +// UpdatedByNotIn applies the NotIn predicate on the "updated_by" field. +func UpdatedByNotIn(vs ...int64) predicate.Announcement { + return predicate.Announcement(sql.FieldNotIn(FieldUpdatedBy, vs...)) +} + +// UpdatedByGT applies the GT predicate on the "updated_by" field. +func UpdatedByGT(v int64) predicate.Announcement { + return predicate.Announcement(sql.FieldGT(FieldUpdatedBy, v)) +} + +// UpdatedByGTE applies the GTE predicate on the "updated_by" field. +func UpdatedByGTE(v int64) predicate.Announcement { + return predicate.Announcement(sql.FieldGTE(FieldUpdatedBy, v)) +} + +// UpdatedByLT applies the LT predicate on the "updated_by" field. +func UpdatedByLT(v int64) predicate.Announcement { + return predicate.Announcement(sql.FieldLT(FieldUpdatedBy, v)) +} + +// UpdatedByLTE applies the LTE predicate on the "updated_by" field. +func UpdatedByLTE(v int64) predicate.Announcement { + return predicate.Announcement(sql.FieldLTE(FieldUpdatedBy, v)) +} + +// UpdatedByIsNil applies the IsNil predicate on the "updated_by" field. +func UpdatedByIsNil() predicate.Announcement { + return predicate.Announcement(sql.FieldIsNull(FieldUpdatedBy)) +} + +// UpdatedByNotNil applies the NotNil predicate on the "updated_by" field. +func UpdatedByNotNil() predicate.Announcement { + return predicate.Announcement(sql.FieldNotNull(FieldUpdatedBy)) +} + +// CreatedAtEQ applies the EQ predicate on the "created_at" field. +func CreatedAtEQ(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldEQ(FieldCreatedAt, v)) +} + +// CreatedAtNEQ applies the NEQ predicate on the "created_at" field. +func CreatedAtNEQ(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldNEQ(FieldCreatedAt, v)) +} + +// CreatedAtIn applies the In predicate on the "created_at" field. +func CreatedAtIn(vs ...time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldIn(FieldCreatedAt, vs...)) +} + +// CreatedAtNotIn applies the NotIn predicate on the "created_at" field. +func CreatedAtNotIn(vs ...time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldNotIn(FieldCreatedAt, vs...)) +} + +// CreatedAtGT applies the GT predicate on the "created_at" field. +func CreatedAtGT(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldGT(FieldCreatedAt, v)) +} + +// CreatedAtGTE applies the GTE predicate on the "created_at" field. +func CreatedAtGTE(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldGTE(FieldCreatedAt, v)) +} + +// CreatedAtLT applies the LT predicate on the "created_at" field. +func CreatedAtLT(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldLT(FieldCreatedAt, v)) +} + +// CreatedAtLTE applies the LTE predicate on the "created_at" field. +func CreatedAtLTE(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldLTE(FieldCreatedAt, v)) +} + +// UpdatedAtEQ applies the EQ predicate on the "updated_at" field. +func UpdatedAtEQ(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldEQ(FieldUpdatedAt, v)) +} + +// UpdatedAtNEQ applies the NEQ predicate on the "updated_at" field. +func UpdatedAtNEQ(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldNEQ(FieldUpdatedAt, v)) +} + +// UpdatedAtIn applies the In predicate on the "updated_at" field. +func UpdatedAtIn(vs ...time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldIn(FieldUpdatedAt, vs...)) +} + +// UpdatedAtNotIn applies the NotIn predicate on the "updated_at" field. +func UpdatedAtNotIn(vs ...time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldNotIn(FieldUpdatedAt, vs...)) +} + +// UpdatedAtGT applies the GT predicate on the "updated_at" field. +func UpdatedAtGT(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldGT(FieldUpdatedAt, v)) +} + +// UpdatedAtGTE applies the GTE predicate on the "updated_at" field. +func UpdatedAtGTE(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldGTE(FieldUpdatedAt, v)) +} + +// UpdatedAtLT applies the LT predicate on the "updated_at" field. +func UpdatedAtLT(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldLT(FieldUpdatedAt, v)) +} + +// UpdatedAtLTE applies the LTE predicate on the "updated_at" field. +func UpdatedAtLTE(v time.Time) predicate.Announcement { + return predicate.Announcement(sql.FieldLTE(FieldUpdatedAt, v)) +} + +// HasReads applies the HasEdge predicate on the "reads" edge. +func HasReads() predicate.Announcement { + return predicate.Announcement(func(s *sql.Selector) { + step := sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, ReadsTable, ReadsColumn), + ) + sqlgraph.HasNeighbors(s, step) + }) +} + +// HasReadsWith applies the HasEdge predicate on the "reads" edge with a given conditions (other predicates). +func HasReadsWith(preds ...predicate.AnnouncementRead) predicate.Announcement { + return predicate.Announcement(func(s *sql.Selector) { + step := newReadsStep() + sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { + for _, p := range preds { + p(s) + } + }) + }) +} + +// And groups predicates with the AND operator between them. +func And(predicates ...predicate.Announcement) predicate.Announcement { + return predicate.Announcement(sql.AndPredicates(predicates...)) +} + +// Or groups predicates with the OR operator between them. +func Or(predicates ...predicate.Announcement) predicate.Announcement { + return predicate.Announcement(sql.OrPredicates(predicates...)) +} + +// Not applies the not operator on the given predicate. +func Not(p predicate.Announcement) predicate.Announcement { + return predicate.Announcement(sql.NotPredicates(p)) +} diff --git a/backend/ent/announcement_create.go b/backend/ent/announcement_create.go new file mode 100644 index 00000000..151d4c11 --- /dev/null +++ b/backend/ent/announcement_create.go @@ -0,0 +1,1159 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "context" + "errors" + "fmt" + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/Wei-Shaw/sub2api/ent/announcement" + "github.com/Wei-Shaw/sub2api/ent/announcementread" + "github.com/Wei-Shaw/sub2api/internal/domain" +) + +// AnnouncementCreate is the builder for creating a Announcement entity. +type AnnouncementCreate struct { + config + mutation *AnnouncementMutation + hooks []Hook + conflict []sql.ConflictOption +} + +// SetTitle sets the "title" field. +func (_c *AnnouncementCreate) SetTitle(v string) *AnnouncementCreate { + _c.mutation.SetTitle(v) + return _c +} + +// SetContent sets the "content" field. +func (_c *AnnouncementCreate) SetContent(v string) *AnnouncementCreate { + _c.mutation.SetContent(v) + return _c +} + +// SetStatus sets the "status" field. +func (_c *AnnouncementCreate) SetStatus(v string) *AnnouncementCreate { + _c.mutation.SetStatus(v) + return _c +} + +// SetNillableStatus sets the "status" field if the given value is not nil. +func (_c *AnnouncementCreate) SetNillableStatus(v *string) *AnnouncementCreate { + if v != nil { + _c.SetStatus(*v) + } + return _c +} + +// SetTargeting sets the "targeting" field. +func (_c *AnnouncementCreate) SetTargeting(v domain.AnnouncementTargeting) *AnnouncementCreate { + _c.mutation.SetTargeting(v) + return _c +} + +// SetNillableTargeting sets the "targeting" field if the given value is not nil. +func (_c *AnnouncementCreate) SetNillableTargeting(v *domain.AnnouncementTargeting) *AnnouncementCreate { + if v != nil { + _c.SetTargeting(*v) + } + return _c +} + +// SetStartsAt sets the "starts_at" field. +func (_c *AnnouncementCreate) SetStartsAt(v time.Time) *AnnouncementCreate { + _c.mutation.SetStartsAt(v) + return _c +} + +// SetNillableStartsAt sets the "starts_at" field if the given value is not nil. +func (_c *AnnouncementCreate) SetNillableStartsAt(v *time.Time) *AnnouncementCreate { + if v != nil { + _c.SetStartsAt(*v) + } + return _c +} + +// SetEndsAt sets the "ends_at" field. +func (_c *AnnouncementCreate) SetEndsAt(v time.Time) *AnnouncementCreate { + _c.mutation.SetEndsAt(v) + return _c +} + +// SetNillableEndsAt sets the "ends_at" field if the given value is not nil. +func (_c *AnnouncementCreate) SetNillableEndsAt(v *time.Time) *AnnouncementCreate { + if v != nil { + _c.SetEndsAt(*v) + } + return _c +} + +// SetCreatedBy sets the "created_by" field. +func (_c *AnnouncementCreate) SetCreatedBy(v int64) *AnnouncementCreate { + _c.mutation.SetCreatedBy(v) + return _c +} + +// SetNillableCreatedBy sets the "created_by" field if the given value is not nil. +func (_c *AnnouncementCreate) SetNillableCreatedBy(v *int64) *AnnouncementCreate { + if v != nil { + _c.SetCreatedBy(*v) + } + return _c +} + +// SetUpdatedBy sets the "updated_by" field. +func (_c *AnnouncementCreate) SetUpdatedBy(v int64) *AnnouncementCreate { + _c.mutation.SetUpdatedBy(v) + return _c +} + +// SetNillableUpdatedBy sets the "updated_by" field if the given value is not nil. +func (_c *AnnouncementCreate) SetNillableUpdatedBy(v *int64) *AnnouncementCreate { + if v != nil { + _c.SetUpdatedBy(*v) + } + return _c +} + +// SetCreatedAt sets the "created_at" field. +func (_c *AnnouncementCreate) SetCreatedAt(v time.Time) *AnnouncementCreate { + _c.mutation.SetCreatedAt(v) + return _c +} + +// SetNillableCreatedAt sets the "created_at" field if the given value is not nil. +func (_c *AnnouncementCreate) SetNillableCreatedAt(v *time.Time) *AnnouncementCreate { + if v != nil { + _c.SetCreatedAt(*v) + } + return _c +} + +// SetUpdatedAt sets the "updated_at" field. +func (_c *AnnouncementCreate) SetUpdatedAt(v time.Time) *AnnouncementCreate { + _c.mutation.SetUpdatedAt(v) + return _c +} + +// SetNillableUpdatedAt sets the "updated_at" field if the given value is not nil. +func (_c *AnnouncementCreate) SetNillableUpdatedAt(v *time.Time) *AnnouncementCreate { + if v != nil { + _c.SetUpdatedAt(*v) + } + return _c +} + +// AddReadIDs adds the "reads" edge to the AnnouncementRead entity by IDs. +func (_c *AnnouncementCreate) AddReadIDs(ids ...int64) *AnnouncementCreate { + _c.mutation.AddReadIDs(ids...) + return _c +} + +// AddReads adds the "reads" edges to the AnnouncementRead entity. +func (_c *AnnouncementCreate) AddReads(v ...*AnnouncementRead) *AnnouncementCreate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _c.AddReadIDs(ids...) +} + +// Mutation returns the AnnouncementMutation object of the builder. +func (_c *AnnouncementCreate) Mutation() *AnnouncementMutation { + return _c.mutation +} + +// Save creates the Announcement in the database. +func (_c *AnnouncementCreate) Save(ctx context.Context) (*Announcement, error) { + _c.defaults() + return withHooks(ctx, _c.sqlSave, _c.mutation, _c.hooks) +} + +// SaveX calls Save and panics if Save returns an error. +func (_c *AnnouncementCreate) SaveX(ctx context.Context) *Announcement { + v, err := _c.Save(ctx) + if err != nil { + panic(err) + } + return v +} + +// Exec executes the query. +func (_c *AnnouncementCreate) Exec(ctx context.Context) error { + _, err := _c.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_c *AnnouncementCreate) ExecX(ctx context.Context) { + if err := _c.Exec(ctx); err != nil { + panic(err) + } +} + +// defaults sets the default values of the builder before save. +func (_c *AnnouncementCreate) defaults() { + if _, ok := _c.mutation.Status(); !ok { + v := announcement.DefaultStatus + _c.mutation.SetStatus(v) + } + if _, ok := _c.mutation.CreatedAt(); !ok { + v := announcement.DefaultCreatedAt() + _c.mutation.SetCreatedAt(v) + } + if _, ok := _c.mutation.UpdatedAt(); !ok { + v := announcement.DefaultUpdatedAt() + _c.mutation.SetUpdatedAt(v) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_c *AnnouncementCreate) check() error { + if _, ok := _c.mutation.Title(); !ok { + return &ValidationError{Name: "title", err: errors.New(`ent: missing required field "Announcement.title"`)} + } + if v, ok := _c.mutation.Title(); ok { + if err := announcement.TitleValidator(v); err != nil { + return &ValidationError{Name: "title", err: fmt.Errorf(`ent: validator failed for field "Announcement.title": %w`, err)} + } + } + if _, ok := _c.mutation.Content(); !ok { + return &ValidationError{Name: "content", err: errors.New(`ent: missing required field "Announcement.content"`)} + } + if v, ok := _c.mutation.Content(); ok { + if err := announcement.ContentValidator(v); err != nil { + return &ValidationError{Name: "content", err: fmt.Errorf(`ent: validator failed for field "Announcement.content": %w`, err)} + } + } + if _, ok := _c.mutation.Status(); !ok { + return &ValidationError{Name: "status", err: errors.New(`ent: missing required field "Announcement.status"`)} + } + if v, ok := _c.mutation.Status(); ok { + if err := announcement.StatusValidator(v); err != nil { + return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "Announcement.status": %w`, err)} + } + } + if _, ok := _c.mutation.CreatedAt(); !ok { + return &ValidationError{Name: "created_at", err: errors.New(`ent: missing required field "Announcement.created_at"`)} + } + if _, ok := _c.mutation.UpdatedAt(); !ok { + return &ValidationError{Name: "updated_at", err: errors.New(`ent: missing required field "Announcement.updated_at"`)} + } + return nil +} + +func (_c *AnnouncementCreate) sqlSave(ctx context.Context) (*Announcement, error) { + if err := _c.check(); err != nil { + return nil, err + } + _node, _spec := _c.createSpec() + if err := sqlgraph.CreateNode(ctx, _c.driver, _spec); err != nil { + if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return nil, err + } + id := _spec.ID.Value.(int64) + _node.ID = int64(id) + _c.mutation.id = &_node.ID + _c.mutation.done = true + return _node, nil +} + +func (_c *AnnouncementCreate) createSpec() (*Announcement, *sqlgraph.CreateSpec) { + var ( + _node = &Announcement{config: _c.config} + _spec = sqlgraph.NewCreateSpec(announcement.Table, sqlgraph.NewFieldSpec(announcement.FieldID, field.TypeInt64)) + ) + _spec.OnConflict = _c.conflict + if value, ok := _c.mutation.Title(); ok { + _spec.SetField(announcement.FieldTitle, field.TypeString, value) + _node.Title = value + } + if value, ok := _c.mutation.Content(); ok { + _spec.SetField(announcement.FieldContent, field.TypeString, value) + _node.Content = value + } + if value, ok := _c.mutation.Status(); ok { + _spec.SetField(announcement.FieldStatus, field.TypeString, value) + _node.Status = value + } + if value, ok := _c.mutation.Targeting(); ok { + _spec.SetField(announcement.FieldTargeting, field.TypeJSON, value) + _node.Targeting = value + } + if value, ok := _c.mutation.StartsAt(); ok { + _spec.SetField(announcement.FieldStartsAt, field.TypeTime, value) + _node.StartsAt = &value + } + if value, ok := _c.mutation.EndsAt(); ok { + _spec.SetField(announcement.FieldEndsAt, field.TypeTime, value) + _node.EndsAt = &value + } + if value, ok := _c.mutation.CreatedBy(); ok { + _spec.SetField(announcement.FieldCreatedBy, field.TypeInt64, value) + _node.CreatedBy = &value + } + if value, ok := _c.mutation.UpdatedBy(); ok { + _spec.SetField(announcement.FieldUpdatedBy, field.TypeInt64, value) + _node.UpdatedBy = &value + } + if value, ok := _c.mutation.CreatedAt(); ok { + _spec.SetField(announcement.FieldCreatedAt, field.TypeTime, value) + _node.CreatedAt = value + } + if value, ok := _c.mutation.UpdatedAt(); ok { + _spec.SetField(announcement.FieldUpdatedAt, field.TypeTime, value) + _node.UpdatedAt = value + } + if nodes := _c.mutation.ReadsIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: announcement.ReadsTable, + Columns: []string{announcement.ReadsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(announcementread.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges = append(_spec.Edges, edge) + } + return _node, _spec +} + +// OnConflict allows configuring the `ON CONFLICT` / `ON DUPLICATE KEY` clause +// of the `INSERT` statement. For example: +// +// client.Announcement.Create(). +// SetTitle(v). +// OnConflict( +// // Update the row with the new values +// // the was proposed for insertion. +// sql.ResolveWithNewValues(), +// ). +// // Override some of the fields with custom +// // update values. +// Update(func(u *ent.AnnouncementUpsert) { +// SetTitle(v+v). +// }). +// Exec(ctx) +func (_c *AnnouncementCreate) OnConflict(opts ...sql.ConflictOption) *AnnouncementUpsertOne { + _c.conflict = opts + return &AnnouncementUpsertOne{ + create: _c, + } +} + +// OnConflictColumns calls `OnConflict` and configures the columns +// as conflict target. Using this option is equivalent to using: +// +// client.Announcement.Create(). +// OnConflict(sql.ConflictColumns(columns...)). +// Exec(ctx) +func (_c *AnnouncementCreate) OnConflictColumns(columns ...string) *AnnouncementUpsertOne { + _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...)) + return &AnnouncementUpsertOne{ + create: _c, + } +} + +type ( + // AnnouncementUpsertOne is the builder for "upsert"-ing + // one Announcement node. + AnnouncementUpsertOne struct { + create *AnnouncementCreate + } + + // AnnouncementUpsert is the "OnConflict" setter. + AnnouncementUpsert struct { + *sql.UpdateSet + } +) + +// SetTitle sets the "title" field. +func (u *AnnouncementUpsert) SetTitle(v string) *AnnouncementUpsert { + u.Set(announcement.FieldTitle, v) + return u +} + +// UpdateTitle sets the "title" field to the value that was provided on create. +func (u *AnnouncementUpsert) UpdateTitle() *AnnouncementUpsert { + u.SetExcluded(announcement.FieldTitle) + return u +} + +// SetContent sets the "content" field. +func (u *AnnouncementUpsert) SetContent(v string) *AnnouncementUpsert { + u.Set(announcement.FieldContent, v) + return u +} + +// UpdateContent sets the "content" field to the value that was provided on create. +func (u *AnnouncementUpsert) UpdateContent() *AnnouncementUpsert { + u.SetExcluded(announcement.FieldContent) + return u +} + +// SetStatus sets the "status" field. +func (u *AnnouncementUpsert) SetStatus(v string) *AnnouncementUpsert { + u.Set(announcement.FieldStatus, v) + return u +} + +// UpdateStatus sets the "status" field to the value that was provided on create. +func (u *AnnouncementUpsert) UpdateStatus() *AnnouncementUpsert { + u.SetExcluded(announcement.FieldStatus) + return u +} + +// SetTargeting sets the "targeting" field. +func (u *AnnouncementUpsert) SetTargeting(v domain.AnnouncementTargeting) *AnnouncementUpsert { + u.Set(announcement.FieldTargeting, v) + return u +} + +// UpdateTargeting sets the "targeting" field to the value that was provided on create. +func (u *AnnouncementUpsert) UpdateTargeting() *AnnouncementUpsert { + u.SetExcluded(announcement.FieldTargeting) + return u +} + +// ClearTargeting clears the value of the "targeting" field. +func (u *AnnouncementUpsert) ClearTargeting() *AnnouncementUpsert { + u.SetNull(announcement.FieldTargeting) + return u +} + +// SetStartsAt sets the "starts_at" field. +func (u *AnnouncementUpsert) SetStartsAt(v time.Time) *AnnouncementUpsert { + u.Set(announcement.FieldStartsAt, v) + return u +} + +// UpdateStartsAt sets the "starts_at" field to the value that was provided on create. +func (u *AnnouncementUpsert) UpdateStartsAt() *AnnouncementUpsert { + u.SetExcluded(announcement.FieldStartsAt) + return u +} + +// ClearStartsAt clears the value of the "starts_at" field. +func (u *AnnouncementUpsert) ClearStartsAt() *AnnouncementUpsert { + u.SetNull(announcement.FieldStartsAt) + return u +} + +// SetEndsAt sets the "ends_at" field. +func (u *AnnouncementUpsert) SetEndsAt(v time.Time) *AnnouncementUpsert { + u.Set(announcement.FieldEndsAt, v) + return u +} + +// UpdateEndsAt sets the "ends_at" field to the value that was provided on create. +func (u *AnnouncementUpsert) UpdateEndsAt() *AnnouncementUpsert { + u.SetExcluded(announcement.FieldEndsAt) + return u +} + +// ClearEndsAt clears the value of the "ends_at" field. +func (u *AnnouncementUpsert) ClearEndsAt() *AnnouncementUpsert { + u.SetNull(announcement.FieldEndsAt) + return u +} + +// SetCreatedBy sets the "created_by" field. +func (u *AnnouncementUpsert) SetCreatedBy(v int64) *AnnouncementUpsert { + u.Set(announcement.FieldCreatedBy, v) + return u +} + +// UpdateCreatedBy sets the "created_by" field to the value that was provided on create. +func (u *AnnouncementUpsert) UpdateCreatedBy() *AnnouncementUpsert { + u.SetExcluded(announcement.FieldCreatedBy) + return u +} + +// AddCreatedBy adds v to the "created_by" field. +func (u *AnnouncementUpsert) AddCreatedBy(v int64) *AnnouncementUpsert { + u.Add(announcement.FieldCreatedBy, v) + return u +} + +// ClearCreatedBy clears the value of the "created_by" field. +func (u *AnnouncementUpsert) ClearCreatedBy() *AnnouncementUpsert { + u.SetNull(announcement.FieldCreatedBy) + return u +} + +// SetUpdatedBy sets the "updated_by" field. +func (u *AnnouncementUpsert) SetUpdatedBy(v int64) *AnnouncementUpsert { + u.Set(announcement.FieldUpdatedBy, v) + return u +} + +// UpdateUpdatedBy sets the "updated_by" field to the value that was provided on create. +func (u *AnnouncementUpsert) UpdateUpdatedBy() *AnnouncementUpsert { + u.SetExcluded(announcement.FieldUpdatedBy) + return u +} + +// AddUpdatedBy adds v to the "updated_by" field. +func (u *AnnouncementUpsert) AddUpdatedBy(v int64) *AnnouncementUpsert { + u.Add(announcement.FieldUpdatedBy, v) + return u +} + +// ClearUpdatedBy clears the value of the "updated_by" field. +func (u *AnnouncementUpsert) ClearUpdatedBy() *AnnouncementUpsert { + u.SetNull(announcement.FieldUpdatedBy) + return u +} + +// SetUpdatedAt sets the "updated_at" field. +func (u *AnnouncementUpsert) SetUpdatedAt(v time.Time) *AnnouncementUpsert { + u.Set(announcement.FieldUpdatedAt, v) + return u +} + +// UpdateUpdatedAt sets the "updated_at" field to the value that was provided on create. +func (u *AnnouncementUpsert) UpdateUpdatedAt() *AnnouncementUpsert { + u.SetExcluded(announcement.FieldUpdatedAt) + return u +} + +// UpdateNewValues updates the mutable fields using the new values that were set on create. +// Using this option is equivalent to using: +// +// client.Announcement.Create(). +// OnConflict( +// sql.ResolveWithNewValues(), +// ). +// Exec(ctx) +func (u *AnnouncementUpsertOne) UpdateNewValues() *AnnouncementUpsertOne { + u.create.conflict = append(u.create.conflict, sql.ResolveWithNewValues()) + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(s *sql.UpdateSet) { + if _, exists := u.create.mutation.CreatedAt(); exists { + s.SetIgnore(announcement.FieldCreatedAt) + } + })) + return u +} + +// Ignore sets each column to itself in case of conflict. +// Using this option is equivalent to using: +// +// client.Announcement.Create(). +// OnConflict(sql.ResolveWithIgnore()). +// Exec(ctx) +func (u *AnnouncementUpsertOne) Ignore() *AnnouncementUpsertOne { + u.create.conflict = append(u.create.conflict, sql.ResolveWithIgnore()) + return u +} + +// DoNothing configures the conflict_action to `DO NOTHING`. +// Supported only by SQLite and PostgreSQL. +func (u *AnnouncementUpsertOne) DoNothing() *AnnouncementUpsertOne { + u.create.conflict = append(u.create.conflict, sql.DoNothing()) + return u +} + +// Update allows overriding fields `UPDATE` values. See the AnnouncementCreate.OnConflict +// documentation for more info. +func (u *AnnouncementUpsertOne) Update(set func(*AnnouncementUpsert)) *AnnouncementUpsertOne { + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) { + set(&AnnouncementUpsert{UpdateSet: update}) + })) + return u +} + +// SetTitle sets the "title" field. +func (u *AnnouncementUpsertOne) SetTitle(v string) *AnnouncementUpsertOne { + return u.Update(func(s *AnnouncementUpsert) { + s.SetTitle(v) + }) +} + +// UpdateTitle sets the "title" field to the value that was provided on create. +func (u *AnnouncementUpsertOne) UpdateTitle() *AnnouncementUpsertOne { + return u.Update(func(s *AnnouncementUpsert) { + s.UpdateTitle() + }) +} + +// SetContent sets the "content" field. +func (u *AnnouncementUpsertOne) SetContent(v string) *AnnouncementUpsertOne { + return u.Update(func(s *AnnouncementUpsert) { + s.SetContent(v) + }) +} + +// UpdateContent sets the "content" field to the value that was provided on create. +func (u *AnnouncementUpsertOne) UpdateContent() *AnnouncementUpsertOne { + return u.Update(func(s *AnnouncementUpsert) { + s.UpdateContent() + }) +} + +// SetStatus sets the "status" field. +func (u *AnnouncementUpsertOne) SetStatus(v string) *AnnouncementUpsertOne { + return u.Update(func(s *AnnouncementUpsert) { + s.SetStatus(v) + }) +} + +// UpdateStatus sets the "status" field to the value that was provided on create. +func (u *AnnouncementUpsertOne) UpdateStatus() *AnnouncementUpsertOne { + return u.Update(func(s *AnnouncementUpsert) { + s.UpdateStatus() + }) +} + +// SetTargeting sets the "targeting" field. +func (u *AnnouncementUpsertOne) SetTargeting(v domain.AnnouncementTargeting) *AnnouncementUpsertOne { + return u.Update(func(s *AnnouncementUpsert) { + s.SetTargeting(v) + }) +} + +// UpdateTargeting sets the "targeting" field to the value that was provided on create. +func (u *AnnouncementUpsertOne) UpdateTargeting() *AnnouncementUpsertOne { + return u.Update(func(s *AnnouncementUpsert) { + s.UpdateTargeting() + }) +} + +// ClearTargeting clears the value of the "targeting" field. +func (u *AnnouncementUpsertOne) ClearTargeting() *AnnouncementUpsertOne { + return u.Update(func(s *AnnouncementUpsert) { + s.ClearTargeting() + }) +} + +// SetStartsAt sets the "starts_at" field. +func (u *AnnouncementUpsertOne) SetStartsAt(v time.Time) *AnnouncementUpsertOne { + return u.Update(func(s *AnnouncementUpsert) { + s.SetStartsAt(v) + }) +} + +// UpdateStartsAt sets the "starts_at" field to the value that was provided on create. +func (u *AnnouncementUpsertOne) UpdateStartsAt() *AnnouncementUpsertOne { + return u.Update(func(s *AnnouncementUpsert) { + s.UpdateStartsAt() + }) +} + +// ClearStartsAt clears the value of the "starts_at" field. +func (u *AnnouncementUpsertOne) ClearStartsAt() *AnnouncementUpsertOne { + return u.Update(func(s *AnnouncementUpsert) { + s.ClearStartsAt() + }) +} + +// SetEndsAt sets the "ends_at" field. +func (u *AnnouncementUpsertOne) SetEndsAt(v time.Time) *AnnouncementUpsertOne { + return u.Update(func(s *AnnouncementUpsert) { + s.SetEndsAt(v) + }) +} + +// UpdateEndsAt sets the "ends_at" field to the value that was provided on create. +func (u *AnnouncementUpsertOne) UpdateEndsAt() *AnnouncementUpsertOne { + return u.Update(func(s *AnnouncementUpsert) { + s.UpdateEndsAt() + }) +} + +// ClearEndsAt clears the value of the "ends_at" field. +func (u *AnnouncementUpsertOne) ClearEndsAt() *AnnouncementUpsertOne { + return u.Update(func(s *AnnouncementUpsert) { + s.ClearEndsAt() + }) +} + +// SetCreatedBy sets the "created_by" field. +func (u *AnnouncementUpsertOne) SetCreatedBy(v int64) *AnnouncementUpsertOne { + return u.Update(func(s *AnnouncementUpsert) { + s.SetCreatedBy(v) + }) +} + +// AddCreatedBy adds v to the "created_by" field. +func (u *AnnouncementUpsertOne) AddCreatedBy(v int64) *AnnouncementUpsertOne { + return u.Update(func(s *AnnouncementUpsert) { + s.AddCreatedBy(v) + }) +} + +// UpdateCreatedBy sets the "created_by" field to the value that was provided on create. +func (u *AnnouncementUpsertOne) UpdateCreatedBy() *AnnouncementUpsertOne { + return u.Update(func(s *AnnouncementUpsert) { + s.UpdateCreatedBy() + }) +} + +// ClearCreatedBy clears the value of the "created_by" field. +func (u *AnnouncementUpsertOne) ClearCreatedBy() *AnnouncementUpsertOne { + return u.Update(func(s *AnnouncementUpsert) { + s.ClearCreatedBy() + }) +} + +// SetUpdatedBy sets the "updated_by" field. +func (u *AnnouncementUpsertOne) SetUpdatedBy(v int64) *AnnouncementUpsertOne { + return u.Update(func(s *AnnouncementUpsert) { + s.SetUpdatedBy(v) + }) +} + +// AddUpdatedBy adds v to the "updated_by" field. +func (u *AnnouncementUpsertOne) AddUpdatedBy(v int64) *AnnouncementUpsertOne { + return u.Update(func(s *AnnouncementUpsert) { + s.AddUpdatedBy(v) + }) +} + +// UpdateUpdatedBy sets the "updated_by" field to the value that was provided on create. +func (u *AnnouncementUpsertOne) UpdateUpdatedBy() *AnnouncementUpsertOne { + return u.Update(func(s *AnnouncementUpsert) { + s.UpdateUpdatedBy() + }) +} + +// ClearUpdatedBy clears the value of the "updated_by" field. +func (u *AnnouncementUpsertOne) ClearUpdatedBy() *AnnouncementUpsertOne { + return u.Update(func(s *AnnouncementUpsert) { + s.ClearUpdatedBy() + }) +} + +// SetUpdatedAt sets the "updated_at" field. +func (u *AnnouncementUpsertOne) SetUpdatedAt(v time.Time) *AnnouncementUpsertOne { + return u.Update(func(s *AnnouncementUpsert) { + s.SetUpdatedAt(v) + }) +} + +// UpdateUpdatedAt sets the "updated_at" field to the value that was provided on create. +func (u *AnnouncementUpsertOne) UpdateUpdatedAt() *AnnouncementUpsertOne { + return u.Update(func(s *AnnouncementUpsert) { + s.UpdateUpdatedAt() + }) +} + +// Exec executes the query. +func (u *AnnouncementUpsertOne) Exec(ctx context.Context) error { + if len(u.create.conflict) == 0 { + return errors.New("ent: missing options for AnnouncementCreate.OnConflict") + } + return u.create.Exec(ctx) +} + +// ExecX is like Exec, but panics if an error occurs. +func (u *AnnouncementUpsertOne) ExecX(ctx context.Context) { + if err := u.create.Exec(ctx); err != nil { + panic(err) + } +} + +// Exec executes the UPSERT query and returns the inserted/updated ID. +func (u *AnnouncementUpsertOne) ID(ctx context.Context) (id int64, err error) { + node, err := u.create.Save(ctx) + if err != nil { + return id, err + } + return node.ID, nil +} + +// IDX is like ID, but panics if an error occurs. +func (u *AnnouncementUpsertOne) IDX(ctx context.Context) int64 { + id, err := u.ID(ctx) + if err != nil { + panic(err) + } + return id +} + +// AnnouncementCreateBulk is the builder for creating many Announcement entities in bulk. +type AnnouncementCreateBulk struct { + config + err error + builders []*AnnouncementCreate + conflict []sql.ConflictOption +} + +// Save creates the Announcement entities in the database. +func (_c *AnnouncementCreateBulk) Save(ctx context.Context) ([]*Announcement, error) { + if _c.err != nil { + return nil, _c.err + } + specs := make([]*sqlgraph.CreateSpec, len(_c.builders)) + nodes := make([]*Announcement, len(_c.builders)) + mutators := make([]Mutator, len(_c.builders)) + for i := range _c.builders { + func(i int, root context.Context) { + builder := _c.builders[i] + builder.defaults() + var mut Mutator = MutateFunc(func(ctx context.Context, m Mutation) (Value, error) { + mutation, ok := m.(*AnnouncementMutation) + if !ok { + return nil, fmt.Errorf("unexpected mutation type %T", m) + } + if err := builder.check(); err != nil { + return nil, err + } + builder.mutation = mutation + var err error + nodes[i], specs[i] = builder.createSpec() + if i < len(mutators)-1 { + _, err = mutators[i+1].Mutate(root, _c.builders[i+1].mutation) + } else { + spec := &sqlgraph.BatchCreateSpec{Nodes: specs} + spec.OnConflict = _c.conflict + // Invoke the actual operation on the latest mutation in the chain. + if err = sqlgraph.BatchCreate(ctx, _c.driver, spec); err != nil { + if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + } + } + if err != nil { + return nil, err + } + mutation.id = &nodes[i].ID + if specs[i].ID.Value != nil { + id := specs[i].ID.Value.(int64) + nodes[i].ID = int64(id) + } + mutation.done = true + return nodes[i], nil + }) + for i := len(builder.hooks) - 1; i >= 0; i-- { + mut = builder.hooks[i](mut) + } + mutators[i] = mut + }(i, ctx) + } + if len(mutators) > 0 { + if _, err := mutators[0].Mutate(ctx, _c.builders[0].mutation); err != nil { + return nil, err + } + } + return nodes, nil +} + +// SaveX is like Save, but panics if an error occurs. +func (_c *AnnouncementCreateBulk) SaveX(ctx context.Context) []*Announcement { + v, err := _c.Save(ctx) + if err != nil { + panic(err) + } + return v +} + +// Exec executes the query. +func (_c *AnnouncementCreateBulk) Exec(ctx context.Context) error { + _, err := _c.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_c *AnnouncementCreateBulk) ExecX(ctx context.Context) { + if err := _c.Exec(ctx); err != nil { + panic(err) + } +} + +// OnConflict allows configuring the `ON CONFLICT` / `ON DUPLICATE KEY` clause +// of the `INSERT` statement. For example: +// +// client.Announcement.CreateBulk(builders...). +// OnConflict( +// // Update the row with the new values +// // the was proposed for insertion. +// sql.ResolveWithNewValues(), +// ). +// // Override some of the fields with custom +// // update values. +// Update(func(u *ent.AnnouncementUpsert) { +// SetTitle(v+v). +// }). +// Exec(ctx) +func (_c *AnnouncementCreateBulk) OnConflict(opts ...sql.ConflictOption) *AnnouncementUpsertBulk { + _c.conflict = opts + return &AnnouncementUpsertBulk{ + create: _c, + } +} + +// OnConflictColumns calls `OnConflict` and configures the columns +// as conflict target. Using this option is equivalent to using: +// +// client.Announcement.Create(). +// OnConflict(sql.ConflictColumns(columns...)). +// Exec(ctx) +func (_c *AnnouncementCreateBulk) OnConflictColumns(columns ...string) *AnnouncementUpsertBulk { + _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...)) + return &AnnouncementUpsertBulk{ + create: _c, + } +} + +// AnnouncementUpsertBulk is the builder for "upsert"-ing +// a bulk of Announcement nodes. +type AnnouncementUpsertBulk struct { + create *AnnouncementCreateBulk +} + +// UpdateNewValues updates the mutable fields using the new values that +// were set on create. Using this option is equivalent to using: +// +// client.Announcement.Create(). +// OnConflict( +// sql.ResolveWithNewValues(), +// ). +// Exec(ctx) +func (u *AnnouncementUpsertBulk) UpdateNewValues() *AnnouncementUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.ResolveWithNewValues()) + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(s *sql.UpdateSet) { + for _, b := range u.create.builders { + if _, exists := b.mutation.CreatedAt(); exists { + s.SetIgnore(announcement.FieldCreatedAt) + } + } + })) + return u +} + +// Ignore sets each column to itself in case of conflict. +// Using this option is equivalent to using: +// +// client.Announcement.Create(). +// OnConflict(sql.ResolveWithIgnore()). +// Exec(ctx) +func (u *AnnouncementUpsertBulk) Ignore() *AnnouncementUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.ResolveWithIgnore()) + return u +} + +// DoNothing configures the conflict_action to `DO NOTHING`. +// Supported only by SQLite and PostgreSQL. +func (u *AnnouncementUpsertBulk) DoNothing() *AnnouncementUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.DoNothing()) + return u +} + +// Update allows overriding fields `UPDATE` values. See the AnnouncementCreateBulk.OnConflict +// documentation for more info. +func (u *AnnouncementUpsertBulk) Update(set func(*AnnouncementUpsert)) *AnnouncementUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) { + set(&AnnouncementUpsert{UpdateSet: update}) + })) + return u +} + +// SetTitle sets the "title" field. +func (u *AnnouncementUpsertBulk) SetTitle(v string) *AnnouncementUpsertBulk { + return u.Update(func(s *AnnouncementUpsert) { + s.SetTitle(v) + }) +} + +// UpdateTitle sets the "title" field to the value that was provided on create. +func (u *AnnouncementUpsertBulk) UpdateTitle() *AnnouncementUpsertBulk { + return u.Update(func(s *AnnouncementUpsert) { + s.UpdateTitle() + }) +} + +// SetContent sets the "content" field. +func (u *AnnouncementUpsertBulk) SetContent(v string) *AnnouncementUpsertBulk { + return u.Update(func(s *AnnouncementUpsert) { + s.SetContent(v) + }) +} + +// UpdateContent sets the "content" field to the value that was provided on create. +func (u *AnnouncementUpsertBulk) UpdateContent() *AnnouncementUpsertBulk { + return u.Update(func(s *AnnouncementUpsert) { + s.UpdateContent() + }) +} + +// SetStatus sets the "status" field. +func (u *AnnouncementUpsertBulk) SetStatus(v string) *AnnouncementUpsertBulk { + return u.Update(func(s *AnnouncementUpsert) { + s.SetStatus(v) + }) +} + +// UpdateStatus sets the "status" field to the value that was provided on create. +func (u *AnnouncementUpsertBulk) UpdateStatus() *AnnouncementUpsertBulk { + return u.Update(func(s *AnnouncementUpsert) { + s.UpdateStatus() + }) +} + +// SetTargeting sets the "targeting" field. +func (u *AnnouncementUpsertBulk) SetTargeting(v domain.AnnouncementTargeting) *AnnouncementUpsertBulk { + return u.Update(func(s *AnnouncementUpsert) { + s.SetTargeting(v) + }) +} + +// UpdateTargeting sets the "targeting" field to the value that was provided on create. +func (u *AnnouncementUpsertBulk) UpdateTargeting() *AnnouncementUpsertBulk { + return u.Update(func(s *AnnouncementUpsert) { + s.UpdateTargeting() + }) +} + +// ClearTargeting clears the value of the "targeting" field. +func (u *AnnouncementUpsertBulk) ClearTargeting() *AnnouncementUpsertBulk { + return u.Update(func(s *AnnouncementUpsert) { + s.ClearTargeting() + }) +} + +// SetStartsAt sets the "starts_at" field. +func (u *AnnouncementUpsertBulk) SetStartsAt(v time.Time) *AnnouncementUpsertBulk { + return u.Update(func(s *AnnouncementUpsert) { + s.SetStartsAt(v) + }) +} + +// UpdateStartsAt sets the "starts_at" field to the value that was provided on create. +func (u *AnnouncementUpsertBulk) UpdateStartsAt() *AnnouncementUpsertBulk { + return u.Update(func(s *AnnouncementUpsert) { + s.UpdateStartsAt() + }) +} + +// ClearStartsAt clears the value of the "starts_at" field. +func (u *AnnouncementUpsertBulk) ClearStartsAt() *AnnouncementUpsertBulk { + return u.Update(func(s *AnnouncementUpsert) { + s.ClearStartsAt() + }) +} + +// SetEndsAt sets the "ends_at" field. +func (u *AnnouncementUpsertBulk) SetEndsAt(v time.Time) *AnnouncementUpsertBulk { + return u.Update(func(s *AnnouncementUpsert) { + s.SetEndsAt(v) + }) +} + +// UpdateEndsAt sets the "ends_at" field to the value that was provided on create. +func (u *AnnouncementUpsertBulk) UpdateEndsAt() *AnnouncementUpsertBulk { + return u.Update(func(s *AnnouncementUpsert) { + s.UpdateEndsAt() + }) +} + +// ClearEndsAt clears the value of the "ends_at" field. +func (u *AnnouncementUpsertBulk) ClearEndsAt() *AnnouncementUpsertBulk { + return u.Update(func(s *AnnouncementUpsert) { + s.ClearEndsAt() + }) +} + +// SetCreatedBy sets the "created_by" field. +func (u *AnnouncementUpsertBulk) SetCreatedBy(v int64) *AnnouncementUpsertBulk { + return u.Update(func(s *AnnouncementUpsert) { + s.SetCreatedBy(v) + }) +} + +// AddCreatedBy adds v to the "created_by" field. +func (u *AnnouncementUpsertBulk) AddCreatedBy(v int64) *AnnouncementUpsertBulk { + return u.Update(func(s *AnnouncementUpsert) { + s.AddCreatedBy(v) + }) +} + +// UpdateCreatedBy sets the "created_by" field to the value that was provided on create. +func (u *AnnouncementUpsertBulk) UpdateCreatedBy() *AnnouncementUpsertBulk { + return u.Update(func(s *AnnouncementUpsert) { + s.UpdateCreatedBy() + }) +} + +// ClearCreatedBy clears the value of the "created_by" field. +func (u *AnnouncementUpsertBulk) ClearCreatedBy() *AnnouncementUpsertBulk { + return u.Update(func(s *AnnouncementUpsert) { + s.ClearCreatedBy() + }) +} + +// SetUpdatedBy sets the "updated_by" field. +func (u *AnnouncementUpsertBulk) SetUpdatedBy(v int64) *AnnouncementUpsertBulk { + return u.Update(func(s *AnnouncementUpsert) { + s.SetUpdatedBy(v) + }) +} + +// AddUpdatedBy adds v to the "updated_by" field. +func (u *AnnouncementUpsertBulk) AddUpdatedBy(v int64) *AnnouncementUpsertBulk { + return u.Update(func(s *AnnouncementUpsert) { + s.AddUpdatedBy(v) + }) +} + +// UpdateUpdatedBy sets the "updated_by" field to the value that was provided on create. +func (u *AnnouncementUpsertBulk) UpdateUpdatedBy() *AnnouncementUpsertBulk { + return u.Update(func(s *AnnouncementUpsert) { + s.UpdateUpdatedBy() + }) +} + +// ClearUpdatedBy clears the value of the "updated_by" field. +func (u *AnnouncementUpsertBulk) ClearUpdatedBy() *AnnouncementUpsertBulk { + return u.Update(func(s *AnnouncementUpsert) { + s.ClearUpdatedBy() + }) +} + +// SetUpdatedAt sets the "updated_at" field. +func (u *AnnouncementUpsertBulk) SetUpdatedAt(v time.Time) *AnnouncementUpsertBulk { + return u.Update(func(s *AnnouncementUpsert) { + s.SetUpdatedAt(v) + }) +} + +// UpdateUpdatedAt sets the "updated_at" field to the value that was provided on create. +func (u *AnnouncementUpsertBulk) UpdateUpdatedAt() *AnnouncementUpsertBulk { + return u.Update(func(s *AnnouncementUpsert) { + s.UpdateUpdatedAt() + }) +} + +// Exec executes the query. +func (u *AnnouncementUpsertBulk) Exec(ctx context.Context) error { + if u.create.err != nil { + return u.create.err + } + for i, b := range u.create.builders { + if len(b.conflict) != 0 { + return fmt.Errorf("ent: OnConflict was set for builder %d. Set it on the AnnouncementCreateBulk instead", i) + } + } + if len(u.create.conflict) == 0 { + return errors.New("ent: missing options for AnnouncementCreateBulk.OnConflict") + } + return u.create.Exec(ctx) +} + +// ExecX is like Exec, but panics if an error occurs. +func (u *AnnouncementUpsertBulk) ExecX(ctx context.Context) { + if err := u.create.Exec(ctx); err != nil { + panic(err) + } +} diff --git a/backend/ent/announcement_delete.go b/backend/ent/announcement_delete.go new file mode 100644 index 00000000..d185e9f7 --- /dev/null +++ b/backend/ent/announcement_delete.go @@ -0,0 +1,88 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "context" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/Wei-Shaw/sub2api/ent/announcement" + "github.com/Wei-Shaw/sub2api/ent/predicate" +) + +// AnnouncementDelete is the builder for deleting a Announcement entity. +type AnnouncementDelete struct { + config + hooks []Hook + mutation *AnnouncementMutation +} + +// Where appends a list predicates to the AnnouncementDelete builder. +func (_d *AnnouncementDelete) Where(ps ...predicate.Announcement) *AnnouncementDelete { + _d.mutation.Where(ps...) + return _d +} + +// Exec executes the deletion query and returns how many vertices were deleted. +func (_d *AnnouncementDelete) Exec(ctx context.Context) (int, error) { + return withHooks(ctx, _d.sqlExec, _d.mutation, _d.hooks) +} + +// ExecX is like Exec, but panics if an error occurs. +func (_d *AnnouncementDelete) ExecX(ctx context.Context) int { + n, err := _d.Exec(ctx) + if err != nil { + panic(err) + } + return n +} + +func (_d *AnnouncementDelete) sqlExec(ctx context.Context) (int, error) { + _spec := sqlgraph.NewDeleteSpec(announcement.Table, sqlgraph.NewFieldSpec(announcement.FieldID, field.TypeInt64)) + if ps := _d.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + affected, err := sqlgraph.DeleteNodes(ctx, _d.driver, _spec) + if err != nil && sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + _d.mutation.done = true + return affected, err +} + +// AnnouncementDeleteOne is the builder for deleting a single Announcement entity. +type AnnouncementDeleteOne struct { + _d *AnnouncementDelete +} + +// Where appends a list predicates to the AnnouncementDelete builder. +func (_d *AnnouncementDeleteOne) Where(ps ...predicate.Announcement) *AnnouncementDeleteOne { + _d._d.mutation.Where(ps...) + return _d +} + +// Exec executes the deletion query. +func (_d *AnnouncementDeleteOne) Exec(ctx context.Context) error { + n, err := _d._d.Exec(ctx) + switch { + case err != nil: + return err + case n == 0: + return &NotFoundError{announcement.Label} + default: + return nil + } +} + +// ExecX is like Exec, but panics if an error occurs. +func (_d *AnnouncementDeleteOne) ExecX(ctx context.Context) { + if err := _d.Exec(ctx); err != nil { + panic(err) + } +} diff --git a/backend/ent/announcement_query.go b/backend/ent/announcement_query.go new file mode 100644 index 00000000..a27d50fa --- /dev/null +++ b/backend/ent/announcement_query.go @@ -0,0 +1,643 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "context" + "database/sql/driver" + "fmt" + "math" + + "entgo.io/ent" + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/Wei-Shaw/sub2api/ent/announcement" + "github.com/Wei-Shaw/sub2api/ent/announcementread" + "github.com/Wei-Shaw/sub2api/ent/predicate" +) + +// AnnouncementQuery is the builder for querying Announcement entities. +type AnnouncementQuery struct { + config + ctx *QueryContext + order []announcement.OrderOption + inters []Interceptor + predicates []predicate.Announcement + withReads *AnnouncementReadQuery + modifiers []func(*sql.Selector) + // intermediate query (i.e. traversal path). + sql *sql.Selector + path func(context.Context) (*sql.Selector, error) +} + +// Where adds a new predicate for the AnnouncementQuery builder. +func (_q *AnnouncementQuery) Where(ps ...predicate.Announcement) *AnnouncementQuery { + _q.predicates = append(_q.predicates, ps...) + return _q +} + +// Limit the number of records to be returned by this query. +func (_q *AnnouncementQuery) Limit(limit int) *AnnouncementQuery { + _q.ctx.Limit = &limit + return _q +} + +// Offset to start from. +func (_q *AnnouncementQuery) Offset(offset int) *AnnouncementQuery { + _q.ctx.Offset = &offset + return _q +} + +// Unique configures the query builder to filter duplicate records on query. +// By default, unique is set to true, and can be disabled using this method. +func (_q *AnnouncementQuery) Unique(unique bool) *AnnouncementQuery { + _q.ctx.Unique = &unique + return _q +} + +// Order specifies how the records should be ordered. +func (_q *AnnouncementQuery) Order(o ...announcement.OrderOption) *AnnouncementQuery { + _q.order = append(_q.order, o...) + return _q +} + +// QueryReads chains the current query on the "reads" edge. +func (_q *AnnouncementQuery) QueryReads() *AnnouncementReadQuery { + query := (&AnnouncementReadClient{config: _q.config}).Query() + query.path = func(ctx context.Context) (fromU *sql.Selector, err error) { + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + selector := _q.sqlQuery(ctx) + if err := selector.Err(); err != nil { + return nil, err + } + step := sqlgraph.NewStep( + sqlgraph.From(announcement.Table, announcement.FieldID, selector), + sqlgraph.To(announcementread.Table, announcementread.FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, announcement.ReadsTable, announcement.ReadsColumn), + ) + fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step) + return fromU, nil + } + return query +} + +// First returns the first Announcement entity from the query. +// Returns a *NotFoundError when no Announcement was found. +func (_q *AnnouncementQuery) First(ctx context.Context) (*Announcement, error) { + nodes, err := _q.Limit(1).All(setContextOp(ctx, _q.ctx, ent.OpQueryFirst)) + if err != nil { + return nil, err + } + if len(nodes) == 0 { + return nil, &NotFoundError{announcement.Label} + } + return nodes[0], nil +} + +// FirstX is like First, but panics if an error occurs. +func (_q *AnnouncementQuery) FirstX(ctx context.Context) *Announcement { + node, err := _q.First(ctx) + if err != nil && !IsNotFound(err) { + panic(err) + } + return node +} + +// FirstID returns the first Announcement ID from the query. +// Returns a *NotFoundError when no Announcement ID was found. +func (_q *AnnouncementQuery) FirstID(ctx context.Context) (id int64, err error) { + var ids []int64 + if ids, err = _q.Limit(1).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryFirstID)); err != nil { + return + } + if len(ids) == 0 { + err = &NotFoundError{announcement.Label} + return + } + return ids[0], nil +} + +// FirstIDX is like FirstID, but panics if an error occurs. +func (_q *AnnouncementQuery) FirstIDX(ctx context.Context) int64 { + id, err := _q.FirstID(ctx) + if err != nil && !IsNotFound(err) { + panic(err) + } + return id +} + +// Only returns a single Announcement entity found by the query, ensuring it only returns one. +// Returns a *NotSingularError when more than one Announcement entity is found. +// Returns a *NotFoundError when no Announcement entities are found. +func (_q *AnnouncementQuery) Only(ctx context.Context) (*Announcement, error) { + nodes, err := _q.Limit(2).All(setContextOp(ctx, _q.ctx, ent.OpQueryOnly)) + if err != nil { + return nil, err + } + switch len(nodes) { + case 1: + return nodes[0], nil + case 0: + return nil, &NotFoundError{announcement.Label} + default: + return nil, &NotSingularError{announcement.Label} + } +} + +// OnlyX is like Only, but panics if an error occurs. +func (_q *AnnouncementQuery) OnlyX(ctx context.Context) *Announcement { + node, err := _q.Only(ctx) + if err != nil { + panic(err) + } + return node +} + +// OnlyID is like Only, but returns the only Announcement ID in the query. +// Returns a *NotSingularError when more than one Announcement ID is found. +// Returns a *NotFoundError when no entities are found. +func (_q *AnnouncementQuery) OnlyID(ctx context.Context) (id int64, err error) { + var ids []int64 + if ids, err = _q.Limit(2).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryOnlyID)); err != nil { + return + } + switch len(ids) { + case 1: + id = ids[0] + case 0: + err = &NotFoundError{announcement.Label} + default: + err = &NotSingularError{announcement.Label} + } + return +} + +// OnlyIDX is like OnlyID, but panics if an error occurs. +func (_q *AnnouncementQuery) OnlyIDX(ctx context.Context) int64 { + id, err := _q.OnlyID(ctx) + if err != nil { + panic(err) + } + return id +} + +// All executes the query and returns a list of Announcements. +func (_q *AnnouncementQuery) All(ctx context.Context) ([]*Announcement, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryAll) + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + qr := querierAll[[]*Announcement, *AnnouncementQuery]() + return withInterceptors[[]*Announcement](ctx, _q, qr, _q.inters) +} + +// AllX is like All, but panics if an error occurs. +func (_q *AnnouncementQuery) AllX(ctx context.Context) []*Announcement { + nodes, err := _q.All(ctx) + if err != nil { + panic(err) + } + return nodes +} + +// IDs executes the query and returns a list of Announcement IDs. +func (_q *AnnouncementQuery) IDs(ctx context.Context) (ids []int64, err error) { + if _q.ctx.Unique == nil && _q.path != nil { + _q.Unique(true) + } + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryIDs) + if err = _q.Select(announcement.FieldID).Scan(ctx, &ids); err != nil { + return nil, err + } + return ids, nil +} + +// IDsX is like IDs, but panics if an error occurs. +func (_q *AnnouncementQuery) IDsX(ctx context.Context) []int64 { + ids, err := _q.IDs(ctx) + if err != nil { + panic(err) + } + return ids +} + +// Count returns the count of the given query. +func (_q *AnnouncementQuery) Count(ctx context.Context) (int, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryCount) + if err := _q.prepareQuery(ctx); err != nil { + return 0, err + } + return withInterceptors[int](ctx, _q, querierCount[*AnnouncementQuery](), _q.inters) +} + +// CountX is like Count, but panics if an error occurs. +func (_q *AnnouncementQuery) CountX(ctx context.Context) int { + count, err := _q.Count(ctx) + if err != nil { + panic(err) + } + return count +} + +// Exist returns true if the query has elements in the graph. +func (_q *AnnouncementQuery) Exist(ctx context.Context) (bool, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryExist) + switch _, err := _q.FirstID(ctx); { + case IsNotFound(err): + return false, nil + case err != nil: + return false, fmt.Errorf("ent: check existence: %w", err) + default: + return true, nil + } +} + +// ExistX is like Exist, but panics if an error occurs. +func (_q *AnnouncementQuery) ExistX(ctx context.Context) bool { + exist, err := _q.Exist(ctx) + if err != nil { + panic(err) + } + return exist +} + +// Clone returns a duplicate of the AnnouncementQuery builder, including all associated steps. It can be +// used to prepare common query builders and use them differently after the clone is made. +func (_q *AnnouncementQuery) Clone() *AnnouncementQuery { + if _q == nil { + return nil + } + return &AnnouncementQuery{ + config: _q.config, + ctx: _q.ctx.Clone(), + order: append([]announcement.OrderOption{}, _q.order...), + inters: append([]Interceptor{}, _q.inters...), + predicates: append([]predicate.Announcement{}, _q.predicates...), + withReads: _q.withReads.Clone(), + // clone intermediate query. + sql: _q.sql.Clone(), + path: _q.path, + } +} + +// WithReads tells the query-builder to eager-load the nodes that are connected to +// the "reads" edge. The optional arguments are used to configure the query builder of the edge. +func (_q *AnnouncementQuery) WithReads(opts ...func(*AnnouncementReadQuery)) *AnnouncementQuery { + query := (&AnnouncementReadClient{config: _q.config}).Query() + for _, opt := range opts { + opt(query) + } + _q.withReads = query + return _q +} + +// GroupBy is used to group vertices by one or more fields/columns. +// It is often used with aggregate functions, like: count, max, mean, min, sum. +// +// Example: +// +// var v []struct { +// Title string `json:"title,omitempty"` +// Count int `json:"count,omitempty"` +// } +// +// client.Announcement.Query(). +// GroupBy(announcement.FieldTitle). +// Aggregate(ent.Count()). +// Scan(ctx, &v) +func (_q *AnnouncementQuery) GroupBy(field string, fields ...string) *AnnouncementGroupBy { + _q.ctx.Fields = append([]string{field}, fields...) + grbuild := &AnnouncementGroupBy{build: _q} + grbuild.flds = &_q.ctx.Fields + grbuild.label = announcement.Label + grbuild.scan = grbuild.Scan + return grbuild +} + +// Select allows the selection one or more fields/columns for the given query, +// instead of selecting all fields in the entity. +// +// Example: +// +// var v []struct { +// Title string `json:"title,omitempty"` +// } +// +// client.Announcement.Query(). +// Select(announcement.FieldTitle). +// Scan(ctx, &v) +func (_q *AnnouncementQuery) Select(fields ...string) *AnnouncementSelect { + _q.ctx.Fields = append(_q.ctx.Fields, fields...) + sbuild := &AnnouncementSelect{AnnouncementQuery: _q} + sbuild.label = announcement.Label + sbuild.flds, sbuild.scan = &_q.ctx.Fields, sbuild.Scan + return sbuild +} + +// Aggregate returns a AnnouncementSelect configured with the given aggregations. +func (_q *AnnouncementQuery) Aggregate(fns ...AggregateFunc) *AnnouncementSelect { + return _q.Select().Aggregate(fns...) +} + +func (_q *AnnouncementQuery) prepareQuery(ctx context.Context) error { + for _, inter := range _q.inters { + if inter == nil { + return fmt.Errorf("ent: uninitialized interceptor (forgotten import ent/runtime?)") + } + if trv, ok := inter.(Traverser); ok { + if err := trv.Traverse(ctx, _q); err != nil { + return err + } + } + } + for _, f := range _q.ctx.Fields { + if !announcement.ValidColumn(f) { + return &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)} + } + } + if _q.path != nil { + prev, err := _q.path(ctx) + if err != nil { + return err + } + _q.sql = prev + } + return nil +} + +func (_q *AnnouncementQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Announcement, error) { + var ( + nodes = []*Announcement{} + _spec = _q.querySpec() + loadedTypes = [1]bool{ + _q.withReads != nil, + } + ) + _spec.ScanValues = func(columns []string) ([]any, error) { + return (*Announcement).scanValues(nil, columns) + } + _spec.Assign = func(columns []string, values []any) error { + node := &Announcement{config: _q.config} + nodes = append(nodes, node) + node.Edges.loadedTypes = loadedTypes + return node.assignValues(columns, values) + } + if len(_q.modifiers) > 0 { + _spec.Modifiers = _q.modifiers + } + for i := range hooks { + hooks[i](ctx, _spec) + } + if err := sqlgraph.QueryNodes(ctx, _q.driver, _spec); err != nil { + return nil, err + } + if len(nodes) == 0 { + return nodes, nil + } + if query := _q.withReads; query != nil { + if err := _q.loadReads(ctx, query, nodes, + func(n *Announcement) { n.Edges.Reads = []*AnnouncementRead{} }, + func(n *Announcement, e *AnnouncementRead) { n.Edges.Reads = append(n.Edges.Reads, e) }); err != nil { + return nil, err + } + } + return nodes, nil +} + +func (_q *AnnouncementQuery) loadReads(ctx context.Context, query *AnnouncementReadQuery, nodes []*Announcement, init func(*Announcement), assign func(*Announcement, *AnnouncementRead)) error { + fks := make([]driver.Value, 0, len(nodes)) + nodeids := make(map[int64]*Announcement) + for i := range nodes { + fks = append(fks, nodes[i].ID) + nodeids[nodes[i].ID] = nodes[i] + if init != nil { + init(nodes[i]) + } + } + if len(query.ctx.Fields) > 0 { + query.ctx.AppendFieldOnce(announcementread.FieldAnnouncementID) + } + query.Where(predicate.AnnouncementRead(func(s *sql.Selector) { + s.Where(sql.InValues(s.C(announcement.ReadsColumn), fks...)) + })) + neighbors, err := query.All(ctx) + if err != nil { + return err + } + for _, n := range neighbors { + fk := n.AnnouncementID + node, ok := nodeids[fk] + if !ok { + return fmt.Errorf(`unexpected referenced foreign-key "announcement_id" returned %v for node %v`, fk, n.ID) + } + assign(node, n) + } + return nil +} + +func (_q *AnnouncementQuery) sqlCount(ctx context.Context) (int, error) { + _spec := _q.querySpec() + if len(_q.modifiers) > 0 { + _spec.Modifiers = _q.modifiers + } + _spec.Node.Columns = _q.ctx.Fields + if len(_q.ctx.Fields) > 0 { + _spec.Unique = _q.ctx.Unique != nil && *_q.ctx.Unique + } + return sqlgraph.CountNodes(ctx, _q.driver, _spec) +} + +func (_q *AnnouncementQuery) querySpec() *sqlgraph.QuerySpec { + _spec := sqlgraph.NewQuerySpec(announcement.Table, announcement.Columns, sqlgraph.NewFieldSpec(announcement.FieldID, field.TypeInt64)) + _spec.From = _q.sql + if unique := _q.ctx.Unique; unique != nil { + _spec.Unique = *unique + } else if _q.path != nil { + _spec.Unique = true + } + if fields := _q.ctx.Fields; len(fields) > 0 { + _spec.Node.Columns = make([]string, 0, len(fields)) + _spec.Node.Columns = append(_spec.Node.Columns, announcement.FieldID) + for i := range fields { + if fields[i] != announcement.FieldID { + _spec.Node.Columns = append(_spec.Node.Columns, fields[i]) + } + } + } + if ps := _q.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if limit := _q.ctx.Limit; limit != nil { + _spec.Limit = *limit + } + if offset := _q.ctx.Offset; offset != nil { + _spec.Offset = *offset + } + if ps := _q.order; len(ps) > 0 { + _spec.Order = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + return _spec +} + +func (_q *AnnouncementQuery) sqlQuery(ctx context.Context) *sql.Selector { + builder := sql.Dialect(_q.driver.Dialect()) + t1 := builder.Table(announcement.Table) + columns := _q.ctx.Fields + if len(columns) == 0 { + columns = announcement.Columns + } + selector := builder.Select(t1.Columns(columns...)...).From(t1) + if _q.sql != nil { + selector = _q.sql + selector.Select(selector.Columns(columns...)...) + } + if _q.ctx.Unique != nil && *_q.ctx.Unique { + selector.Distinct() + } + for _, m := range _q.modifiers { + m(selector) + } + for _, p := range _q.predicates { + p(selector) + } + for _, p := range _q.order { + p(selector) + } + if offset := _q.ctx.Offset; offset != nil { + // limit is mandatory for offset clause. We start + // with default value, and override it below if needed. + selector.Offset(*offset).Limit(math.MaxInt32) + } + if limit := _q.ctx.Limit; limit != nil { + selector.Limit(*limit) + } + return selector +} + +// ForUpdate locks the selected rows against concurrent updates, and prevent them from being +// updated, deleted or "selected ... for update" by other sessions, until the transaction is +// either committed or rolled-back. +func (_q *AnnouncementQuery) ForUpdate(opts ...sql.LockOption) *AnnouncementQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForUpdate(opts...) + }) + return _q +} + +// ForShare behaves similarly to ForUpdate, except that it acquires a shared mode lock +// on any rows that are read. Other sessions can read the rows, but cannot modify them +// until your transaction commits. +func (_q *AnnouncementQuery) ForShare(opts ...sql.LockOption) *AnnouncementQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForShare(opts...) + }) + return _q +} + +// AnnouncementGroupBy is the group-by builder for Announcement entities. +type AnnouncementGroupBy struct { + selector + build *AnnouncementQuery +} + +// Aggregate adds the given aggregation functions to the group-by query. +func (_g *AnnouncementGroupBy) Aggregate(fns ...AggregateFunc) *AnnouncementGroupBy { + _g.fns = append(_g.fns, fns...) + return _g +} + +// Scan applies the selector query and scans the result into the given value. +func (_g *AnnouncementGroupBy) Scan(ctx context.Context, v any) error { + ctx = setContextOp(ctx, _g.build.ctx, ent.OpQueryGroupBy) + if err := _g.build.prepareQuery(ctx); err != nil { + return err + } + return scanWithInterceptors[*AnnouncementQuery, *AnnouncementGroupBy](ctx, _g.build, _g, _g.build.inters, v) +} + +func (_g *AnnouncementGroupBy) sqlScan(ctx context.Context, root *AnnouncementQuery, v any) error { + selector := root.sqlQuery(ctx).Select() + aggregation := make([]string, 0, len(_g.fns)) + for _, fn := range _g.fns { + aggregation = append(aggregation, fn(selector)) + } + if len(selector.SelectedColumns()) == 0 { + columns := make([]string, 0, len(*_g.flds)+len(_g.fns)) + for _, f := range *_g.flds { + columns = append(columns, selector.C(f)) + } + columns = append(columns, aggregation...) + selector.Select(columns...) + } + selector.GroupBy(selector.Columns(*_g.flds...)...) + if err := selector.Err(); err != nil { + return err + } + rows := &sql.Rows{} + query, args := selector.Query() + if err := _g.build.driver.Query(ctx, query, args, rows); err != nil { + return err + } + defer rows.Close() + return sql.ScanSlice(rows, v) +} + +// AnnouncementSelect is the builder for selecting fields of Announcement entities. +type AnnouncementSelect struct { + *AnnouncementQuery + selector +} + +// Aggregate adds the given aggregation functions to the selector query. +func (_s *AnnouncementSelect) Aggregate(fns ...AggregateFunc) *AnnouncementSelect { + _s.fns = append(_s.fns, fns...) + return _s +} + +// Scan applies the selector query and scans the result into the given value. +func (_s *AnnouncementSelect) Scan(ctx context.Context, v any) error { + ctx = setContextOp(ctx, _s.ctx, ent.OpQuerySelect) + if err := _s.prepareQuery(ctx); err != nil { + return err + } + return scanWithInterceptors[*AnnouncementQuery, *AnnouncementSelect](ctx, _s.AnnouncementQuery, _s, _s.inters, v) +} + +func (_s *AnnouncementSelect) sqlScan(ctx context.Context, root *AnnouncementQuery, v any) error { + selector := root.sqlQuery(ctx) + aggregation := make([]string, 0, len(_s.fns)) + for _, fn := range _s.fns { + aggregation = append(aggregation, fn(selector)) + } + switch n := len(*_s.selector.flds); { + case n == 0 && len(aggregation) > 0: + selector.Select(aggregation...) + case n != 0 && len(aggregation) > 0: + selector.AppendSelect(aggregation...) + } + rows := &sql.Rows{} + query, args := selector.Query() + if err := _s.driver.Query(ctx, query, args, rows); err != nil { + return err + } + defer rows.Close() + return sql.ScanSlice(rows, v) +} diff --git a/backend/ent/announcement_update.go b/backend/ent/announcement_update.go new file mode 100644 index 00000000..702d0817 --- /dev/null +++ b/backend/ent/announcement_update.go @@ -0,0 +1,824 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "context" + "errors" + "fmt" + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/Wei-Shaw/sub2api/ent/announcement" + "github.com/Wei-Shaw/sub2api/ent/announcementread" + "github.com/Wei-Shaw/sub2api/ent/predicate" + "github.com/Wei-Shaw/sub2api/internal/domain" +) + +// AnnouncementUpdate is the builder for updating Announcement entities. +type AnnouncementUpdate struct { + config + hooks []Hook + mutation *AnnouncementMutation +} + +// Where appends a list predicates to the AnnouncementUpdate builder. +func (_u *AnnouncementUpdate) Where(ps ...predicate.Announcement) *AnnouncementUpdate { + _u.mutation.Where(ps...) + return _u +} + +// SetTitle sets the "title" field. +func (_u *AnnouncementUpdate) SetTitle(v string) *AnnouncementUpdate { + _u.mutation.SetTitle(v) + return _u +} + +// SetNillableTitle sets the "title" field if the given value is not nil. +func (_u *AnnouncementUpdate) SetNillableTitle(v *string) *AnnouncementUpdate { + if v != nil { + _u.SetTitle(*v) + } + return _u +} + +// SetContent sets the "content" field. +func (_u *AnnouncementUpdate) SetContent(v string) *AnnouncementUpdate { + _u.mutation.SetContent(v) + return _u +} + +// SetNillableContent sets the "content" field if the given value is not nil. +func (_u *AnnouncementUpdate) SetNillableContent(v *string) *AnnouncementUpdate { + if v != nil { + _u.SetContent(*v) + } + return _u +} + +// SetStatus sets the "status" field. +func (_u *AnnouncementUpdate) SetStatus(v string) *AnnouncementUpdate { + _u.mutation.SetStatus(v) + return _u +} + +// SetNillableStatus sets the "status" field if the given value is not nil. +func (_u *AnnouncementUpdate) SetNillableStatus(v *string) *AnnouncementUpdate { + if v != nil { + _u.SetStatus(*v) + } + return _u +} + +// SetTargeting sets the "targeting" field. +func (_u *AnnouncementUpdate) SetTargeting(v domain.AnnouncementTargeting) *AnnouncementUpdate { + _u.mutation.SetTargeting(v) + return _u +} + +// SetNillableTargeting sets the "targeting" field if the given value is not nil. +func (_u *AnnouncementUpdate) SetNillableTargeting(v *domain.AnnouncementTargeting) *AnnouncementUpdate { + if v != nil { + _u.SetTargeting(*v) + } + return _u +} + +// ClearTargeting clears the value of the "targeting" field. +func (_u *AnnouncementUpdate) ClearTargeting() *AnnouncementUpdate { + _u.mutation.ClearTargeting() + return _u +} + +// SetStartsAt sets the "starts_at" field. +func (_u *AnnouncementUpdate) SetStartsAt(v time.Time) *AnnouncementUpdate { + _u.mutation.SetStartsAt(v) + return _u +} + +// SetNillableStartsAt sets the "starts_at" field if the given value is not nil. +func (_u *AnnouncementUpdate) SetNillableStartsAt(v *time.Time) *AnnouncementUpdate { + if v != nil { + _u.SetStartsAt(*v) + } + return _u +} + +// ClearStartsAt clears the value of the "starts_at" field. +func (_u *AnnouncementUpdate) ClearStartsAt() *AnnouncementUpdate { + _u.mutation.ClearStartsAt() + return _u +} + +// SetEndsAt sets the "ends_at" field. +func (_u *AnnouncementUpdate) SetEndsAt(v time.Time) *AnnouncementUpdate { + _u.mutation.SetEndsAt(v) + return _u +} + +// SetNillableEndsAt sets the "ends_at" field if the given value is not nil. +func (_u *AnnouncementUpdate) SetNillableEndsAt(v *time.Time) *AnnouncementUpdate { + if v != nil { + _u.SetEndsAt(*v) + } + return _u +} + +// ClearEndsAt clears the value of the "ends_at" field. +func (_u *AnnouncementUpdate) ClearEndsAt() *AnnouncementUpdate { + _u.mutation.ClearEndsAt() + return _u +} + +// SetCreatedBy sets the "created_by" field. +func (_u *AnnouncementUpdate) SetCreatedBy(v int64) *AnnouncementUpdate { + _u.mutation.ResetCreatedBy() + _u.mutation.SetCreatedBy(v) + return _u +} + +// SetNillableCreatedBy sets the "created_by" field if the given value is not nil. +func (_u *AnnouncementUpdate) SetNillableCreatedBy(v *int64) *AnnouncementUpdate { + if v != nil { + _u.SetCreatedBy(*v) + } + return _u +} + +// AddCreatedBy adds value to the "created_by" field. +func (_u *AnnouncementUpdate) AddCreatedBy(v int64) *AnnouncementUpdate { + _u.mutation.AddCreatedBy(v) + return _u +} + +// ClearCreatedBy clears the value of the "created_by" field. +func (_u *AnnouncementUpdate) ClearCreatedBy() *AnnouncementUpdate { + _u.mutation.ClearCreatedBy() + return _u +} + +// SetUpdatedBy sets the "updated_by" field. +func (_u *AnnouncementUpdate) SetUpdatedBy(v int64) *AnnouncementUpdate { + _u.mutation.ResetUpdatedBy() + _u.mutation.SetUpdatedBy(v) + return _u +} + +// SetNillableUpdatedBy sets the "updated_by" field if the given value is not nil. +func (_u *AnnouncementUpdate) SetNillableUpdatedBy(v *int64) *AnnouncementUpdate { + if v != nil { + _u.SetUpdatedBy(*v) + } + return _u +} + +// AddUpdatedBy adds value to the "updated_by" field. +func (_u *AnnouncementUpdate) AddUpdatedBy(v int64) *AnnouncementUpdate { + _u.mutation.AddUpdatedBy(v) + return _u +} + +// ClearUpdatedBy clears the value of the "updated_by" field. +func (_u *AnnouncementUpdate) ClearUpdatedBy() *AnnouncementUpdate { + _u.mutation.ClearUpdatedBy() + return _u +} + +// SetUpdatedAt sets the "updated_at" field. +func (_u *AnnouncementUpdate) SetUpdatedAt(v time.Time) *AnnouncementUpdate { + _u.mutation.SetUpdatedAt(v) + return _u +} + +// AddReadIDs adds the "reads" edge to the AnnouncementRead entity by IDs. +func (_u *AnnouncementUpdate) AddReadIDs(ids ...int64) *AnnouncementUpdate { + _u.mutation.AddReadIDs(ids...) + return _u +} + +// AddReads adds the "reads" edges to the AnnouncementRead entity. +func (_u *AnnouncementUpdate) AddReads(v ...*AnnouncementRead) *AnnouncementUpdate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.AddReadIDs(ids...) +} + +// Mutation returns the AnnouncementMutation object of the builder. +func (_u *AnnouncementUpdate) Mutation() *AnnouncementMutation { + return _u.mutation +} + +// ClearReads clears all "reads" edges to the AnnouncementRead entity. +func (_u *AnnouncementUpdate) ClearReads() *AnnouncementUpdate { + _u.mutation.ClearReads() + return _u +} + +// RemoveReadIDs removes the "reads" edge to AnnouncementRead entities by IDs. +func (_u *AnnouncementUpdate) RemoveReadIDs(ids ...int64) *AnnouncementUpdate { + _u.mutation.RemoveReadIDs(ids...) + return _u +} + +// RemoveReads removes "reads" edges to AnnouncementRead entities. +func (_u *AnnouncementUpdate) RemoveReads(v ...*AnnouncementRead) *AnnouncementUpdate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.RemoveReadIDs(ids...) +} + +// Save executes the query and returns the number of nodes affected by the update operation. +func (_u *AnnouncementUpdate) Save(ctx context.Context) (int, error) { + _u.defaults() + return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks) +} + +// SaveX is like Save, but panics if an error occurs. +func (_u *AnnouncementUpdate) SaveX(ctx context.Context) int { + affected, err := _u.Save(ctx) + if err != nil { + panic(err) + } + return affected +} + +// Exec executes the query. +func (_u *AnnouncementUpdate) Exec(ctx context.Context) error { + _, err := _u.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_u *AnnouncementUpdate) ExecX(ctx context.Context) { + if err := _u.Exec(ctx); err != nil { + panic(err) + } +} + +// defaults sets the default values of the builder before save. +func (_u *AnnouncementUpdate) defaults() { + if _, ok := _u.mutation.UpdatedAt(); !ok { + v := announcement.UpdateDefaultUpdatedAt() + _u.mutation.SetUpdatedAt(v) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_u *AnnouncementUpdate) check() error { + if v, ok := _u.mutation.Title(); ok { + if err := announcement.TitleValidator(v); err != nil { + return &ValidationError{Name: "title", err: fmt.Errorf(`ent: validator failed for field "Announcement.title": %w`, err)} + } + } + if v, ok := _u.mutation.Content(); ok { + if err := announcement.ContentValidator(v); err != nil { + return &ValidationError{Name: "content", err: fmt.Errorf(`ent: validator failed for field "Announcement.content": %w`, err)} + } + } + if v, ok := _u.mutation.Status(); ok { + if err := announcement.StatusValidator(v); err != nil { + return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "Announcement.status": %w`, err)} + } + } + return nil +} + +func (_u *AnnouncementUpdate) sqlSave(ctx context.Context) (_node int, err error) { + if err := _u.check(); err != nil { + return _node, err + } + _spec := sqlgraph.NewUpdateSpec(announcement.Table, announcement.Columns, sqlgraph.NewFieldSpec(announcement.FieldID, field.TypeInt64)) + if ps := _u.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if value, ok := _u.mutation.Title(); ok { + _spec.SetField(announcement.FieldTitle, field.TypeString, value) + } + if value, ok := _u.mutation.Content(); ok { + _spec.SetField(announcement.FieldContent, field.TypeString, value) + } + if value, ok := _u.mutation.Status(); ok { + _spec.SetField(announcement.FieldStatus, field.TypeString, value) + } + if value, ok := _u.mutation.Targeting(); ok { + _spec.SetField(announcement.FieldTargeting, field.TypeJSON, value) + } + if _u.mutation.TargetingCleared() { + _spec.ClearField(announcement.FieldTargeting, field.TypeJSON) + } + if value, ok := _u.mutation.StartsAt(); ok { + _spec.SetField(announcement.FieldStartsAt, field.TypeTime, value) + } + if _u.mutation.StartsAtCleared() { + _spec.ClearField(announcement.FieldStartsAt, field.TypeTime) + } + if value, ok := _u.mutation.EndsAt(); ok { + _spec.SetField(announcement.FieldEndsAt, field.TypeTime, value) + } + if _u.mutation.EndsAtCleared() { + _spec.ClearField(announcement.FieldEndsAt, field.TypeTime) + } + if value, ok := _u.mutation.CreatedBy(); ok { + _spec.SetField(announcement.FieldCreatedBy, field.TypeInt64, value) + } + if value, ok := _u.mutation.AddedCreatedBy(); ok { + _spec.AddField(announcement.FieldCreatedBy, field.TypeInt64, value) + } + if _u.mutation.CreatedByCleared() { + _spec.ClearField(announcement.FieldCreatedBy, field.TypeInt64) + } + if value, ok := _u.mutation.UpdatedBy(); ok { + _spec.SetField(announcement.FieldUpdatedBy, field.TypeInt64, value) + } + if value, ok := _u.mutation.AddedUpdatedBy(); ok { + _spec.AddField(announcement.FieldUpdatedBy, field.TypeInt64, value) + } + if _u.mutation.UpdatedByCleared() { + _spec.ClearField(announcement.FieldUpdatedBy, field.TypeInt64) + } + if value, ok := _u.mutation.UpdatedAt(); ok { + _spec.SetField(announcement.FieldUpdatedAt, field.TypeTime, value) + } + if _u.mutation.ReadsCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: announcement.ReadsTable, + Columns: []string{announcement.ReadsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(announcementread.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.RemovedReadsIDs(); len(nodes) > 0 && !_u.mutation.ReadsCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: announcement.ReadsTable, + Columns: []string{announcement.ReadsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(announcementread.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.ReadsIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: announcement.ReadsTable, + Columns: []string{announcement.ReadsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(announcementread.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } + if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { + if _, ok := err.(*sqlgraph.NotFoundError); ok { + err = &NotFoundError{announcement.Label} + } else if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return 0, err + } + _u.mutation.done = true + return _node, nil +} + +// AnnouncementUpdateOne is the builder for updating a single Announcement entity. +type AnnouncementUpdateOne struct { + config + fields []string + hooks []Hook + mutation *AnnouncementMutation +} + +// SetTitle sets the "title" field. +func (_u *AnnouncementUpdateOne) SetTitle(v string) *AnnouncementUpdateOne { + _u.mutation.SetTitle(v) + return _u +} + +// SetNillableTitle sets the "title" field if the given value is not nil. +func (_u *AnnouncementUpdateOne) SetNillableTitle(v *string) *AnnouncementUpdateOne { + if v != nil { + _u.SetTitle(*v) + } + return _u +} + +// SetContent sets the "content" field. +func (_u *AnnouncementUpdateOne) SetContent(v string) *AnnouncementUpdateOne { + _u.mutation.SetContent(v) + return _u +} + +// SetNillableContent sets the "content" field if the given value is not nil. +func (_u *AnnouncementUpdateOne) SetNillableContent(v *string) *AnnouncementUpdateOne { + if v != nil { + _u.SetContent(*v) + } + return _u +} + +// SetStatus sets the "status" field. +func (_u *AnnouncementUpdateOne) SetStatus(v string) *AnnouncementUpdateOne { + _u.mutation.SetStatus(v) + return _u +} + +// SetNillableStatus sets the "status" field if the given value is not nil. +func (_u *AnnouncementUpdateOne) SetNillableStatus(v *string) *AnnouncementUpdateOne { + if v != nil { + _u.SetStatus(*v) + } + return _u +} + +// SetTargeting sets the "targeting" field. +func (_u *AnnouncementUpdateOne) SetTargeting(v domain.AnnouncementTargeting) *AnnouncementUpdateOne { + _u.mutation.SetTargeting(v) + return _u +} + +// SetNillableTargeting sets the "targeting" field if the given value is not nil. +func (_u *AnnouncementUpdateOne) SetNillableTargeting(v *domain.AnnouncementTargeting) *AnnouncementUpdateOne { + if v != nil { + _u.SetTargeting(*v) + } + return _u +} + +// ClearTargeting clears the value of the "targeting" field. +func (_u *AnnouncementUpdateOne) ClearTargeting() *AnnouncementUpdateOne { + _u.mutation.ClearTargeting() + return _u +} + +// SetStartsAt sets the "starts_at" field. +func (_u *AnnouncementUpdateOne) SetStartsAt(v time.Time) *AnnouncementUpdateOne { + _u.mutation.SetStartsAt(v) + return _u +} + +// SetNillableStartsAt sets the "starts_at" field if the given value is not nil. +func (_u *AnnouncementUpdateOne) SetNillableStartsAt(v *time.Time) *AnnouncementUpdateOne { + if v != nil { + _u.SetStartsAt(*v) + } + return _u +} + +// ClearStartsAt clears the value of the "starts_at" field. +func (_u *AnnouncementUpdateOne) ClearStartsAt() *AnnouncementUpdateOne { + _u.mutation.ClearStartsAt() + return _u +} + +// SetEndsAt sets the "ends_at" field. +func (_u *AnnouncementUpdateOne) SetEndsAt(v time.Time) *AnnouncementUpdateOne { + _u.mutation.SetEndsAt(v) + return _u +} + +// SetNillableEndsAt sets the "ends_at" field if the given value is not nil. +func (_u *AnnouncementUpdateOne) SetNillableEndsAt(v *time.Time) *AnnouncementUpdateOne { + if v != nil { + _u.SetEndsAt(*v) + } + return _u +} + +// ClearEndsAt clears the value of the "ends_at" field. +func (_u *AnnouncementUpdateOne) ClearEndsAt() *AnnouncementUpdateOne { + _u.mutation.ClearEndsAt() + return _u +} + +// SetCreatedBy sets the "created_by" field. +func (_u *AnnouncementUpdateOne) SetCreatedBy(v int64) *AnnouncementUpdateOne { + _u.mutation.ResetCreatedBy() + _u.mutation.SetCreatedBy(v) + return _u +} + +// SetNillableCreatedBy sets the "created_by" field if the given value is not nil. +func (_u *AnnouncementUpdateOne) SetNillableCreatedBy(v *int64) *AnnouncementUpdateOne { + if v != nil { + _u.SetCreatedBy(*v) + } + return _u +} + +// AddCreatedBy adds value to the "created_by" field. +func (_u *AnnouncementUpdateOne) AddCreatedBy(v int64) *AnnouncementUpdateOne { + _u.mutation.AddCreatedBy(v) + return _u +} + +// ClearCreatedBy clears the value of the "created_by" field. +func (_u *AnnouncementUpdateOne) ClearCreatedBy() *AnnouncementUpdateOne { + _u.mutation.ClearCreatedBy() + return _u +} + +// SetUpdatedBy sets the "updated_by" field. +func (_u *AnnouncementUpdateOne) SetUpdatedBy(v int64) *AnnouncementUpdateOne { + _u.mutation.ResetUpdatedBy() + _u.mutation.SetUpdatedBy(v) + return _u +} + +// SetNillableUpdatedBy sets the "updated_by" field if the given value is not nil. +func (_u *AnnouncementUpdateOne) SetNillableUpdatedBy(v *int64) *AnnouncementUpdateOne { + if v != nil { + _u.SetUpdatedBy(*v) + } + return _u +} + +// AddUpdatedBy adds value to the "updated_by" field. +func (_u *AnnouncementUpdateOne) AddUpdatedBy(v int64) *AnnouncementUpdateOne { + _u.mutation.AddUpdatedBy(v) + return _u +} + +// ClearUpdatedBy clears the value of the "updated_by" field. +func (_u *AnnouncementUpdateOne) ClearUpdatedBy() *AnnouncementUpdateOne { + _u.mutation.ClearUpdatedBy() + return _u +} + +// SetUpdatedAt sets the "updated_at" field. +func (_u *AnnouncementUpdateOne) SetUpdatedAt(v time.Time) *AnnouncementUpdateOne { + _u.mutation.SetUpdatedAt(v) + return _u +} + +// AddReadIDs adds the "reads" edge to the AnnouncementRead entity by IDs. +func (_u *AnnouncementUpdateOne) AddReadIDs(ids ...int64) *AnnouncementUpdateOne { + _u.mutation.AddReadIDs(ids...) + return _u +} + +// AddReads adds the "reads" edges to the AnnouncementRead entity. +func (_u *AnnouncementUpdateOne) AddReads(v ...*AnnouncementRead) *AnnouncementUpdateOne { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.AddReadIDs(ids...) +} + +// Mutation returns the AnnouncementMutation object of the builder. +func (_u *AnnouncementUpdateOne) Mutation() *AnnouncementMutation { + return _u.mutation +} + +// ClearReads clears all "reads" edges to the AnnouncementRead entity. +func (_u *AnnouncementUpdateOne) ClearReads() *AnnouncementUpdateOne { + _u.mutation.ClearReads() + return _u +} + +// RemoveReadIDs removes the "reads" edge to AnnouncementRead entities by IDs. +func (_u *AnnouncementUpdateOne) RemoveReadIDs(ids ...int64) *AnnouncementUpdateOne { + _u.mutation.RemoveReadIDs(ids...) + return _u +} + +// RemoveReads removes "reads" edges to AnnouncementRead entities. +func (_u *AnnouncementUpdateOne) RemoveReads(v ...*AnnouncementRead) *AnnouncementUpdateOne { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.RemoveReadIDs(ids...) +} + +// Where appends a list predicates to the AnnouncementUpdate builder. +func (_u *AnnouncementUpdateOne) Where(ps ...predicate.Announcement) *AnnouncementUpdateOne { + _u.mutation.Where(ps...) + return _u +} + +// Select allows selecting one or more fields (columns) of the returned entity. +// The default is selecting all fields defined in the entity schema. +func (_u *AnnouncementUpdateOne) Select(field string, fields ...string) *AnnouncementUpdateOne { + _u.fields = append([]string{field}, fields...) + return _u +} + +// Save executes the query and returns the updated Announcement entity. +func (_u *AnnouncementUpdateOne) Save(ctx context.Context) (*Announcement, error) { + _u.defaults() + return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks) +} + +// SaveX is like Save, but panics if an error occurs. +func (_u *AnnouncementUpdateOne) SaveX(ctx context.Context) *Announcement { + node, err := _u.Save(ctx) + if err != nil { + panic(err) + } + return node +} + +// Exec executes the query on the entity. +func (_u *AnnouncementUpdateOne) Exec(ctx context.Context) error { + _, err := _u.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_u *AnnouncementUpdateOne) ExecX(ctx context.Context) { + if err := _u.Exec(ctx); err != nil { + panic(err) + } +} + +// defaults sets the default values of the builder before save. +func (_u *AnnouncementUpdateOne) defaults() { + if _, ok := _u.mutation.UpdatedAt(); !ok { + v := announcement.UpdateDefaultUpdatedAt() + _u.mutation.SetUpdatedAt(v) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_u *AnnouncementUpdateOne) check() error { + if v, ok := _u.mutation.Title(); ok { + if err := announcement.TitleValidator(v); err != nil { + return &ValidationError{Name: "title", err: fmt.Errorf(`ent: validator failed for field "Announcement.title": %w`, err)} + } + } + if v, ok := _u.mutation.Content(); ok { + if err := announcement.ContentValidator(v); err != nil { + return &ValidationError{Name: "content", err: fmt.Errorf(`ent: validator failed for field "Announcement.content": %w`, err)} + } + } + if v, ok := _u.mutation.Status(); ok { + if err := announcement.StatusValidator(v); err != nil { + return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "Announcement.status": %w`, err)} + } + } + return nil +} + +func (_u *AnnouncementUpdateOne) sqlSave(ctx context.Context) (_node *Announcement, err error) { + if err := _u.check(); err != nil { + return _node, err + } + _spec := sqlgraph.NewUpdateSpec(announcement.Table, announcement.Columns, sqlgraph.NewFieldSpec(announcement.FieldID, field.TypeInt64)) + id, ok := _u.mutation.ID() + if !ok { + return nil, &ValidationError{Name: "id", err: errors.New(`ent: missing "Announcement.id" for update`)} + } + _spec.Node.ID.Value = id + if fields := _u.fields; len(fields) > 0 { + _spec.Node.Columns = make([]string, 0, len(fields)) + _spec.Node.Columns = append(_spec.Node.Columns, announcement.FieldID) + for _, f := range fields { + if !announcement.ValidColumn(f) { + return nil, &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)} + } + if f != announcement.FieldID { + _spec.Node.Columns = append(_spec.Node.Columns, f) + } + } + } + if ps := _u.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if value, ok := _u.mutation.Title(); ok { + _spec.SetField(announcement.FieldTitle, field.TypeString, value) + } + if value, ok := _u.mutation.Content(); ok { + _spec.SetField(announcement.FieldContent, field.TypeString, value) + } + if value, ok := _u.mutation.Status(); ok { + _spec.SetField(announcement.FieldStatus, field.TypeString, value) + } + if value, ok := _u.mutation.Targeting(); ok { + _spec.SetField(announcement.FieldTargeting, field.TypeJSON, value) + } + if _u.mutation.TargetingCleared() { + _spec.ClearField(announcement.FieldTargeting, field.TypeJSON) + } + if value, ok := _u.mutation.StartsAt(); ok { + _spec.SetField(announcement.FieldStartsAt, field.TypeTime, value) + } + if _u.mutation.StartsAtCleared() { + _spec.ClearField(announcement.FieldStartsAt, field.TypeTime) + } + if value, ok := _u.mutation.EndsAt(); ok { + _spec.SetField(announcement.FieldEndsAt, field.TypeTime, value) + } + if _u.mutation.EndsAtCleared() { + _spec.ClearField(announcement.FieldEndsAt, field.TypeTime) + } + if value, ok := _u.mutation.CreatedBy(); ok { + _spec.SetField(announcement.FieldCreatedBy, field.TypeInt64, value) + } + if value, ok := _u.mutation.AddedCreatedBy(); ok { + _spec.AddField(announcement.FieldCreatedBy, field.TypeInt64, value) + } + if _u.mutation.CreatedByCleared() { + _spec.ClearField(announcement.FieldCreatedBy, field.TypeInt64) + } + if value, ok := _u.mutation.UpdatedBy(); ok { + _spec.SetField(announcement.FieldUpdatedBy, field.TypeInt64, value) + } + if value, ok := _u.mutation.AddedUpdatedBy(); ok { + _spec.AddField(announcement.FieldUpdatedBy, field.TypeInt64, value) + } + if _u.mutation.UpdatedByCleared() { + _spec.ClearField(announcement.FieldUpdatedBy, field.TypeInt64) + } + if value, ok := _u.mutation.UpdatedAt(); ok { + _spec.SetField(announcement.FieldUpdatedAt, field.TypeTime, value) + } + if _u.mutation.ReadsCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: announcement.ReadsTable, + Columns: []string{announcement.ReadsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(announcementread.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.RemovedReadsIDs(); len(nodes) > 0 && !_u.mutation.ReadsCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: announcement.ReadsTable, + Columns: []string{announcement.ReadsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(announcementread.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.ReadsIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: announcement.ReadsTable, + Columns: []string{announcement.ReadsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(announcementread.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } + _node = &Announcement{config: _u.config} + _spec.Assign = _node.assignValues + _spec.ScanValues = _node.scanValues + if err = sqlgraph.UpdateNode(ctx, _u.driver, _spec); err != nil { + if _, ok := err.(*sqlgraph.NotFoundError); ok { + err = &NotFoundError{announcement.Label} + } else if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return nil, err + } + _u.mutation.done = true + return _node, nil +} diff --git a/backend/ent/announcementread.go b/backend/ent/announcementread.go new file mode 100644 index 00000000..7bba04f2 --- /dev/null +++ b/backend/ent/announcementread.go @@ -0,0 +1,185 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "fmt" + "strings" + "time" + + "entgo.io/ent" + "entgo.io/ent/dialect/sql" + "github.com/Wei-Shaw/sub2api/ent/announcement" + "github.com/Wei-Shaw/sub2api/ent/announcementread" + "github.com/Wei-Shaw/sub2api/ent/user" +) + +// AnnouncementRead is the model entity for the AnnouncementRead schema. +type AnnouncementRead struct { + config `json:"-"` + // ID of the ent. + ID int64 `json:"id,omitempty"` + // AnnouncementID holds the value of the "announcement_id" field. + AnnouncementID int64 `json:"announcement_id,omitempty"` + // UserID holds the value of the "user_id" field. + UserID int64 `json:"user_id,omitempty"` + // 用户首次已读时间 + ReadAt time.Time `json:"read_at,omitempty"` + // CreatedAt holds the value of the "created_at" field. + CreatedAt time.Time `json:"created_at,omitempty"` + // Edges holds the relations/edges for other nodes in the graph. + // The values are being populated by the AnnouncementReadQuery when eager-loading is set. + Edges AnnouncementReadEdges `json:"edges"` + selectValues sql.SelectValues +} + +// AnnouncementReadEdges holds the relations/edges for other nodes in the graph. +type AnnouncementReadEdges struct { + // Announcement holds the value of the announcement edge. + Announcement *Announcement `json:"announcement,omitempty"` + // User holds the value of the user edge. + User *User `json:"user,omitempty"` + // loadedTypes holds the information for reporting if a + // type was loaded (or requested) in eager-loading or not. + loadedTypes [2]bool +} + +// AnnouncementOrErr returns the Announcement value or an error if the edge +// was not loaded in eager-loading, or loaded but was not found. +func (e AnnouncementReadEdges) AnnouncementOrErr() (*Announcement, error) { + if e.Announcement != nil { + return e.Announcement, nil + } else if e.loadedTypes[0] { + return nil, &NotFoundError{label: announcement.Label} + } + return nil, &NotLoadedError{edge: "announcement"} +} + +// UserOrErr returns the User value or an error if the edge +// was not loaded in eager-loading, or loaded but was not found. +func (e AnnouncementReadEdges) UserOrErr() (*User, error) { + if e.User != nil { + return e.User, nil + } else if e.loadedTypes[1] { + return nil, &NotFoundError{label: user.Label} + } + return nil, &NotLoadedError{edge: "user"} +} + +// scanValues returns the types for scanning values from sql.Rows. +func (*AnnouncementRead) scanValues(columns []string) ([]any, error) { + values := make([]any, len(columns)) + for i := range columns { + switch columns[i] { + case announcementread.FieldID, announcementread.FieldAnnouncementID, announcementread.FieldUserID: + values[i] = new(sql.NullInt64) + case announcementread.FieldReadAt, announcementread.FieldCreatedAt: + values[i] = new(sql.NullTime) + default: + values[i] = new(sql.UnknownType) + } + } + return values, nil +} + +// assignValues assigns the values that were returned from sql.Rows (after scanning) +// to the AnnouncementRead fields. +func (_m *AnnouncementRead) assignValues(columns []string, values []any) error { + if m, n := len(values), len(columns); m < n { + return fmt.Errorf("mismatch number of scan values: %d != %d", m, n) + } + for i := range columns { + switch columns[i] { + case announcementread.FieldID: + value, ok := values[i].(*sql.NullInt64) + if !ok { + return fmt.Errorf("unexpected type %T for field id", value) + } + _m.ID = int64(value.Int64) + case announcementread.FieldAnnouncementID: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field announcement_id", values[i]) + } else if value.Valid { + _m.AnnouncementID = value.Int64 + } + case announcementread.FieldUserID: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field user_id", values[i]) + } else if value.Valid { + _m.UserID = value.Int64 + } + case announcementread.FieldReadAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field read_at", values[i]) + } else if value.Valid { + _m.ReadAt = value.Time + } + case announcementread.FieldCreatedAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field created_at", values[i]) + } else if value.Valid { + _m.CreatedAt = value.Time + } + default: + _m.selectValues.Set(columns[i], values[i]) + } + } + return nil +} + +// Value returns the ent.Value that was dynamically selected and assigned to the AnnouncementRead. +// This includes values selected through modifiers, order, etc. +func (_m *AnnouncementRead) Value(name string) (ent.Value, error) { + return _m.selectValues.Get(name) +} + +// QueryAnnouncement queries the "announcement" edge of the AnnouncementRead entity. +func (_m *AnnouncementRead) QueryAnnouncement() *AnnouncementQuery { + return NewAnnouncementReadClient(_m.config).QueryAnnouncement(_m) +} + +// QueryUser queries the "user" edge of the AnnouncementRead entity. +func (_m *AnnouncementRead) QueryUser() *UserQuery { + return NewAnnouncementReadClient(_m.config).QueryUser(_m) +} + +// Update returns a builder for updating this AnnouncementRead. +// Note that you need to call AnnouncementRead.Unwrap() before calling this method if this AnnouncementRead +// was returned from a transaction, and the transaction was committed or rolled back. +func (_m *AnnouncementRead) Update() *AnnouncementReadUpdateOne { + return NewAnnouncementReadClient(_m.config).UpdateOne(_m) +} + +// Unwrap unwraps the AnnouncementRead entity that was returned from a transaction after it was closed, +// so that all future queries will be executed through the driver which created the transaction. +func (_m *AnnouncementRead) Unwrap() *AnnouncementRead { + _tx, ok := _m.config.driver.(*txDriver) + if !ok { + panic("ent: AnnouncementRead is not a transactional entity") + } + _m.config.driver = _tx.drv + return _m +} + +// String implements the fmt.Stringer. +func (_m *AnnouncementRead) String() string { + var builder strings.Builder + builder.WriteString("AnnouncementRead(") + builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID)) + builder.WriteString("announcement_id=") + builder.WriteString(fmt.Sprintf("%v", _m.AnnouncementID)) + builder.WriteString(", ") + builder.WriteString("user_id=") + builder.WriteString(fmt.Sprintf("%v", _m.UserID)) + builder.WriteString(", ") + builder.WriteString("read_at=") + builder.WriteString(_m.ReadAt.Format(time.ANSIC)) + builder.WriteString(", ") + builder.WriteString("created_at=") + builder.WriteString(_m.CreatedAt.Format(time.ANSIC)) + builder.WriteByte(')') + return builder.String() +} + +// AnnouncementReads is a parsable slice of AnnouncementRead. +type AnnouncementReads []*AnnouncementRead diff --git a/backend/ent/announcementread/announcementread.go b/backend/ent/announcementread/announcementread.go new file mode 100644 index 00000000..cf5fe458 --- /dev/null +++ b/backend/ent/announcementread/announcementread.go @@ -0,0 +1,127 @@ +// Code generated by ent, DO NOT EDIT. + +package announcementread + +import ( + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" +) + +const ( + // Label holds the string label denoting the announcementread type in the database. + Label = "announcement_read" + // FieldID holds the string denoting the id field in the database. + FieldID = "id" + // FieldAnnouncementID holds the string denoting the announcement_id field in the database. + FieldAnnouncementID = "announcement_id" + // FieldUserID holds the string denoting the user_id field in the database. + FieldUserID = "user_id" + // FieldReadAt holds the string denoting the read_at field in the database. + FieldReadAt = "read_at" + // FieldCreatedAt holds the string denoting the created_at field in the database. + FieldCreatedAt = "created_at" + // EdgeAnnouncement holds the string denoting the announcement edge name in mutations. + EdgeAnnouncement = "announcement" + // EdgeUser holds the string denoting the user edge name in mutations. + EdgeUser = "user" + // Table holds the table name of the announcementread in the database. + Table = "announcement_reads" + // AnnouncementTable is the table that holds the announcement relation/edge. + AnnouncementTable = "announcement_reads" + // AnnouncementInverseTable is the table name for the Announcement entity. + // It exists in this package in order to avoid circular dependency with the "announcement" package. + AnnouncementInverseTable = "announcements" + // AnnouncementColumn is the table column denoting the announcement relation/edge. + AnnouncementColumn = "announcement_id" + // UserTable is the table that holds the user relation/edge. + UserTable = "announcement_reads" + // UserInverseTable is the table name for the User entity. + // It exists in this package in order to avoid circular dependency with the "user" package. + UserInverseTable = "users" + // UserColumn is the table column denoting the user relation/edge. + UserColumn = "user_id" +) + +// Columns holds all SQL columns for announcementread fields. +var Columns = []string{ + FieldID, + FieldAnnouncementID, + FieldUserID, + FieldReadAt, + FieldCreatedAt, +} + +// ValidColumn reports if the column name is valid (part of the table columns). +func ValidColumn(column string) bool { + for i := range Columns { + if column == Columns[i] { + return true + } + } + return false +} + +var ( + // DefaultReadAt holds the default value on creation for the "read_at" field. + DefaultReadAt func() time.Time + // DefaultCreatedAt holds the default value on creation for the "created_at" field. + DefaultCreatedAt func() time.Time +) + +// OrderOption defines the ordering options for the AnnouncementRead queries. +type OrderOption func(*sql.Selector) + +// ByID orders the results by the id field. +func ByID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldID, opts...).ToFunc() +} + +// ByAnnouncementID orders the results by the announcement_id field. +func ByAnnouncementID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldAnnouncementID, opts...).ToFunc() +} + +// ByUserID orders the results by the user_id field. +func ByUserID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUserID, opts...).ToFunc() +} + +// ByReadAt orders the results by the read_at field. +func ByReadAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldReadAt, opts...).ToFunc() +} + +// ByCreatedAt orders the results by the created_at field. +func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldCreatedAt, opts...).ToFunc() +} + +// ByAnnouncementField orders the results by announcement field. +func ByAnnouncementField(field string, opts ...sql.OrderTermOption) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborTerms(s, newAnnouncementStep(), sql.OrderByField(field, opts...)) + } +} + +// ByUserField orders the results by user field. +func ByUserField(field string, opts ...sql.OrderTermOption) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborTerms(s, newUserStep(), sql.OrderByField(field, opts...)) + } +} +func newAnnouncementStep() *sqlgraph.Step { + return sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.To(AnnouncementInverseTable, FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, AnnouncementTable, AnnouncementColumn), + ) +} +func newUserStep() *sqlgraph.Step { + return sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.To(UserInverseTable, FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, UserTable, UserColumn), + ) +} diff --git a/backend/ent/announcementread/where.go b/backend/ent/announcementread/where.go new file mode 100644 index 00000000..1a4305e8 --- /dev/null +++ b/backend/ent/announcementread/where.go @@ -0,0 +1,257 @@ +// Code generated by ent, DO NOT EDIT. + +package announcementread + +import ( + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "github.com/Wei-Shaw/sub2api/ent/predicate" +) + +// ID filters vertices based on their ID field. +func ID(id int64) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldEQ(FieldID, id)) +} + +// IDEQ applies the EQ predicate on the ID field. +func IDEQ(id int64) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldEQ(FieldID, id)) +} + +// IDNEQ applies the NEQ predicate on the ID field. +func IDNEQ(id int64) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldNEQ(FieldID, id)) +} + +// IDIn applies the In predicate on the ID field. +func IDIn(ids ...int64) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldIn(FieldID, ids...)) +} + +// IDNotIn applies the NotIn predicate on the ID field. +func IDNotIn(ids ...int64) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldNotIn(FieldID, ids...)) +} + +// IDGT applies the GT predicate on the ID field. +func IDGT(id int64) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldGT(FieldID, id)) +} + +// IDGTE applies the GTE predicate on the ID field. +func IDGTE(id int64) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldGTE(FieldID, id)) +} + +// IDLT applies the LT predicate on the ID field. +func IDLT(id int64) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldLT(FieldID, id)) +} + +// IDLTE applies the LTE predicate on the ID field. +func IDLTE(id int64) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldLTE(FieldID, id)) +} + +// AnnouncementID applies equality check predicate on the "announcement_id" field. It's identical to AnnouncementIDEQ. +func AnnouncementID(v int64) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldEQ(FieldAnnouncementID, v)) +} + +// UserID applies equality check predicate on the "user_id" field. It's identical to UserIDEQ. +func UserID(v int64) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldEQ(FieldUserID, v)) +} + +// ReadAt applies equality check predicate on the "read_at" field. It's identical to ReadAtEQ. +func ReadAt(v time.Time) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldEQ(FieldReadAt, v)) +} + +// CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ. +func CreatedAt(v time.Time) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldEQ(FieldCreatedAt, v)) +} + +// AnnouncementIDEQ applies the EQ predicate on the "announcement_id" field. +func AnnouncementIDEQ(v int64) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldEQ(FieldAnnouncementID, v)) +} + +// AnnouncementIDNEQ applies the NEQ predicate on the "announcement_id" field. +func AnnouncementIDNEQ(v int64) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldNEQ(FieldAnnouncementID, v)) +} + +// AnnouncementIDIn applies the In predicate on the "announcement_id" field. +func AnnouncementIDIn(vs ...int64) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldIn(FieldAnnouncementID, vs...)) +} + +// AnnouncementIDNotIn applies the NotIn predicate on the "announcement_id" field. +func AnnouncementIDNotIn(vs ...int64) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldNotIn(FieldAnnouncementID, vs...)) +} + +// UserIDEQ applies the EQ predicate on the "user_id" field. +func UserIDEQ(v int64) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldEQ(FieldUserID, v)) +} + +// UserIDNEQ applies the NEQ predicate on the "user_id" field. +func UserIDNEQ(v int64) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldNEQ(FieldUserID, v)) +} + +// UserIDIn applies the In predicate on the "user_id" field. +func UserIDIn(vs ...int64) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldIn(FieldUserID, vs...)) +} + +// UserIDNotIn applies the NotIn predicate on the "user_id" field. +func UserIDNotIn(vs ...int64) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldNotIn(FieldUserID, vs...)) +} + +// ReadAtEQ applies the EQ predicate on the "read_at" field. +func ReadAtEQ(v time.Time) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldEQ(FieldReadAt, v)) +} + +// ReadAtNEQ applies the NEQ predicate on the "read_at" field. +func ReadAtNEQ(v time.Time) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldNEQ(FieldReadAt, v)) +} + +// ReadAtIn applies the In predicate on the "read_at" field. +func ReadAtIn(vs ...time.Time) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldIn(FieldReadAt, vs...)) +} + +// ReadAtNotIn applies the NotIn predicate on the "read_at" field. +func ReadAtNotIn(vs ...time.Time) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldNotIn(FieldReadAt, vs...)) +} + +// ReadAtGT applies the GT predicate on the "read_at" field. +func ReadAtGT(v time.Time) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldGT(FieldReadAt, v)) +} + +// ReadAtGTE applies the GTE predicate on the "read_at" field. +func ReadAtGTE(v time.Time) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldGTE(FieldReadAt, v)) +} + +// ReadAtLT applies the LT predicate on the "read_at" field. +func ReadAtLT(v time.Time) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldLT(FieldReadAt, v)) +} + +// ReadAtLTE applies the LTE predicate on the "read_at" field. +func ReadAtLTE(v time.Time) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldLTE(FieldReadAt, v)) +} + +// CreatedAtEQ applies the EQ predicate on the "created_at" field. +func CreatedAtEQ(v time.Time) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldEQ(FieldCreatedAt, v)) +} + +// CreatedAtNEQ applies the NEQ predicate on the "created_at" field. +func CreatedAtNEQ(v time.Time) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldNEQ(FieldCreatedAt, v)) +} + +// CreatedAtIn applies the In predicate on the "created_at" field. +func CreatedAtIn(vs ...time.Time) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldIn(FieldCreatedAt, vs...)) +} + +// CreatedAtNotIn applies the NotIn predicate on the "created_at" field. +func CreatedAtNotIn(vs ...time.Time) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldNotIn(FieldCreatedAt, vs...)) +} + +// CreatedAtGT applies the GT predicate on the "created_at" field. +func CreatedAtGT(v time.Time) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldGT(FieldCreatedAt, v)) +} + +// CreatedAtGTE applies the GTE predicate on the "created_at" field. +func CreatedAtGTE(v time.Time) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldGTE(FieldCreatedAt, v)) +} + +// CreatedAtLT applies the LT predicate on the "created_at" field. +func CreatedAtLT(v time.Time) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldLT(FieldCreatedAt, v)) +} + +// CreatedAtLTE applies the LTE predicate on the "created_at" field. +func CreatedAtLTE(v time.Time) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.FieldLTE(FieldCreatedAt, v)) +} + +// HasAnnouncement applies the HasEdge predicate on the "announcement" edge. +func HasAnnouncement() predicate.AnnouncementRead { + return predicate.AnnouncementRead(func(s *sql.Selector) { + step := sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, AnnouncementTable, AnnouncementColumn), + ) + sqlgraph.HasNeighbors(s, step) + }) +} + +// HasAnnouncementWith applies the HasEdge predicate on the "announcement" edge with a given conditions (other predicates). +func HasAnnouncementWith(preds ...predicate.Announcement) predicate.AnnouncementRead { + return predicate.AnnouncementRead(func(s *sql.Selector) { + step := newAnnouncementStep() + sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { + for _, p := range preds { + p(s) + } + }) + }) +} + +// HasUser applies the HasEdge predicate on the "user" edge. +func HasUser() predicate.AnnouncementRead { + return predicate.AnnouncementRead(func(s *sql.Selector) { + step := sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, UserTable, UserColumn), + ) + sqlgraph.HasNeighbors(s, step) + }) +} + +// HasUserWith applies the HasEdge predicate on the "user" edge with a given conditions (other predicates). +func HasUserWith(preds ...predicate.User) predicate.AnnouncementRead { + return predicate.AnnouncementRead(func(s *sql.Selector) { + step := newUserStep() + sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { + for _, p := range preds { + p(s) + } + }) + }) +} + +// And groups predicates with the AND operator between them. +func And(predicates ...predicate.AnnouncementRead) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.AndPredicates(predicates...)) +} + +// Or groups predicates with the OR operator between them. +func Or(predicates ...predicate.AnnouncementRead) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.OrPredicates(predicates...)) +} + +// Not applies the not operator on the given predicate. +func Not(p predicate.AnnouncementRead) predicate.AnnouncementRead { + return predicate.AnnouncementRead(sql.NotPredicates(p)) +} diff --git a/backend/ent/announcementread_create.go b/backend/ent/announcementread_create.go new file mode 100644 index 00000000..c8c211ff --- /dev/null +++ b/backend/ent/announcementread_create.go @@ -0,0 +1,660 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "context" + "errors" + "fmt" + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/Wei-Shaw/sub2api/ent/announcement" + "github.com/Wei-Shaw/sub2api/ent/announcementread" + "github.com/Wei-Shaw/sub2api/ent/user" +) + +// AnnouncementReadCreate is the builder for creating a AnnouncementRead entity. +type AnnouncementReadCreate struct { + config + mutation *AnnouncementReadMutation + hooks []Hook + conflict []sql.ConflictOption +} + +// SetAnnouncementID sets the "announcement_id" field. +func (_c *AnnouncementReadCreate) SetAnnouncementID(v int64) *AnnouncementReadCreate { + _c.mutation.SetAnnouncementID(v) + return _c +} + +// SetUserID sets the "user_id" field. +func (_c *AnnouncementReadCreate) SetUserID(v int64) *AnnouncementReadCreate { + _c.mutation.SetUserID(v) + return _c +} + +// SetReadAt sets the "read_at" field. +func (_c *AnnouncementReadCreate) SetReadAt(v time.Time) *AnnouncementReadCreate { + _c.mutation.SetReadAt(v) + return _c +} + +// SetNillableReadAt sets the "read_at" field if the given value is not nil. +func (_c *AnnouncementReadCreate) SetNillableReadAt(v *time.Time) *AnnouncementReadCreate { + if v != nil { + _c.SetReadAt(*v) + } + return _c +} + +// SetCreatedAt sets the "created_at" field. +func (_c *AnnouncementReadCreate) SetCreatedAt(v time.Time) *AnnouncementReadCreate { + _c.mutation.SetCreatedAt(v) + return _c +} + +// SetNillableCreatedAt sets the "created_at" field if the given value is not nil. +func (_c *AnnouncementReadCreate) SetNillableCreatedAt(v *time.Time) *AnnouncementReadCreate { + if v != nil { + _c.SetCreatedAt(*v) + } + return _c +} + +// SetAnnouncement sets the "announcement" edge to the Announcement entity. +func (_c *AnnouncementReadCreate) SetAnnouncement(v *Announcement) *AnnouncementReadCreate { + return _c.SetAnnouncementID(v.ID) +} + +// SetUser sets the "user" edge to the User entity. +func (_c *AnnouncementReadCreate) SetUser(v *User) *AnnouncementReadCreate { + return _c.SetUserID(v.ID) +} + +// Mutation returns the AnnouncementReadMutation object of the builder. +func (_c *AnnouncementReadCreate) Mutation() *AnnouncementReadMutation { + return _c.mutation +} + +// Save creates the AnnouncementRead in the database. +func (_c *AnnouncementReadCreate) Save(ctx context.Context) (*AnnouncementRead, error) { + _c.defaults() + return withHooks(ctx, _c.sqlSave, _c.mutation, _c.hooks) +} + +// SaveX calls Save and panics if Save returns an error. +func (_c *AnnouncementReadCreate) SaveX(ctx context.Context) *AnnouncementRead { + v, err := _c.Save(ctx) + if err != nil { + panic(err) + } + return v +} + +// Exec executes the query. +func (_c *AnnouncementReadCreate) Exec(ctx context.Context) error { + _, err := _c.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_c *AnnouncementReadCreate) ExecX(ctx context.Context) { + if err := _c.Exec(ctx); err != nil { + panic(err) + } +} + +// defaults sets the default values of the builder before save. +func (_c *AnnouncementReadCreate) defaults() { + if _, ok := _c.mutation.ReadAt(); !ok { + v := announcementread.DefaultReadAt() + _c.mutation.SetReadAt(v) + } + if _, ok := _c.mutation.CreatedAt(); !ok { + v := announcementread.DefaultCreatedAt() + _c.mutation.SetCreatedAt(v) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_c *AnnouncementReadCreate) check() error { + if _, ok := _c.mutation.AnnouncementID(); !ok { + return &ValidationError{Name: "announcement_id", err: errors.New(`ent: missing required field "AnnouncementRead.announcement_id"`)} + } + if _, ok := _c.mutation.UserID(); !ok { + return &ValidationError{Name: "user_id", err: errors.New(`ent: missing required field "AnnouncementRead.user_id"`)} + } + if _, ok := _c.mutation.ReadAt(); !ok { + return &ValidationError{Name: "read_at", err: errors.New(`ent: missing required field "AnnouncementRead.read_at"`)} + } + if _, ok := _c.mutation.CreatedAt(); !ok { + return &ValidationError{Name: "created_at", err: errors.New(`ent: missing required field "AnnouncementRead.created_at"`)} + } + if len(_c.mutation.AnnouncementIDs()) == 0 { + return &ValidationError{Name: "announcement", err: errors.New(`ent: missing required edge "AnnouncementRead.announcement"`)} + } + if len(_c.mutation.UserIDs()) == 0 { + return &ValidationError{Name: "user", err: errors.New(`ent: missing required edge "AnnouncementRead.user"`)} + } + return nil +} + +func (_c *AnnouncementReadCreate) sqlSave(ctx context.Context) (*AnnouncementRead, error) { + if err := _c.check(); err != nil { + return nil, err + } + _node, _spec := _c.createSpec() + if err := sqlgraph.CreateNode(ctx, _c.driver, _spec); err != nil { + if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return nil, err + } + id := _spec.ID.Value.(int64) + _node.ID = int64(id) + _c.mutation.id = &_node.ID + _c.mutation.done = true + return _node, nil +} + +func (_c *AnnouncementReadCreate) createSpec() (*AnnouncementRead, *sqlgraph.CreateSpec) { + var ( + _node = &AnnouncementRead{config: _c.config} + _spec = sqlgraph.NewCreateSpec(announcementread.Table, sqlgraph.NewFieldSpec(announcementread.FieldID, field.TypeInt64)) + ) + _spec.OnConflict = _c.conflict + if value, ok := _c.mutation.ReadAt(); ok { + _spec.SetField(announcementread.FieldReadAt, field.TypeTime, value) + _node.ReadAt = value + } + if value, ok := _c.mutation.CreatedAt(); ok { + _spec.SetField(announcementread.FieldCreatedAt, field.TypeTime, value) + _node.CreatedAt = value + } + if nodes := _c.mutation.AnnouncementIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: announcementread.AnnouncementTable, + Columns: []string{announcementread.AnnouncementColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(announcement.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _node.AnnouncementID = nodes[0] + _spec.Edges = append(_spec.Edges, edge) + } + if nodes := _c.mutation.UserIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: announcementread.UserTable, + Columns: []string{announcementread.UserColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _node.UserID = nodes[0] + _spec.Edges = append(_spec.Edges, edge) + } + return _node, _spec +} + +// OnConflict allows configuring the `ON CONFLICT` / `ON DUPLICATE KEY` clause +// of the `INSERT` statement. For example: +// +// client.AnnouncementRead.Create(). +// SetAnnouncementID(v). +// OnConflict( +// // Update the row with the new values +// // the was proposed for insertion. +// sql.ResolveWithNewValues(), +// ). +// // Override some of the fields with custom +// // update values. +// Update(func(u *ent.AnnouncementReadUpsert) { +// SetAnnouncementID(v+v). +// }). +// Exec(ctx) +func (_c *AnnouncementReadCreate) OnConflict(opts ...sql.ConflictOption) *AnnouncementReadUpsertOne { + _c.conflict = opts + return &AnnouncementReadUpsertOne{ + create: _c, + } +} + +// OnConflictColumns calls `OnConflict` and configures the columns +// as conflict target. Using this option is equivalent to using: +// +// client.AnnouncementRead.Create(). +// OnConflict(sql.ConflictColumns(columns...)). +// Exec(ctx) +func (_c *AnnouncementReadCreate) OnConflictColumns(columns ...string) *AnnouncementReadUpsertOne { + _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...)) + return &AnnouncementReadUpsertOne{ + create: _c, + } +} + +type ( + // AnnouncementReadUpsertOne is the builder for "upsert"-ing + // one AnnouncementRead node. + AnnouncementReadUpsertOne struct { + create *AnnouncementReadCreate + } + + // AnnouncementReadUpsert is the "OnConflict" setter. + AnnouncementReadUpsert struct { + *sql.UpdateSet + } +) + +// SetAnnouncementID sets the "announcement_id" field. +func (u *AnnouncementReadUpsert) SetAnnouncementID(v int64) *AnnouncementReadUpsert { + u.Set(announcementread.FieldAnnouncementID, v) + return u +} + +// UpdateAnnouncementID sets the "announcement_id" field to the value that was provided on create. +func (u *AnnouncementReadUpsert) UpdateAnnouncementID() *AnnouncementReadUpsert { + u.SetExcluded(announcementread.FieldAnnouncementID) + return u +} + +// SetUserID sets the "user_id" field. +func (u *AnnouncementReadUpsert) SetUserID(v int64) *AnnouncementReadUpsert { + u.Set(announcementread.FieldUserID, v) + return u +} + +// UpdateUserID sets the "user_id" field to the value that was provided on create. +func (u *AnnouncementReadUpsert) UpdateUserID() *AnnouncementReadUpsert { + u.SetExcluded(announcementread.FieldUserID) + return u +} + +// SetReadAt sets the "read_at" field. +func (u *AnnouncementReadUpsert) SetReadAt(v time.Time) *AnnouncementReadUpsert { + u.Set(announcementread.FieldReadAt, v) + return u +} + +// UpdateReadAt sets the "read_at" field to the value that was provided on create. +func (u *AnnouncementReadUpsert) UpdateReadAt() *AnnouncementReadUpsert { + u.SetExcluded(announcementread.FieldReadAt) + return u +} + +// UpdateNewValues updates the mutable fields using the new values that were set on create. +// Using this option is equivalent to using: +// +// client.AnnouncementRead.Create(). +// OnConflict( +// sql.ResolveWithNewValues(), +// ). +// Exec(ctx) +func (u *AnnouncementReadUpsertOne) UpdateNewValues() *AnnouncementReadUpsertOne { + u.create.conflict = append(u.create.conflict, sql.ResolveWithNewValues()) + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(s *sql.UpdateSet) { + if _, exists := u.create.mutation.CreatedAt(); exists { + s.SetIgnore(announcementread.FieldCreatedAt) + } + })) + return u +} + +// Ignore sets each column to itself in case of conflict. +// Using this option is equivalent to using: +// +// client.AnnouncementRead.Create(). +// OnConflict(sql.ResolveWithIgnore()). +// Exec(ctx) +func (u *AnnouncementReadUpsertOne) Ignore() *AnnouncementReadUpsertOne { + u.create.conflict = append(u.create.conflict, sql.ResolveWithIgnore()) + return u +} + +// DoNothing configures the conflict_action to `DO NOTHING`. +// Supported only by SQLite and PostgreSQL. +func (u *AnnouncementReadUpsertOne) DoNothing() *AnnouncementReadUpsertOne { + u.create.conflict = append(u.create.conflict, sql.DoNothing()) + return u +} + +// Update allows overriding fields `UPDATE` values. See the AnnouncementReadCreate.OnConflict +// documentation for more info. +func (u *AnnouncementReadUpsertOne) Update(set func(*AnnouncementReadUpsert)) *AnnouncementReadUpsertOne { + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) { + set(&AnnouncementReadUpsert{UpdateSet: update}) + })) + return u +} + +// SetAnnouncementID sets the "announcement_id" field. +func (u *AnnouncementReadUpsertOne) SetAnnouncementID(v int64) *AnnouncementReadUpsertOne { + return u.Update(func(s *AnnouncementReadUpsert) { + s.SetAnnouncementID(v) + }) +} + +// UpdateAnnouncementID sets the "announcement_id" field to the value that was provided on create. +func (u *AnnouncementReadUpsertOne) UpdateAnnouncementID() *AnnouncementReadUpsertOne { + return u.Update(func(s *AnnouncementReadUpsert) { + s.UpdateAnnouncementID() + }) +} + +// SetUserID sets the "user_id" field. +func (u *AnnouncementReadUpsertOne) SetUserID(v int64) *AnnouncementReadUpsertOne { + return u.Update(func(s *AnnouncementReadUpsert) { + s.SetUserID(v) + }) +} + +// UpdateUserID sets the "user_id" field to the value that was provided on create. +func (u *AnnouncementReadUpsertOne) UpdateUserID() *AnnouncementReadUpsertOne { + return u.Update(func(s *AnnouncementReadUpsert) { + s.UpdateUserID() + }) +} + +// SetReadAt sets the "read_at" field. +func (u *AnnouncementReadUpsertOne) SetReadAt(v time.Time) *AnnouncementReadUpsertOne { + return u.Update(func(s *AnnouncementReadUpsert) { + s.SetReadAt(v) + }) +} + +// UpdateReadAt sets the "read_at" field to the value that was provided on create. +func (u *AnnouncementReadUpsertOne) UpdateReadAt() *AnnouncementReadUpsertOne { + return u.Update(func(s *AnnouncementReadUpsert) { + s.UpdateReadAt() + }) +} + +// Exec executes the query. +func (u *AnnouncementReadUpsertOne) Exec(ctx context.Context) error { + if len(u.create.conflict) == 0 { + return errors.New("ent: missing options for AnnouncementReadCreate.OnConflict") + } + return u.create.Exec(ctx) +} + +// ExecX is like Exec, but panics if an error occurs. +func (u *AnnouncementReadUpsertOne) ExecX(ctx context.Context) { + if err := u.create.Exec(ctx); err != nil { + panic(err) + } +} + +// Exec executes the UPSERT query and returns the inserted/updated ID. +func (u *AnnouncementReadUpsertOne) ID(ctx context.Context) (id int64, err error) { + node, err := u.create.Save(ctx) + if err != nil { + return id, err + } + return node.ID, nil +} + +// IDX is like ID, but panics if an error occurs. +func (u *AnnouncementReadUpsertOne) IDX(ctx context.Context) int64 { + id, err := u.ID(ctx) + if err != nil { + panic(err) + } + return id +} + +// AnnouncementReadCreateBulk is the builder for creating many AnnouncementRead entities in bulk. +type AnnouncementReadCreateBulk struct { + config + err error + builders []*AnnouncementReadCreate + conflict []sql.ConflictOption +} + +// Save creates the AnnouncementRead entities in the database. +func (_c *AnnouncementReadCreateBulk) Save(ctx context.Context) ([]*AnnouncementRead, error) { + if _c.err != nil { + return nil, _c.err + } + specs := make([]*sqlgraph.CreateSpec, len(_c.builders)) + nodes := make([]*AnnouncementRead, len(_c.builders)) + mutators := make([]Mutator, len(_c.builders)) + for i := range _c.builders { + func(i int, root context.Context) { + builder := _c.builders[i] + builder.defaults() + var mut Mutator = MutateFunc(func(ctx context.Context, m Mutation) (Value, error) { + mutation, ok := m.(*AnnouncementReadMutation) + if !ok { + return nil, fmt.Errorf("unexpected mutation type %T", m) + } + if err := builder.check(); err != nil { + return nil, err + } + builder.mutation = mutation + var err error + nodes[i], specs[i] = builder.createSpec() + if i < len(mutators)-1 { + _, err = mutators[i+1].Mutate(root, _c.builders[i+1].mutation) + } else { + spec := &sqlgraph.BatchCreateSpec{Nodes: specs} + spec.OnConflict = _c.conflict + // Invoke the actual operation on the latest mutation in the chain. + if err = sqlgraph.BatchCreate(ctx, _c.driver, spec); err != nil { + if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + } + } + if err != nil { + return nil, err + } + mutation.id = &nodes[i].ID + if specs[i].ID.Value != nil { + id := specs[i].ID.Value.(int64) + nodes[i].ID = int64(id) + } + mutation.done = true + return nodes[i], nil + }) + for i := len(builder.hooks) - 1; i >= 0; i-- { + mut = builder.hooks[i](mut) + } + mutators[i] = mut + }(i, ctx) + } + if len(mutators) > 0 { + if _, err := mutators[0].Mutate(ctx, _c.builders[0].mutation); err != nil { + return nil, err + } + } + return nodes, nil +} + +// SaveX is like Save, but panics if an error occurs. +func (_c *AnnouncementReadCreateBulk) SaveX(ctx context.Context) []*AnnouncementRead { + v, err := _c.Save(ctx) + if err != nil { + panic(err) + } + return v +} + +// Exec executes the query. +func (_c *AnnouncementReadCreateBulk) Exec(ctx context.Context) error { + _, err := _c.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_c *AnnouncementReadCreateBulk) ExecX(ctx context.Context) { + if err := _c.Exec(ctx); err != nil { + panic(err) + } +} + +// OnConflict allows configuring the `ON CONFLICT` / `ON DUPLICATE KEY` clause +// of the `INSERT` statement. For example: +// +// client.AnnouncementRead.CreateBulk(builders...). +// OnConflict( +// // Update the row with the new values +// // the was proposed for insertion. +// sql.ResolveWithNewValues(), +// ). +// // Override some of the fields with custom +// // update values. +// Update(func(u *ent.AnnouncementReadUpsert) { +// SetAnnouncementID(v+v). +// }). +// Exec(ctx) +func (_c *AnnouncementReadCreateBulk) OnConflict(opts ...sql.ConflictOption) *AnnouncementReadUpsertBulk { + _c.conflict = opts + return &AnnouncementReadUpsertBulk{ + create: _c, + } +} + +// OnConflictColumns calls `OnConflict` and configures the columns +// as conflict target. Using this option is equivalent to using: +// +// client.AnnouncementRead.Create(). +// OnConflict(sql.ConflictColumns(columns...)). +// Exec(ctx) +func (_c *AnnouncementReadCreateBulk) OnConflictColumns(columns ...string) *AnnouncementReadUpsertBulk { + _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...)) + return &AnnouncementReadUpsertBulk{ + create: _c, + } +} + +// AnnouncementReadUpsertBulk is the builder for "upsert"-ing +// a bulk of AnnouncementRead nodes. +type AnnouncementReadUpsertBulk struct { + create *AnnouncementReadCreateBulk +} + +// UpdateNewValues updates the mutable fields using the new values that +// were set on create. Using this option is equivalent to using: +// +// client.AnnouncementRead.Create(). +// OnConflict( +// sql.ResolveWithNewValues(), +// ). +// Exec(ctx) +func (u *AnnouncementReadUpsertBulk) UpdateNewValues() *AnnouncementReadUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.ResolveWithNewValues()) + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(s *sql.UpdateSet) { + for _, b := range u.create.builders { + if _, exists := b.mutation.CreatedAt(); exists { + s.SetIgnore(announcementread.FieldCreatedAt) + } + } + })) + return u +} + +// Ignore sets each column to itself in case of conflict. +// Using this option is equivalent to using: +// +// client.AnnouncementRead.Create(). +// OnConflict(sql.ResolveWithIgnore()). +// Exec(ctx) +func (u *AnnouncementReadUpsertBulk) Ignore() *AnnouncementReadUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.ResolveWithIgnore()) + return u +} + +// DoNothing configures the conflict_action to `DO NOTHING`. +// Supported only by SQLite and PostgreSQL. +func (u *AnnouncementReadUpsertBulk) DoNothing() *AnnouncementReadUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.DoNothing()) + return u +} + +// Update allows overriding fields `UPDATE` values. See the AnnouncementReadCreateBulk.OnConflict +// documentation for more info. +func (u *AnnouncementReadUpsertBulk) Update(set func(*AnnouncementReadUpsert)) *AnnouncementReadUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) { + set(&AnnouncementReadUpsert{UpdateSet: update}) + })) + return u +} + +// SetAnnouncementID sets the "announcement_id" field. +func (u *AnnouncementReadUpsertBulk) SetAnnouncementID(v int64) *AnnouncementReadUpsertBulk { + return u.Update(func(s *AnnouncementReadUpsert) { + s.SetAnnouncementID(v) + }) +} + +// UpdateAnnouncementID sets the "announcement_id" field to the value that was provided on create. +func (u *AnnouncementReadUpsertBulk) UpdateAnnouncementID() *AnnouncementReadUpsertBulk { + return u.Update(func(s *AnnouncementReadUpsert) { + s.UpdateAnnouncementID() + }) +} + +// SetUserID sets the "user_id" field. +func (u *AnnouncementReadUpsertBulk) SetUserID(v int64) *AnnouncementReadUpsertBulk { + return u.Update(func(s *AnnouncementReadUpsert) { + s.SetUserID(v) + }) +} + +// UpdateUserID sets the "user_id" field to the value that was provided on create. +func (u *AnnouncementReadUpsertBulk) UpdateUserID() *AnnouncementReadUpsertBulk { + return u.Update(func(s *AnnouncementReadUpsert) { + s.UpdateUserID() + }) +} + +// SetReadAt sets the "read_at" field. +func (u *AnnouncementReadUpsertBulk) SetReadAt(v time.Time) *AnnouncementReadUpsertBulk { + return u.Update(func(s *AnnouncementReadUpsert) { + s.SetReadAt(v) + }) +} + +// UpdateReadAt sets the "read_at" field to the value that was provided on create. +func (u *AnnouncementReadUpsertBulk) UpdateReadAt() *AnnouncementReadUpsertBulk { + return u.Update(func(s *AnnouncementReadUpsert) { + s.UpdateReadAt() + }) +} + +// Exec executes the query. +func (u *AnnouncementReadUpsertBulk) Exec(ctx context.Context) error { + if u.create.err != nil { + return u.create.err + } + for i, b := range u.create.builders { + if len(b.conflict) != 0 { + return fmt.Errorf("ent: OnConflict was set for builder %d. Set it on the AnnouncementReadCreateBulk instead", i) + } + } + if len(u.create.conflict) == 0 { + return errors.New("ent: missing options for AnnouncementReadCreateBulk.OnConflict") + } + return u.create.Exec(ctx) +} + +// ExecX is like Exec, but panics if an error occurs. +func (u *AnnouncementReadUpsertBulk) ExecX(ctx context.Context) { + if err := u.create.Exec(ctx); err != nil { + panic(err) + } +} diff --git a/backend/ent/announcementread_delete.go b/backend/ent/announcementread_delete.go new file mode 100644 index 00000000..a4da0821 --- /dev/null +++ b/backend/ent/announcementread_delete.go @@ -0,0 +1,88 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "context" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/Wei-Shaw/sub2api/ent/announcementread" + "github.com/Wei-Shaw/sub2api/ent/predicate" +) + +// AnnouncementReadDelete is the builder for deleting a AnnouncementRead entity. +type AnnouncementReadDelete struct { + config + hooks []Hook + mutation *AnnouncementReadMutation +} + +// Where appends a list predicates to the AnnouncementReadDelete builder. +func (_d *AnnouncementReadDelete) Where(ps ...predicate.AnnouncementRead) *AnnouncementReadDelete { + _d.mutation.Where(ps...) + return _d +} + +// Exec executes the deletion query and returns how many vertices were deleted. +func (_d *AnnouncementReadDelete) Exec(ctx context.Context) (int, error) { + return withHooks(ctx, _d.sqlExec, _d.mutation, _d.hooks) +} + +// ExecX is like Exec, but panics if an error occurs. +func (_d *AnnouncementReadDelete) ExecX(ctx context.Context) int { + n, err := _d.Exec(ctx) + if err != nil { + panic(err) + } + return n +} + +func (_d *AnnouncementReadDelete) sqlExec(ctx context.Context) (int, error) { + _spec := sqlgraph.NewDeleteSpec(announcementread.Table, sqlgraph.NewFieldSpec(announcementread.FieldID, field.TypeInt64)) + if ps := _d.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + affected, err := sqlgraph.DeleteNodes(ctx, _d.driver, _spec) + if err != nil && sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + _d.mutation.done = true + return affected, err +} + +// AnnouncementReadDeleteOne is the builder for deleting a single AnnouncementRead entity. +type AnnouncementReadDeleteOne struct { + _d *AnnouncementReadDelete +} + +// Where appends a list predicates to the AnnouncementReadDelete builder. +func (_d *AnnouncementReadDeleteOne) Where(ps ...predicate.AnnouncementRead) *AnnouncementReadDeleteOne { + _d._d.mutation.Where(ps...) + return _d +} + +// Exec executes the deletion query. +func (_d *AnnouncementReadDeleteOne) Exec(ctx context.Context) error { + n, err := _d._d.Exec(ctx) + switch { + case err != nil: + return err + case n == 0: + return &NotFoundError{announcementread.Label} + default: + return nil + } +} + +// ExecX is like Exec, but panics if an error occurs. +func (_d *AnnouncementReadDeleteOne) ExecX(ctx context.Context) { + if err := _d.Exec(ctx); err != nil { + panic(err) + } +} diff --git a/backend/ent/announcementread_query.go b/backend/ent/announcementread_query.go new file mode 100644 index 00000000..108299fd --- /dev/null +++ b/backend/ent/announcementread_query.go @@ -0,0 +1,718 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "context" + "fmt" + "math" + + "entgo.io/ent" + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/Wei-Shaw/sub2api/ent/announcement" + "github.com/Wei-Shaw/sub2api/ent/announcementread" + "github.com/Wei-Shaw/sub2api/ent/predicate" + "github.com/Wei-Shaw/sub2api/ent/user" +) + +// AnnouncementReadQuery is the builder for querying AnnouncementRead entities. +type AnnouncementReadQuery struct { + config + ctx *QueryContext + order []announcementread.OrderOption + inters []Interceptor + predicates []predicate.AnnouncementRead + withAnnouncement *AnnouncementQuery + withUser *UserQuery + modifiers []func(*sql.Selector) + // intermediate query (i.e. traversal path). + sql *sql.Selector + path func(context.Context) (*sql.Selector, error) +} + +// Where adds a new predicate for the AnnouncementReadQuery builder. +func (_q *AnnouncementReadQuery) Where(ps ...predicate.AnnouncementRead) *AnnouncementReadQuery { + _q.predicates = append(_q.predicates, ps...) + return _q +} + +// Limit the number of records to be returned by this query. +func (_q *AnnouncementReadQuery) Limit(limit int) *AnnouncementReadQuery { + _q.ctx.Limit = &limit + return _q +} + +// Offset to start from. +func (_q *AnnouncementReadQuery) Offset(offset int) *AnnouncementReadQuery { + _q.ctx.Offset = &offset + return _q +} + +// Unique configures the query builder to filter duplicate records on query. +// By default, unique is set to true, and can be disabled using this method. +func (_q *AnnouncementReadQuery) Unique(unique bool) *AnnouncementReadQuery { + _q.ctx.Unique = &unique + return _q +} + +// Order specifies how the records should be ordered. +func (_q *AnnouncementReadQuery) Order(o ...announcementread.OrderOption) *AnnouncementReadQuery { + _q.order = append(_q.order, o...) + return _q +} + +// QueryAnnouncement chains the current query on the "announcement" edge. +func (_q *AnnouncementReadQuery) QueryAnnouncement() *AnnouncementQuery { + query := (&AnnouncementClient{config: _q.config}).Query() + query.path = func(ctx context.Context) (fromU *sql.Selector, err error) { + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + selector := _q.sqlQuery(ctx) + if err := selector.Err(); err != nil { + return nil, err + } + step := sqlgraph.NewStep( + sqlgraph.From(announcementread.Table, announcementread.FieldID, selector), + sqlgraph.To(announcement.Table, announcement.FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, announcementread.AnnouncementTable, announcementread.AnnouncementColumn), + ) + fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step) + return fromU, nil + } + return query +} + +// QueryUser chains the current query on the "user" edge. +func (_q *AnnouncementReadQuery) QueryUser() *UserQuery { + query := (&UserClient{config: _q.config}).Query() + query.path = func(ctx context.Context) (fromU *sql.Selector, err error) { + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + selector := _q.sqlQuery(ctx) + if err := selector.Err(); err != nil { + return nil, err + } + step := sqlgraph.NewStep( + sqlgraph.From(announcementread.Table, announcementread.FieldID, selector), + sqlgraph.To(user.Table, user.FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, announcementread.UserTable, announcementread.UserColumn), + ) + fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step) + return fromU, nil + } + return query +} + +// First returns the first AnnouncementRead entity from the query. +// Returns a *NotFoundError when no AnnouncementRead was found. +func (_q *AnnouncementReadQuery) First(ctx context.Context) (*AnnouncementRead, error) { + nodes, err := _q.Limit(1).All(setContextOp(ctx, _q.ctx, ent.OpQueryFirst)) + if err != nil { + return nil, err + } + if len(nodes) == 0 { + return nil, &NotFoundError{announcementread.Label} + } + return nodes[0], nil +} + +// FirstX is like First, but panics if an error occurs. +func (_q *AnnouncementReadQuery) FirstX(ctx context.Context) *AnnouncementRead { + node, err := _q.First(ctx) + if err != nil && !IsNotFound(err) { + panic(err) + } + return node +} + +// FirstID returns the first AnnouncementRead ID from the query. +// Returns a *NotFoundError when no AnnouncementRead ID was found. +func (_q *AnnouncementReadQuery) FirstID(ctx context.Context) (id int64, err error) { + var ids []int64 + if ids, err = _q.Limit(1).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryFirstID)); err != nil { + return + } + if len(ids) == 0 { + err = &NotFoundError{announcementread.Label} + return + } + return ids[0], nil +} + +// FirstIDX is like FirstID, but panics if an error occurs. +func (_q *AnnouncementReadQuery) FirstIDX(ctx context.Context) int64 { + id, err := _q.FirstID(ctx) + if err != nil && !IsNotFound(err) { + panic(err) + } + return id +} + +// Only returns a single AnnouncementRead entity found by the query, ensuring it only returns one. +// Returns a *NotSingularError when more than one AnnouncementRead entity is found. +// Returns a *NotFoundError when no AnnouncementRead entities are found. +func (_q *AnnouncementReadQuery) Only(ctx context.Context) (*AnnouncementRead, error) { + nodes, err := _q.Limit(2).All(setContextOp(ctx, _q.ctx, ent.OpQueryOnly)) + if err != nil { + return nil, err + } + switch len(nodes) { + case 1: + return nodes[0], nil + case 0: + return nil, &NotFoundError{announcementread.Label} + default: + return nil, &NotSingularError{announcementread.Label} + } +} + +// OnlyX is like Only, but panics if an error occurs. +func (_q *AnnouncementReadQuery) OnlyX(ctx context.Context) *AnnouncementRead { + node, err := _q.Only(ctx) + if err != nil { + panic(err) + } + return node +} + +// OnlyID is like Only, but returns the only AnnouncementRead ID in the query. +// Returns a *NotSingularError when more than one AnnouncementRead ID is found. +// Returns a *NotFoundError when no entities are found. +func (_q *AnnouncementReadQuery) OnlyID(ctx context.Context) (id int64, err error) { + var ids []int64 + if ids, err = _q.Limit(2).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryOnlyID)); err != nil { + return + } + switch len(ids) { + case 1: + id = ids[0] + case 0: + err = &NotFoundError{announcementread.Label} + default: + err = &NotSingularError{announcementread.Label} + } + return +} + +// OnlyIDX is like OnlyID, but panics if an error occurs. +func (_q *AnnouncementReadQuery) OnlyIDX(ctx context.Context) int64 { + id, err := _q.OnlyID(ctx) + if err != nil { + panic(err) + } + return id +} + +// All executes the query and returns a list of AnnouncementReads. +func (_q *AnnouncementReadQuery) All(ctx context.Context) ([]*AnnouncementRead, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryAll) + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + qr := querierAll[[]*AnnouncementRead, *AnnouncementReadQuery]() + return withInterceptors[[]*AnnouncementRead](ctx, _q, qr, _q.inters) +} + +// AllX is like All, but panics if an error occurs. +func (_q *AnnouncementReadQuery) AllX(ctx context.Context) []*AnnouncementRead { + nodes, err := _q.All(ctx) + if err != nil { + panic(err) + } + return nodes +} + +// IDs executes the query and returns a list of AnnouncementRead IDs. +func (_q *AnnouncementReadQuery) IDs(ctx context.Context) (ids []int64, err error) { + if _q.ctx.Unique == nil && _q.path != nil { + _q.Unique(true) + } + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryIDs) + if err = _q.Select(announcementread.FieldID).Scan(ctx, &ids); err != nil { + return nil, err + } + return ids, nil +} + +// IDsX is like IDs, but panics if an error occurs. +func (_q *AnnouncementReadQuery) IDsX(ctx context.Context) []int64 { + ids, err := _q.IDs(ctx) + if err != nil { + panic(err) + } + return ids +} + +// Count returns the count of the given query. +func (_q *AnnouncementReadQuery) Count(ctx context.Context) (int, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryCount) + if err := _q.prepareQuery(ctx); err != nil { + return 0, err + } + return withInterceptors[int](ctx, _q, querierCount[*AnnouncementReadQuery](), _q.inters) +} + +// CountX is like Count, but panics if an error occurs. +func (_q *AnnouncementReadQuery) CountX(ctx context.Context) int { + count, err := _q.Count(ctx) + if err != nil { + panic(err) + } + return count +} + +// Exist returns true if the query has elements in the graph. +func (_q *AnnouncementReadQuery) Exist(ctx context.Context) (bool, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryExist) + switch _, err := _q.FirstID(ctx); { + case IsNotFound(err): + return false, nil + case err != nil: + return false, fmt.Errorf("ent: check existence: %w", err) + default: + return true, nil + } +} + +// ExistX is like Exist, but panics if an error occurs. +func (_q *AnnouncementReadQuery) ExistX(ctx context.Context) bool { + exist, err := _q.Exist(ctx) + if err != nil { + panic(err) + } + return exist +} + +// Clone returns a duplicate of the AnnouncementReadQuery builder, including all associated steps. It can be +// used to prepare common query builders and use them differently after the clone is made. +func (_q *AnnouncementReadQuery) Clone() *AnnouncementReadQuery { + if _q == nil { + return nil + } + return &AnnouncementReadQuery{ + config: _q.config, + ctx: _q.ctx.Clone(), + order: append([]announcementread.OrderOption{}, _q.order...), + inters: append([]Interceptor{}, _q.inters...), + predicates: append([]predicate.AnnouncementRead{}, _q.predicates...), + withAnnouncement: _q.withAnnouncement.Clone(), + withUser: _q.withUser.Clone(), + // clone intermediate query. + sql: _q.sql.Clone(), + path: _q.path, + } +} + +// WithAnnouncement tells the query-builder to eager-load the nodes that are connected to +// the "announcement" edge. The optional arguments are used to configure the query builder of the edge. +func (_q *AnnouncementReadQuery) WithAnnouncement(opts ...func(*AnnouncementQuery)) *AnnouncementReadQuery { + query := (&AnnouncementClient{config: _q.config}).Query() + for _, opt := range opts { + opt(query) + } + _q.withAnnouncement = query + return _q +} + +// WithUser tells the query-builder to eager-load the nodes that are connected to +// the "user" edge. The optional arguments are used to configure the query builder of the edge. +func (_q *AnnouncementReadQuery) WithUser(opts ...func(*UserQuery)) *AnnouncementReadQuery { + query := (&UserClient{config: _q.config}).Query() + for _, opt := range opts { + opt(query) + } + _q.withUser = query + return _q +} + +// GroupBy is used to group vertices by one or more fields/columns. +// It is often used with aggregate functions, like: count, max, mean, min, sum. +// +// Example: +// +// var v []struct { +// AnnouncementID int64 `json:"announcement_id,omitempty"` +// Count int `json:"count,omitempty"` +// } +// +// client.AnnouncementRead.Query(). +// GroupBy(announcementread.FieldAnnouncementID). +// Aggregate(ent.Count()). +// Scan(ctx, &v) +func (_q *AnnouncementReadQuery) GroupBy(field string, fields ...string) *AnnouncementReadGroupBy { + _q.ctx.Fields = append([]string{field}, fields...) + grbuild := &AnnouncementReadGroupBy{build: _q} + grbuild.flds = &_q.ctx.Fields + grbuild.label = announcementread.Label + grbuild.scan = grbuild.Scan + return grbuild +} + +// Select allows the selection one or more fields/columns for the given query, +// instead of selecting all fields in the entity. +// +// Example: +// +// var v []struct { +// AnnouncementID int64 `json:"announcement_id,omitempty"` +// } +// +// client.AnnouncementRead.Query(). +// Select(announcementread.FieldAnnouncementID). +// Scan(ctx, &v) +func (_q *AnnouncementReadQuery) Select(fields ...string) *AnnouncementReadSelect { + _q.ctx.Fields = append(_q.ctx.Fields, fields...) + sbuild := &AnnouncementReadSelect{AnnouncementReadQuery: _q} + sbuild.label = announcementread.Label + sbuild.flds, sbuild.scan = &_q.ctx.Fields, sbuild.Scan + return sbuild +} + +// Aggregate returns a AnnouncementReadSelect configured with the given aggregations. +func (_q *AnnouncementReadQuery) Aggregate(fns ...AggregateFunc) *AnnouncementReadSelect { + return _q.Select().Aggregate(fns...) +} + +func (_q *AnnouncementReadQuery) prepareQuery(ctx context.Context) error { + for _, inter := range _q.inters { + if inter == nil { + return fmt.Errorf("ent: uninitialized interceptor (forgotten import ent/runtime?)") + } + if trv, ok := inter.(Traverser); ok { + if err := trv.Traverse(ctx, _q); err != nil { + return err + } + } + } + for _, f := range _q.ctx.Fields { + if !announcementread.ValidColumn(f) { + return &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)} + } + } + if _q.path != nil { + prev, err := _q.path(ctx) + if err != nil { + return err + } + _q.sql = prev + } + return nil +} + +func (_q *AnnouncementReadQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*AnnouncementRead, error) { + var ( + nodes = []*AnnouncementRead{} + _spec = _q.querySpec() + loadedTypes = [2]bool{ + _q.withAnnouncement != nil, + _q.withUser != nil, + } + ) + _spec.ScanValues = func(columns []string) ([]any, error) { + return (*AnnouncementRead).scanValues(nil, columns) + } + _spec.Assign = func(columns []string, values []any) error { + node := &AnnouncementRead{config: _q.config} + nodes = append(nodes, node) + node.Edges.loadedTypes = loadedTypes + return node.assignValues(columns, values) + } + if len(_q.modifiers) > 0 { + _spec.Modifiers = _q.modifiers + } + for i := range hooks { + hooks[i](ctx, _spec) + } + if err := sqlgraph.QueryNodes(ctx, _q.driver, _spec); err != nil { + return nil, err + } + if len(nodes) == 0 { + return nodes, nil + } + if query := _q.withAnnouncement; query != nil { + if err := _q.loadAnnouncement(ctx, query, nodes, nil, + func(n *AnnouncementRead, e *Announcement) { n.Edges.Announcement = e }); err != nil { + return nil, err + } + } + if query := _q.withUser; query != nil { + if err := _q.loadUser(ctx, query, nodes, nil, + func(n *AnnouncementRead, e *User) { n.Edges.User = e }); err != nil { + return nil, err + } + } + return nodes, nil +} + +func (_q *AnnouncementReadQuery) loadAnnouncement(ctx context.Context, query *AnnouncementQuery, nodes []*AnnouncementRead, init func(*AnnouncementRead), assign func(*AnnouncementRead, *Announcement)) error { + ids := make([]int64, 0, len(nodes)) + nodeids := make(map[int64][]*AnnouncementRead) + for i := range nodes { + fk := nodes[i].AnnouncementID + if _, ok := nodeids[fk]; !ok { + ids = append(ids, fk) + } + nodeids[fk] = append(nodeids[fk], nodes[i]) + } + if len(ids) == 0 { + return nil + } + query.Where(announcement.IDIn(ids...)) + neighbors, err := query.All(ctx) + if err != nil { + return err + } + for _, n := range neighbors { + nodes, ok := nodeids[n.ID] + if !ok { + return fmt.Errorf(`unexpected foreign-key "announcement_id" returned %v`, n.ID) + } + for i := range nodes { + assign(nodes[i], n) + } + } + return nil +} +func (_q *AnnouncementReadQuery) loadUser(ctx context.Context, query *UserQuery, nodes []*AnnouncementRead, init func(*AnnouncementRead), assign func(*AnnouncementRead, *User)) error { + ids := make([]int64, 0, len(nodes)) + nodeids := make(map[int64][]*AnnouncementRead) + for i := range nodes { + fk := nodes[i].UserID + if _, ok := nodeids[fk]; !ok { + ids = append(ids, fk) + } + nodeids[fk] = append(nodeids[fk], nodes[i]) + } + if len(ids) == 0 { + return nil + } + query.Where(user.IDIn(ids...)) + neighbors, err := query.All(ctx) + if err != nil { + return err + } + for _, n := range neighbors { + nodes, ok := nodeids[n.ID] + if !ok { + return fmt.Errorf(`unexpected foreign-key "user_id" returned %v`, n.ID) + } + for i := range nodes { + assign(nodes[i], n) + } + } + return nil +} + +func (_q *AnnouncementReadQuery) sqlCount(ctx context.Context) (int, error) { + _spec := _q.querySpec() + if len(_q.modifiers) > 0 { + _spec.Modifiers = _q.modifiers + } + _spec.Node.Columns = _q.ctx.Fields + if len(_q.ctx.Fields) > 0 { + _spec.Unique = _q.ctx.Unique != nil && *_q.ctx.Unique + } + return sqlgraph.CountNodes(ctx, _q.driver, _spec) +} + +func (_q *AnnouncementReadQuery) querySpec() *sqlgraph.QuerySpec { + _spec := sqlgraph.NewQuerySpec(announcementread.Table, announcementread.Columns, sqlgraph.NewFieldSpec(announcementread.FieldID, field.TypeInt64)) + _spec.From = _q.sql + if unique := _q.ctx.Unique; unique != nil { + _spec.Unique = *unique + } else if _q.path != nil { + _spec.Unique = true + } + if fields := _q.ctx.Fields; len(fields) > 0 { + _spec.Node.Columns = make([]string, 0, len(fields)) + _spec.Node.Columns = append(_spec.Node.Columns, announcementread.FieldID) + for i := range fields { + if fields[i] != announcementread.FieldID { + _spec.Node.Columns = append(_spec.Node.Columns, fields[i]) + } + } + if _q.withAnnouncement != nil { + _spec.Node.AddColumnOnce(announcementread.FieldAnnouncementID) + } + if _q.withUser != nil { + _spec.Node.AddColumnOnce(announcementread.FieldUserID) + } + } + if ps := _q.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if limit := _q.ctx.Limit; limit != nil { + _spec.Limit = *limit + } + if offset := _q.ctx.Offset; offset != nil { + _spec.Offset = *offset + } + if ps := _q.order; len(ps) > 0 { + _spec.Order = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + return _spec +} + +func (_q *AnnouncementReadQuery) sqlQuery(ctx context.Context) *sql.Selector { + builder := sql.Dialect(_q.driver.Dialect()) + t1 := builder.Table(announcementread.Table) + columns := _q.ctx.Fields + if len(columns) == 0 { + columns = announcementread.Columns + } + selector := builder.Select(t1.Columns(columns...)...).From(t1) + if _q.sql != nil { + selector = _q.sql + selector.Select(selector.Columns(columns...)...) + } + if _q.ctx.Unique != nil && *_q.ctx.Unique { + selector.Distinct() + } + for _, m := range _q.modifiers { + m(selector) + } + for _, p := range _q.predicates { + p(selector) + } + for _, p := range _q.order { + p(selector) + } + if offset := _q.ctx.Offset; offset != nil { + // limit is mandatory for offset clause. We start + // with default value, and override it below if needed. + selector.Offset(*offset).Limit(math.MaxInt32) + } + if limit := _q.ctx.Limit; limit != nil { + selector.Limit(*limit) + } + return selector +} + +// ForUpdate locks the selected rows against concurrent updates, and prevent them from being +// updated, deleted or "selected ... for update" by other sessions, until the transaction is +// either committed or rolled-back. +func (_q *AnnouncementReadQuery) ForUpdate(opts ...sql.LockOption) *AnnouncementReadQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForUpdate(opts...) + }) + return _q +} + +// ForShare behaves similarly to ForUpdate, except that it acquires a shared mode lock +// on any rows that are read. Other sessions can read the rows, but cannot modify them +// until your transaction commits. +func (_q *AnnouncementReadQuery) ForShare(opts ...sql.LockOption) *AnnouncementReadQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForShare(opts...) + }) + return _q +} + +// AnnouncementReadGroupBy is the group-by builder for AnnouncementRead entities. +type AnnouncementReadGroupBy struct { + selector + build *AnnouncementReadQuery +} + +// Aggregate adds the given aggregation functions to the group-by query. +func (_g *AnnouncementReadGroupBy) Aggregate(fns ...AggregateFunc) *AnnouncementReadGroupBy { + _g.fns = append(_g.fns, fns...) + return _g +} + +// Scan applies the selector query and scans the result into the given value. +func (_g *AnnouncementReadGroupBy) Scan(ctx context.Context, v any) error { + ctx = setContextOp(ctx, _g.build.ctx, ent.OpQueryGroupBy) + if err := _g.build.prepareQuery(ctx); err != nil { + return err + } + return scanWithInterceptors[*AnnouncementReadQuery, *AnnouncementReadGroupBy](ctx, _g.build, _g, _g.build.inters, v) +} + +func (_g *AnnouncementReadGroupBy) sqlScan(ctx context.Context, root *AnnouncementReadQuery, v any) error { + selector := root.sqlQuery(ctx).Select() + aggregation := make([]string, 0, len(_g.fns)) + for _, fn := range _g.fns { + aggregation = append(aggregation, fn(selector)) + } + if len(selector.SelectedColumns()) == 0 { + columns := make([]string, 0, len(*_g.flds)+len(_g.fns)) + for _, f := range *_g.flds { + columns = append(columns, selector.C(f)) + } + columns = append(columns, aggregation...) + selector.Select(columns...) + } + selector.GroupBy(selector.Columns(*_g.flds...)...) + if err := selector.Err(); err != nil { + return err + } + rows := &sql.Rows{} + query, args := selector.Query() + if err := _g.build.driver.Query(ctx, query, args, rows); err != nil { + return err + } + defer rows.Close() + return sql.ScanSlice(rows, v) +} + +// AnnouncementReadSelect is the builder for selecting fields of AnnouncementRead entities. +type AnnouncementReadSelect struct { + *AnnouncementReadQuery + selector +} + +// Aggregate adds the given aggregation functions to the selector query. +func (_s *AnnouncementReadSelect) Aggregate(fns ...AggregateFunc) *AnnouncementReadSelect { + _s.fns = append(_s.fns, fns...) + return _s +} + +// Scan applies the selector query and scans the result into the given value. +func (_s *AnnouncementReadSelect) Scan(ctx context.Context, v any) error { + ctx = setContextOp(ctx, _s.ctx, ent.OpQuerySelect) + if err := _s.prepareQuery(ctx); err != nil { + return err + } + return scanWithInterceptors[*AnnouncementReadQuery, *AnnouncementReadSelect](ctx, _s.AnnouncementReadQuery, _s, _s.inters, v) +} + +func (_s *AnnouncementReadSelect) sqlScan(ctx context.Context, root *AnnouncementReadQuery, v any) error { + selector := root.sqlQuery(ctx) + aggregation := make([]string, 0, len(_s.fns)) + for _, fn := range _s.fns { + aggregation = append(aggregation, fn(selector)) + } + switch n := len(*_s.selector.flds); { + case n == 0 && len(aggregation) > 0: + selector.Select(aggregation...) + case n != 0 && len(aggregation) > 0: + selector.AppendSelect(aggregation...) + } + rows := &sql.Rows{} + query, args := selector.Query() + if err := _s.driver.Query(ctx, query, args, rows); err != nil { + return err + } + defer rows.Close() + return sql.ScanSlice(rows, v) +} diff --git a/backend/ent/announcementread_update.go b/backend/ent/announcementread_update.go new file mode 100644 index 00000000..55a4eef8 --- /dev/null +++ b/backend/ent/announcementread_update.go @@ -0,0 +1,456 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "context" + "errors" + "fmt" + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/Wei-Shaw/sub2api/ent/announcement" + "github.com/Wei-Shaw/sub2api/ent/announcementread" + "github.com/Wei-Shaw/sub2api/ent/predicate" + "github.com/Wei-Shaw/sub2api/ent/user" +) + +// AnnouncementReadUpdate is the builder for updating AnnouncementRead entities. +type AnnouncementReadUpdate struct { + config + hooks []Hook + mutation *AnnouncementReadMutation +} + +// Where appends a list predicates to the AnnouncementReadUpdate builder. +func (_u *AnnouncementReadUpdate) Where(ps ...predicate.AnnouncementRead) *AnnouncementReadUpdate { + _u.mutation.Where(ps...) + return _u +} + +// SetAnnouncementID sets the "announcement_id" field. +func (_u *AnnouncementReadUpdate) SetAnnouncementID(v int64) *AnnouncementReadUpdate { + _u.mutation.SetAnnouncementID(v) + return _u +} + +// SetNillableAnnouncementID sets the "announcement_id" field if the given value is not nil. +func (_u *AnnouncementReadUpdate) SetNillableAnnouncementID(v *int64) *AnnouncementReadUpdate { + if v != nil { + _u.SetAnnouncementID(*v) + } + return _u +} + +// SetUserID sets the "user_id" field. +func (_u *AnnouncementReadUpdate) SetUserID(v int64) *AnnouncementReadUpdate { + _u.mutation.SetUserID(v) + return _u +} + +// SetNillableUserID sets the "user_id" field if the given value is not nil. +func (_u *AnnouncementReadUpdate) SetNillableUserID(v *int64) *AnnouncementReadUpdate { + if v != nil { + _u.SetUserID(*v) + } + return _u +} + +// SetReadAt sets the "read_at" field. +func (_u *AnnouncementReadUpdate) SetReadAt(v time.Time) *AnnouncementReadUpdate { + _u.mutation.SetReadAt(v) + return _u +} + +// SetNillableReadAt sets the "read_at" field if the given value is not nil. +func (_u *AnnouncementReadUpdate) SetNillableReadAt(v *time.Time) *AnnouncementReadUpdate { + if v != nil { + _u.SetReadAt(*v) + } + return _u +} + +// SetAnnouncement sets the "announcement" edge to the Announcement entity. +func (_u *AnnouncementReadUpdate) SetAnnouncement(v *Announcement) *AnnouncementReadUpdate { + return _u.SetAnnouncementID(v.ID) +} + +// SetUser sets the "user" edge to the User entity. +func (_u *AnnouncementReadUpdate) SetUser(v *User) *AnnouncementReadUpdate { + return _u.SetUserID(v.ID) +} + +// Mutation returns the AnnouncementReadMutation object of the builder. +func (_u *AnnouncementReadUpdate) Mutation() *AnnouncementReadMutation { + return _u.mutation +} + +// ClearAnnouncement clears the "announcement" edge to the Announcement entity. +func (_u *AnnouncementReadUpdate) ClearAnnouncement() *AnnouncementReadUpdate { + _u.mutation.ClearAnnouncement() + return _u +} + +// ClearUser clears the "user" edge to the User entity. +func (_u *AnnouncementReadUpdate) ClearUser() *AnnouncementReadUpdate { + _u.mutation.ClearUser() + return _u +} + +// Save executes the query and returns the number of nodes affected by the update operation. +func (_u *AnnouncementReadUpdate) Save(ctx context.Context) (int, error) { + return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks) +} + +// SaveX is like Save, but panics if an error occurs. +func (_u *AnnouncementReadUpdate) SaveX(ctx context.Context) int { + affected, err := _u.Save(ctx) + if err != nil { + panic(err) + } + return affected +} + +// Exec executes the query. +func (_u *AnnouncementReadUpdate) Exec(ctx context.Context) error { + _, err := _u.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_u *AnnouncementReadUpdate) ExecX(ctx context.Context) { + if err := _u.Exec(ctx); err != nil { + panic(err) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_u *AnnouncementReadUpdate) check() error { + if _u.mutation.AnnouncementCleared() && len(_u.mutation.AnnouncementIDs()) > 0 { + return errors.New(`ent: clearing a required unique edge "AnnouncementRead.announcement"`) + } + if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 { + return errors.New(`ent: clearing a required unique edge "AnnouncementRead.user"`) + } + return nil +} + +func (_u *AnnouncementReadUpdate) sqlSave(ctx context.Context) (_node int, err error) { + if err := _u.check(); err != nil { + return _node, err + } + _spec := sqlgraph.NewUpdateSpec(announcementread.Table, announcementread.Columns, sqlgraph.NewFieldSpec(announcementread.FieldID, field.TypeInt64)) + if ps := _u.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if value, ok := _u.mutation.ReadAt(); ok { + _spec.SetField(announcementread.FieldReadAt, field.TypeTime, value) + } + if _u.mutation.AnnouncementCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: announcementread.AnnouncementTable, + Columns: []string{announcementread.AnnouncementColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(announcement.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.AnnouncementIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: announcementread.AnnouncementTable, + Columns: []string{announcementread.AnnouncementColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(announcement.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } + if _u.mutation.UserCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: announcementread.UserTable, + Columns: []string{announcementread.UserColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.UserIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: announcementread.UserTable, + Columns: []string{announcementread.UserColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } + if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { + if _, ok := err.(*sqlgraph.NotFoundError); ok { + err = &NotFoundError{announcementread.Label} + } else if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return 0, err + } + _u.mutation.done = true + return _node, nil +} + +// AnnouncementReadUpdateOne is the builder for updating a single AnnouncementRead entity. +type AnnouncementReadUpdateOne struct { + config + fields []string + hooks []Hook + mutation *AnnouncementReadMutation +} + +// SetAnnouncementID sets the "announcement_id" field. +func (_u *AnnouncementReadUpdateOne) SetAnnouncementID(v int64) *AnnouncementReadUpdateOne { + _u.mutation.SetAnnouncementID(v) + return _u +} + +// SetNillableAnnouncementID sets the "announcement_id" field if the given value is not nil. +func (_u *AnnouncementReadUpdateOne) SetNillableAnnouncementID(v *int64) *AnnouncementReadUpdateOne { + if v != nil { + _u.SetAnnouncementID(*v) + } + return _u +} + +// SetUserID sets the "user_id" field. +func (_u *AnnouncementReadUpdateOne) SetUserID(v int64) *AnnouncementReadUpdateOne { + _u.mutation.SetUserID(v) + return _u +} + +// SetNillableUserID sets the "user_id" field if the given value is not nil. +func (_u *AnnouncementReadUpdateOne) SetNillableUserID(v *int64) *AnnouncementReadUpdateOne { + if v != nil { + _u.SetUserID(*v) + } + return _u +} + +// SetReadAt sets the "read_at" field. +func (_u *AnnouncementReadUpdateOne) SetReadAt(v time.Time) *AnnouncementReadUpdateOne { + _u.mutation.SetReadAt(v) + return _u +} + +// SetNillableReadAt sets the "read_at" field if the given value is not nil. +func (_u *AnnouncementReadUpdateOne) SetNillableReadAt(v *time.Time) *AnnouncementReadUpdateOne { + if v != nil { + _u.SetReadAt(*v) + } + return _u +} + +// SetAnnouncement sets the "announcement" edge to the Announcement entity. +func (_u *AnnouncementReadUpdateOne) SetAnnouncement(v *Announcement) *AnnouncementReadUpdateOne { + return _u.SetAnnouncementID(v.ID) +} + +// SetUser sets the "user" edge to the User entity. +func (_u *AnnouncementReadUpdateOne) SetUser(v *User) *AnnouncementReadUpdateOne { + return _u.SetUserID(v.ID) +} + +// Mutation returns the AnnouncementReadMutation object of the builder. +func (_u *AnnouncementReadUpdateOne) Mutation() *AnnouncementReadMutation { + return _u.mutation +} + +// ClearAnnouncement clears the "announcement" edge to the Announcement entity. +func (_u *AnnouncementReadUpdateOne) ClearAnnouncement() *AnnouncementReadUpdateOne { + _u.mutation.ClearAnnouncement() + return _u +} + +// ClearUser clears the "user" edge to the User entity. +func (_u *AnnouncementReadUpdateOne) ClearUser() *AnnouncementReadUpdateOne { + _u.mutation.ClearUser() + return _u +} + +// Where appends a list predicates to the AnnouncementReadUpdate builder. +func (_u *AnnouncementReadUpdateOne) Where(ps ...predicate.AnnouncementRead) *AnnouncementReadUpdateOne { + _u.mutation.Where(ps...) + return _u +} + +// Select allows selecting one or more fields (columns) of the returned entity. +// The default is selecting all fields defined in the entity schema. +func (_u *AnnouncementReadUpdateOne) Select(field string, fields ...string) *AnnouncementReadUpdateOne { + _u.fields = append([]string{field}, fields...) + return _u +} + +// Save executes the query and returns the updated AnnouncementRead entity. +func (_u *AnnouncementReadUpdateOne) Save(ctx context.Context) (*AnnouncementRead, error) { + return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks) +} + +// SaveX is like Save, but panics if an error occurs. +func (_u *AnnouncementReadUpdateOne) SaveX(ctx context.Context) *AnnouncementRead { + node, err := _u.Save(ctx) + if err != nil { + panic(err) + } + return node +} + +// Exec executes the query on the entity. +func (_u *AnnouncementReadUpdateOne) Exec(ctx context.Context) error { + _, err := _u.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_u *AnnouncementReadUpdateOne) ExecX(ctx context.Context) { + if err := _u.Exec(ctx); err != nil { + panic(err) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_u *AnnouncementReadUpdateOne) check() error { + if _u.mutation.AnnouncementCleared() && len(_u.mutation.AnnouncementIDs()) > 0 { + return errors.New(`ent: clearing a required unique edge "AnnouncementRead.announcement"`) + } + if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 { + return errors.New(`ent: clearing a required unique edge "AnnouncementRead.user"`) + } + return nil +} + +func (_u *AnnouncementReadUpdateOne) sqlSave(ctx context.Context) (_node *AnnouncementRead, err error) { + if err := _u.check(); err != nil { + return _node, err + } + _spec := sqlgraph.NewUpdateSpec(announcementread.Table, announcementread.Columns, sqlgraph.NewFieldSpec(announcementread.FieldID, field.TypeInt64)) + id, ok := _u.mutation.ID() + if !ok { + return nil, &ValidationError{Name: "id", err: errors.New(`ent: missing "AnnouncementRead.id" for update`)} + } + _spec.Node.ID.Value = id + if fields := _u.fields; len(fields) > 0 { + _spec.Node.Columns = make([]string, 0, len(fields)) + _spec.Node.Columns = append(_spec.Node.Columns, announcementread.FieldID) + for _, f := range fields { + if !announcementread.ValidColumn(f) { + return nil, &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)} + } + if f != announcementread.FieldID { + _spec.Node.Columns = append(_spec.Node.Columns, f) + } + } + } + if ps := _u.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if value, ok := _u.mutation.ReadAt(); ok { + _spec.SetField(announcementread.FieldReadAt, field.TypeTime, value) + } + if _u.mutation.AnnouncementCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: announcementread.AnnouncementTable, + Columns: []string{announcementread.AnnouncementColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(announcement.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.AnnouncementIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: announcementread.AnnouncementTable, + Columns: []string{announcementread.AnnouncementColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(announcement.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } + if _u.mutation.UserCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: announcementread.UserTable, + Columns: []string{announcementread.UserColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.UserIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: announcementread.UserTable, + Columns: []string{announcementread.UserColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } + _node = &AnnouncementRead{config: _u.config} + _spec.Assign = _node.assignValues + _spec.ScanValues = _node.scanValues + if err = sqlgraph.UpdateNode(ctx, _u.driver, _spec); err != nil { + if _, ok := err.(*sqlgraph.NotFoundError); ok { + err = &NotFoundError{announcementread.Label} + } else if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return nil, err + } + _u.mutation.done = true + return _node, nil +} diff --git a/backend/ent/client.go b/backend/ent/client.go index f6c13e84..a17721da 100644 --- a/backend/ent/client.go +++ b/backend/ent/client.go @@ -17,6 +17,8 @@ import ( "entgo.io/ent/dialect/sql/sqlgraph" "github.com/Wei-Shaw/sub2api/ent/account" "github.com/Wei-Shaw/sub2api/ent/accountgroup" + "github.com/Wei-Shaw/sub2api/ent/announcement" + "github.com/Wei-Shaw/sub2api/ent/announcementread" "github.com/Wei-Shaw/sub2api/ent/apikey" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/promocode" @@ -46,6 +48,10 @@ type Client struct { Account *AccountClient // AccountGroup is the client for interacting with the AccountGroup builders. AccountGroup *AccountGroupClient + // Announcement is the client for interacting with the Announcement builders. + Announcement *AnnouncementClient + // AnnouncementRead is the client for interacting with the AnnouncementRead builders. + AnnouncementRead *AnnouncementReadClient // Group is the client for interacting with the Group builders. Group *GroupClient // PromoCode is the client for interacting with the PromoCode builders. @@ -86,6 +92,8 @@ func (c *Client) init() { c.APIKey = NewAPIKeyClient(c.config) c.Account = NewAccountClient(c.config) c.AccountGroup = NewAccountGroupClient(c.config) + c.Announcement = NewAnnouncementClient(c.config) + c.AnnouncementRead = NewAnnouncementReadClient(c.config) c.Group = NewGroupClient(c.config) c.PromoCode = NewPromoCodeClient(c.config) c.PromoCodeUsage = NewPromoCodeUsageClient(c.config) @@ -194,6 +202,8 @@ func (c *Client) Tx(ctx context.Context) (*Tx, error) { APIKey: NewAPIKeyClient(cfg), Account: NewAccountClient(cfg), AccountGroup: NewAccountGroupClient(cfg), + Announcement: NewAnnouncementClient(cfg), + AnnouncementRead: NewAnnouncementReadClient(cfg), Group: NewGroupClient(cfg), PromoCode: NewPromoCodeClient(cfg), PromoCodeUsage: NewPromoCodeUsageClient(cfg), @@ -229,6 +239,8 @@ func (c *Client) BeginTx(ctx context.Context, opts *sql.TxOptions) (*Tx, error) APIKey: NewAPIKeyClient(cfg), Account: NewAccountClient(cfg), AccountGroup: NewAccountGroupClient(cfg), + Announcement: NewAnnouncementClient(cfg), + AnnouncementRead: NewAnnouncementReadClient(cfg), Group: NewGroupClient(cfg), PromoCode: NewPromoCodeClient(cfg), PromoCodeUsage: NewPromoCodeUsageClient(cfg), @@ -271,10 +283,10 @@ func (c *Client) Close() error { // In order to add hooks to a specific client, call: `client.Node.Use(...)`. func (c *Client) Use(hooks ...Hook) { for _, n := range []interface{ Use(...Hook) }{ - c.APIKey, c.Account, c.AccountGroup, c.Group, c.PromoCode, c.PromoCodeUsage, - c.Proxy, c.RedeemCode, c.Setting, c.UsageCleanupTask, c.UsageLog, c.User, - c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue, - c.UserSubscription, + c.APIKey, c.Account, c.AccountGroup, c.Announcement, c.AnnouncementRead, + c.Group, c.PromoCode, c.PromoCodeUsage, c.Proxy, c.RedeemCode, c.Setting, + c.UsageCleanupTask, c.UsageLog, c.User, c.UserAllowedGroup, + c.UserAttributeDefinition, c.UserAttributeValue, c.UserSubscription, } { n.Use(hooks...) } @@ -284,10 +296,10 @@ func (c *Client) Use(hooks ...Hook) { // In order to add interceptors to a specific client, call: `client.Node.Intercept(...)`. func (c *Client) Intercept(interceptors ...Interceptor) { for _, n := range []interface{ Intercept(...Interceptor) }{ - c.APIKey, c.Account, c.AccountGroup, c.Group, c.PromoCode, c.PromoCodeUsage, - c.Proxy, c.RedeemCode, c.Setting, c.UsageCleanupTask, c.UsageLog, c.User, - c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue, - c.UserSubscription, + c.APIKey, c.Account, c.AccountGroup, c.Announcement, c.AnnouncementRead, + c.Group, c.PromoCode, c.PromoCodeUsage, c.Proxy, c.RedeemCode, c.Setting, + c.UsageCleanupTask, c.UsageLog, c.User, c.UserAllowedGroup, + c.UserAttributeDefinition, c.UserAttributeValue, c.UserSubscription, } { n.Intercept(interceptors...) } @@ -302,6 +314,10 @@ func (c *Client) Mutate(ctx context.Context, m Mutation) (Value, error) { return c.Account.mutate(ctx, m) case *AccountGroupMutation: return c.AccountGroup.mutate(ctx, m) + case *AnnouncementMutation: + return c.Announcement.mutate(ctx, m) + case *AnnouncementReadMutation: + return c.AnnouncementRead.mutate(ctx, m) case *GroupMutation: return c.Group.mutate(ctx, m) case *PromoCodeMutation: @@ -831,6 +847,320 @@ func (c *AccountGroupClient) mutate(ctx context.Context, m *AccountGroupMutation } } +// AnnouncementClient is a client for the Announcement schema. +type AnnouncementClient struct { + config +} + +// NewAnnouncementClient returns a client for the Announcement from the given config. +func NewAnnouncementClient(c config) *AnnouncementClient { + return &AnnouncementClient{config: c} +} + +// Use adds a list of mutation hooks to the hooks stack. +// A call to `Use(f, g, h)` equals to `announcement.Hooks(f(g(h())))`. +func (c *AnnouncementClient) Use(hooks ...Hook) { + c.hooks.Announcement = append(c.hooks.Announcement, hooks...) +} + +// Intercept adds a list of query interceptors to the interceptors stack. +// A call to `Intercept(f, g, h)` equals to `announcement.Intercept(f(g(h())))`. +func (c *AnnouncementClient) Intercept(interceptors ...Interceptor) { + c.inters.Announcement = append(c.inters.Announcement, interceptors...) +} + +// Create returns a builder for creating a Announcement entity. +func (c *AnnouncementClient) Create() *AnnouncementCreate { + mutation := newAnnouncementMutation(c.config, OpCreate) + return &AnnouncementCreate{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// CreateBulk returns a builder for creating a bulk of Announcement entities. +func (c *AnnouncementClient) CreateBulk(builders ...*AnnouncementCreate) *AnnouncementCreateBulk { + return &AnnouncementCreateBulk{config: c.config, builders: builders} +} + +// MapCreateBulk creates a bulk creation builder from the given slice. For each item in the slice, the function creates +// a builder and applies setFunc on it. +func (c *AnnouncementClient) MapCreateBulk(slice any, setFunc func(*AnnouncementCreate, int)) *AnnouncementCreateBulk { + rv := reflect.ValueOf(slice) + if rv.Kind() != reflect.Slice { + return &AnnouncementCreateBulk{err: fmt.Errorf("calling to AnnouncementClient.MapCreateBulk with wrong type %T, need slice", slice)} + } + builders := make([]*AnnouncementCreate, rv.Len()) + for i := 0; i < rv.Len(); i++ { + builders[i] = c.Create() + setFunc(builders[i], i) + } + return &AnnouncementCreateBulk{config: c.config, builders: builders} +} + +// Update returns an update builder for Announcement. +func (c *AnnouncementClient) Update() *AnnouncementUpdate { + mutation := newAnnouncementMutation(c.config, OpUpdate) + return &AnnouncementUpdate{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// UpdateOne returns an update builder for the given entity. +func (c *AnnouncementClient) UpdateOne(_m *Announcement) *AnnouncementUpdateOne { + mutation := newAnnouncementMutation(c.config, OpUpdateOne, withAnnouncement(_m)) + return &AnnouncementUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// UpdateOneID returns an update builder for the given id. +func (c *AnnouncementClient) UpdateOneID(id int64) *AnnouncementUpdateOne { + mutation := newAnnouncementMutation(c.config, OpUpdateOne, withAnnouncementID(id)) + return &AnnouncementUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// Delete returns a delete builder for Announcement. +func (c *AnnouncementClient) Delete() *AnnouncementDelete { + mutation := newAnnouncementMutation(c.config, OpDelete) + return &AnnouncementDelete{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// DeleteOne returns a builder for deleting the given entity. +func (c *AnnouncementClient) DeleteOne(_m *Announcement) *AnnouncementDeleteOne { + return c.DeleteOneID(_m.ID) +} + +// DeleteOneID returns a builder for deleting the given entity by its id. +func (c *AnnouncementClient) DeleteOneID(id int64) *AnnouncementDeleteOne { + builder := c.Delete().Where(announcement.ID(id)) + builder.mutation.id = &id + builder.mutation.op = OpDeleteOne + return &AnnouncementDeleteOne{builder} +} + +// Query returns a query builder for Announcement. +func (c *AnnouncementClient) Query() *AnnouncementQuery { + return &AnnouncementQuery{ + config: c.config, + ctx: &QueryContext{Type: TypeAnnouncement}, + inters: c.Interceptors(), + } +} + +// Get returns a Announcement entity by its id. +func (c *AnnouncementClient) Get(ctx context.Context, id int64) (*Announcement, error) { + return c.Query().Where(announcement.ID(id)).Only(ctx) +} + +// GetX is like Get, but panics if an error occurs. +func (c *AnnouncementClient) GetX(ctx context.Context, id int64) *Announcement { + obj, err := c.Get(ctx, id) + if err != nil { + panic(err) + } + return obj +} + +// QueryReads queries the reads edge of a Announcement. +func (c *AnnouncementClient) QueryReads(_m *Announcement) *AnnouncementReadQuery { + query := (&AnnouncementReadClient{config: c.config}).Query() + query.path = func(context.Context) (fromV *sql.Selector, _ error) { + id := _m.ID + step := sqlgraph.NewStep( + sqlgraph.From(announcement.Table, announcement.FieldID, id), + sqlgraph.To(announcementread.Table, announcementread.FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, announcement.ReadsTable, announcement.ReadsColumn), + ) + fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) + return fromV, nil + } + return query +} + +// Hooks returns the client hooks. +func (c *AnnouncementClient) Hooks() []Hook { + return c.hooks.Announcement +} + +// Interceptors returns the client interceptors. +func (c *AnnouncementClient) Interceptors() []Interceptor { + return c.inters.Announcement +} + +func (c *AnnouncementClient) mutate(ctx context.Context, m *AnnouncementMutation) (Value, error) { + switch m.Op() { + case OpCreate: + return (&AnnouncementCreate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpUpdate: + return (&AnnouncementUpdate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpUpdateOne: + return (&AnnouncementUpdateOne{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpDelete, OpDeleteOne: + return (&AnnouncementDelete{config: c.config, hooks: c.Hooks(), mutation: m}).Exec(ctx) + default: + return nil, fmt.Errorf("ent: unknown Announcement mutation op: %q", m.Op()) + } +} + +// AnnouncementReadClient is a client for the AnnouncementRead schema. +type AnnouncementReadClient struct { + config +} + +// NewAnnouncementReadClient returns a client for the AnnouncementRead from the given config. +func NewAnnouncementReadClient(c config) *AnnouncementReadClient { + return &AnnouncementReadClient{config: c} +} + +// Use adds a list of mutation hooks to the hooks stack. +// A call to `Use(f, g, h)` equals to `announcementread.Hooks(f(g(h())))`. +func (c *AnnouncementReadClient) Use(hooks ...Hook) { + c.hooks.AnnouncementRead = append(c.hooks.AnnouncementRead, hooks...) +} + +// Intercept adds a list of query interceptors to the interceptors stack. +// A call to `Intercept(f, g, h)` equals to `announcementread.Intercept(f(g(h())))`. +func (c *AnnouncementReadClient) Intercept(interceptors ...Interceptor) { + c.inters.AnnouncementRead = append(c.inters.AnnouncementRead, interceptors...) +} + +// Create returns a builder for creating a AnnouncementRead entity. +func (c *AnnouncementReadClient) Create() *AnnouncementReadCreate { + mutation := newAnnouncementReadMutation(c.config, OpCreate) + return &AnnouncementReadCreate{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// CreateBulk returns a builder for creating a bulk of AnnouncementRead entities. +func (c *AnnouncementReadClient) CreateBulk(builders ...*AnnouncementReadCreate) *AnnouncementReadCreateBulk { + return &AnnouncementReadCreateBulk{config: c.config, builders: builders} +} + +// MapCreateBulk creates a bulk creation builder from the given slice. For each item in the slice, the function creates +// a builder and applies setFunc on it. +func (c *AnnouncementReadClient) MapCreateBulk(slice any, setFunc func(*AnnouncementReadCreate, int)) *AnnouncementReadCreateBulk { + rv := reflect.ValueOf(slice) + if rv.Kind() != reflect.Slice { + return &AnnouncementReadCreateBulk{err: fmt.Errorf("calling to AnnouncementReadClient.MapCreateBulk with wrong type %T, need slice", slice)} + } + builders := make([]*AnnouncementReadCreate, rv.Len()) + for i := 0; i < rv.Len(); i++ { + builders[i] = c.Create() + setFunc(builders[i], i) + } + return &AnnouncementReadCreateBulk{config: c.config, builders: builders} +} + +// Update returns an update builder for AnnouncementRead. +func (c *AnnouncementReadClient) Update() *AnnouncementReadUpdate { + mutation := newAnnouncementReadMutation(c.config, OpUpdate) + return &AnnouncementReadUpdate{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// UpdateOne returns an update builder for the given entity. +func (c *AnnouncementReadClient) UpdateOne(_m *AnnouncementRead) *AnnouncementReadUpdateOne { + mutation := newAnnouncementReadMutation(c.config, OpUpdateOne, withAnnouncementRead(_m)) + return &AnnouncementReadUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// UpdateOneID returns an update builder for the given id. +func (c *AnnouncementReadClient) UpdateOneID(id int64) *AnnouncementReadUpdateOne { + mutation := newAnnouncementReadMutation(c.config, OpUpdateOne, withAnnouncementReadID(id)) + return &AnnouncementReadUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// Delete returns a delete builder for AnnouncementRead. +func (c *AnnouncementReadClient) Delete() *AnnouncementReadDelete { + mutation := newAnnouncementReadMutation(c.config, OpDelete) + return &AnnouncementReadDelete{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// DeleteOne returns a builder for deleting the given entity. +func (c *AnnouncementReadClient) DeleteOne(_m *AnnouncementRead) *AnnouncementReadDeleteOne { + return c.DeleteOneID(_m.ID) +} + +// DeleteOneID returns a builder for deleting the given entity by its id. +func (c *AnnouncementReadClient) DeleteOneID(id int64) *AnnouncementReadDeleteOne { + builder := c.Delete().Where(announcementread.ID(id)) + builder.mutation.id = &id + builder.mutation.op = OpDeleteOne + return &AnnouncementReadDeleteOne{builder} +} + +// Query returns a query builder for AnnouncementRead. +func (c *AnnouncementReadClient) Query() *AnnouncementReadQuery { + return &AnnouncementReadQuery{ + config: c.config, + ctx: &QueryContext{Type: TypeAnnouncementRead}, + inters: c.Interceptors(), + } +} + +// Get returns a AnnouncementRead entity by its id. +func (c *AnnouncementReadClient) Get(ctx context.Context, id int64) (*AnnouncementRead, error) { + return c.Query().Where(announcementread.ID(id)).Only(ctx) +} + +// GetX is like Get, but panics if an error occurs. +func (c *AnnouncementReadClient) GetX(ctx context.Context, id int64) *AnnouncementRead { + obj, err := c.Get(ctx, id) + if err != nil { + panic(err) + } + return obj +} + +// QueryAnnouncement queries the announcement edge of a AnnouncementRead. +func (c *AnnouncementReadClient) QueryAnnouncement(_m *AnnouncementRead) *AnnouncementQuery { + query := (&AnnouncementClient{config: c.config}).Query() + query.path = func(context.Context) (fromV *sql.Selector, _ error) { + id := _m.ID + step := sqlgraph.NewStep( + sqlgraph.From(announcementread.Table, announcementread.FieldID, id), + sqlgraph.To(announcement.Table, announcement.FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, announcementread.AnnouncementTable, announcementread.AnnouncementColumn), + ) + fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) + return fromV, nil + } + return query +} + +// QueryUser queries the user edge of a AnnouncementRead. +func (c *AnnouncementReadClient) QueryUser(_m *AnnouncementRead) *UserQuery { + query := (&UserClient{config: c.config}).Query() + query.path = func(context.Context) (fromV *sql.Selector, _ error) { + id := _m.ID + step := sqlgraph.NewStep( + sqlgraph.From(announcementread.Table, announcementread.FieldID, id), + sqlgraph.To(user.Table, user.FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, announcementread.UserTable, announcementread.UserColumn), + ) + fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) + return fromV, nil + } + return query +} + +// Hooks returns the client hooks. +func (c *AnnouncementReadClient) Hooks() []Hook { + return c.hooks.AnnouncementRead +} + +// Interceptors returns the client interceptors. +func (c *AnnouncementReadClient) Interceptors() []Interceptor { + return c.inters.AnnouncementRead +} + +func (c *AnnouncementReadClient) mutate(ctx context.Context, m *AnnouncementReadMutation) (Value, error) { + switch m.Op() { + case OpCreate: + return (&AnnouncementReadCreate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpUpdate: + return (&AnnouncementReadUpdate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpUpdateOne: + return (&AnnouncementReadUpdateOne{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpDelete, OpDeleteOne: + return (&AnnouncementReadDelete{config: c.config, hooks: c.Hooks(), mutation: m}).Exec(ctx) + default: + return nil, fmt.Errorf("ent: unknown AnnouncementRead mutation op: %q", m.Op()) + } +} + // GroupClient is a client for the Group schema. type GroupClient struct { config @@ -2375,6 +2705,22 @@ func (c *UserClient) QueryAssignedSubscriptions(_m *User) *UserSubscriptionQuery return query } +// QueryAnnouncementReads queries the announcement_reads edge of a User. +func (c *UserClient) QueryAnnouncementReads(_m *User) *AnnouncementReadQuery { + query := (&AnnouncementReadClient{config: c.config}).Query() + query.path = func(context.Context) (fromV *sql.Selector, _ error) { + id := _m.ID + step := sqlgraph.NewStep( + sqlgraph.From(user.Table, user.FieldID, id), + sqlgraph.To(announcementread.Table, announcementread.FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, user.AnnouncementReadsTable, user.AnnouncementReadsColumn), + ) + fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) + return fromV, nil + } + return query +} + // QueryAllowedGroups queries the allowed_groups edge of a User. func (c *UserClient) QueryAllowedGroups(_m *User) *GroupQuery { query := (&GroupClient{config: c.config}).Query() @@ -3116,14 +3462,16 @@ func (c *UserSubscriptionClient) mutate(ctx context.Context, m *UserSubscription // hooks and interceptors per client, for fast access. type ( hooks struct { - APIKey, Account, AccountGroup, Group, PromoCode, PromoCodeUsage, Proxy, - RedeemCode, Setting, UsageCleanupTask, UsageLog, User, UserAllowedGroup, - UserAttributeDefinition, UserAttributeValue, UserSubscription []ent.Hook + APIKey, Account, AccountGroup, Announcement, AnnouncementRead, Group, PromoCode, + PromoCodeUsage, Proxy, RedeemCode, Setting, UsageCleanupTask, UsageLog, User, + UserAllowedGroup, UserAttributeDefinition, UserAttributeValue, + UserSubscription []ent.Hook } inters struct { - APIKey, Account, AccountGroup, Group, PromoCode, PromoCodeUsage, Proxy, - RedeemCode, Setting, UsageCleanupTask, UsageLog, User, UserAllowedGroup, - UserAttributeDefinition, UserAttributeValue, UserSubscription []ent.Interceptor + APIKey, Account, AccountGroup, Announcement, AnnouncementRead, Group, PromoCode, + PromoCodeUsage, Proxy, RedeemCode, Setting, UsageCleanupTask, UsageLog, User, + UserAllowedGroup, UserAttributeDefinition, UserAttributeValue, + UserSubscription []ent.Interceptor } ) diff --git a/backend/ent/ent.go b/backend/ent/ent.go index 4bcc2642..05e30ba7 100644 --- a/backend/ent/ent.go +++ b/backend/ent/ent.go @@ -14,6 +14,8 @@ import ( "entgo.io/ent/dialect/sql/sqlgraph" "github.com/Wei-Shaw/sub2api/ent/account" "github.com/Wei-Shaw/sub2api/ent/accountgroup" + "github.com/Wei-Shaw/sub2api/ent/announcement" + "github.com/Wei-Shaw/sub2api/ent/announcementread" "github.com/Wei-Shaw/sub2api/ent/apikey" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/promocode" @@ -91,6 +93,8 @@ func checkColumn(t, c string) error { apikey.Table: apikey.ValidColumn, account.Table: account.ValidColumn, accountgroup.Table: accountgroup.ValidColumn, + announcement.Table: announcement.ValidColumn, + announcementread.Table: announcementread.ValidColumn, group.Table: group.ValidColumn, promocode.Table: promocode.ValidColumn, promocodeusage.Table: promocodeusage.ValidColumn, diff --git a/backend/ent/hook/hook.go b/backend/ent/hook/hook.go index edd84f5e..1e653c77 100644 --- a/backend/ent/hook/hook.go +++ b/backend/ent/hook/hook.go @@ -45,6 +45,30 @@ func (f AccountGroupFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.AccountGroupMutation", m) } +// The AnnouncementFunc type is an adapter to allow the use of ordinary +// function as Announcement mutator. +type AnnouncementFunc func(context.Context, *ent.AnnouncementMutation) (ent.Value, error) + +// Mutate calls f(ctx, m). +func (f AnnouncementFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) { + if mv, ok := m.(*ent.AnnouncementMutation); ok { + return f(ctx, mv) + } + return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.AnnouncementMutation", m) +} + +// The AnnouncementReadFunc type is an adapter to allow the use of ordinary +// function as AnnouncementRead mutator. +type AnnouncementReadFunc func(context.Context, *ent.AnnouncementReadMutation) (ent.Value, error) + +// Mutate calls f(ctx, m). +func (f AnnouncementReadFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) { + if mv, ok := m.(*ent.AnnouncementReadMutation); ok { + return f(ctx, mv) + } + return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.AnnouncementReadMutation", m) +} + // The GroupFunc type is an adapter to allow the use of ordinary // function as Group mutator. type GroupFunc func(context.Context, *ent.GroupMutation) (ent.Value, error) diff --git a/backend/ent/intercept/intercept.go b/backend/ent/intercept/intercept.go index f18c0624..a37be48f 100644 --- a/backend/ent/intercept/intercept.go +++ b/backend/ent/intercept/intercept.go @@ -10,6 +10,8 @@ import ( "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/ent/account" "github.com/Wei-Shaw/sub2api/ent/accountgroup" + "github.com/Wei-Shaw/sub2api/ent/announcement" + "github.com/Wei-Shaw/sub2api/ent/announcementread" "github.com/Wei-Shaw/sub2api/ent/apikey" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/predicate" @@ -164,6 +166,60 @@ func (f TraverseAccountGroup) Traverse(ctx context.Context, q ent.Query) error { return fmt.Errorf("unexpected query type %T. expect *ent.AccountGroupQuery", q) } +// The AnnouncementFunc type is an adapter to allow the use of ordinary function as a Querier. +type AnnouncementFunc func(context.Context, *ent.AnnouncementQuery) (ent.Value, error) + +// Query calls f(ctx, q). +func (f AnnouncementFunc) Query(ctx context.Context, q ent.Query) (ent.Value, error) { + if q, ok := q.(*ent.AnnouncementQuery); ok { + return f(ctx, q) + } + return nil, fmt.Errorf("unexpected query type %T. expect *ent.AnnouncementQuery", q) +} + +// The TraverseAnnouncement type is an adapter to allow the use of ordinary function as Traverser. +type TraverseAnnouncement func(context.Context, *ent.AnnouncementQuery) error + +// Intercept is a dummy implementation of Intercept that returns the next Querier in the pipeline. +func (f TraverseAnnouncement) Intercept(next ent.Querier) ent.Querier { + return next +} + +// Traverse calls f(ctx, q). +func (f TraverseAnnouncement) Traverse(ctx context.Context, q ent.Query) error { + if q, ok := q.(*ent.AnnouncementQuery); ok { + return f(ctx, q) + } + return fmt.Errorf("unexpected query type %T. expect *ent.AnnouncementQuery", q) +} + +// The AnnouncementReadFunc type is an adapter to allow the use of ordinary function as a Querier. +type AnnouncementReadFunc func(context.Context, *ent.AnnouncementReadQuery) (ent.Value, error) + +// Query calls f(ctx, q). +func (f AnnouncementReadFunc) Query(ctx context.Context, q ent.Query) (ent.Value, error) { + if q, ok := q.(*ent.AnnouncementReadQuery); ok { + return f(ctx, q) + } + return nil, fmt.Errorf("unexpected query type %T. expect *ent.AnnouncementReadQuery", q) +} + +// The TraverseAnnouncementRead type is an adapter to allow the use of ordinary function as Traverser. +type TraverseAnnouncementRead func(context.Context, *ent.AnnouncementReadQuery) error + +// Intercept is a dummy implementation of Intercept that returns the next Querier in the pipeline. +func (f TraverseAnnouncementRead) Intercept(next ent.Querier) ent.Querier { + return next +} + +// Traverse calls f(ctx, q). +func (f TraverseAnnouncementRead) Traverse(ctx context.Context, q ent.Query) error { + if q, ok := q.(*ent.AnnouncementReadQuery); ok { + return f(ctx, q) + } + return fmt.Errorf("unexpected query type %T. expect *ent.AnnouncementReadQuery", q) +} + // The GroupFunc type is an adapter to allow the use of ordinary function as a Querier. type GroupFunc func(context.Context, *ent.GroupQuery) (ent.Value, error) @@ -524,6 +580,10 @@ func NewQuery(q ent.Query) (Query, error) { return &query[*ent.AccountQuery, predicate.Account, account.OrderOption]{typ: ent.TypeAccount, tq: q}, nil case *ent.AccountGroupQuery: return &query[*ent.AccountGroupQuery, predicate.AccountGroup, accountgroup.OrderOption]{typ: ent.TypeAccountGroup, tq: q}, nil + case *ent.AnnouncementQuery: + return &query[*ent.AnnouncementQuery, predicate.Announcement, announcement.OrderOption]{typ: ent.TypeAnnouncement, tq: q}, nil + case *ent.AnnouncementReadQuery: + return &query[*ent.AnnouncementReadQuery, predicate.AnnouncementRead, announcementread.OrderOption]{typ: ent.TypeAnnouncementRead, tq: q}, nil case *ent.GroupQuery: return &query[*ent.GroupQuery, predicate.Group, group.OrderOption]{typ: ent.TypeGroup, tq: q}, nil case *ent.PromoCodeQuery: diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index fe1f80a8..8df0cdb3 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -204,6 +204,98 @@ var ( }, }, } + // AnnouncementsColumns holds the columns for the "announcements" table. + AnnouncementsColumns = []*schema.Column{ + {Name: "id", Type: field.TypeInt64, Increment: true}, + {Name: "title", Type: field.TypeString, Size: 200}, + {Name: "content", Type: field.TypeString, SchemaType: map[string]string{"postgres": "text"}}, + {Name: "status", Type: field.TypeString, Size: 20, Default: "draft"}, + {Name: "targeting", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}}, + {Name: "starts_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, + {Name: "ends_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, + {Name: "created_by", Type: field.TypeInt64, Nullable: true}, + {Name: "updated_by", Type: field.TypeInt64, Nullable: true}, + {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, + {Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, + } + // AnnouncementsTable holds the schema information for the "announcements" table. + AnnouncementsTable = &schema.Table{ + Name: "announcements", + Columns: AnnouncementsColumns, + PrimaryKey: []*schema.Column{AnnouncementsColumns[0]}, + Indexes: []*schema.Index{ + { + Name: "announcement_status", + Unique: false, + Columns: []*schema.Column{AnnouncementsColumns[3]}, + }, + { + Name: "announcement_created_at", + Unique: false, + Columns: []*schema.Column{AnnouncementsColumns[9]}, + }, + { + Name: "announcement_starts_at", + Unique: false, + Columns: []*schema.Column{AnnouncementsColumns[5]}, + }, + { + Name: "announcement_ends_at", + Unique: false, + Columns: []*schema.Column{AnnouncementsColumns[6]}, + }, + }, + } + // AnnouncementReadsColumns holds the columns for the "announcement_reads" table. + AnnouncementReadsColumns = []*schema.Column{ + {Name: "id", Type: field.TypeInt64, Increment: true}, + {Name: "read_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, + {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, + {Name: "announcement_id", Type: field.TypeInt64}, + {Name: "user_id", Type: field.TypeInt64}, + } + // AnnouncementReadsTable holds the schema information for the "announcement_reads" table. + AnnouncementReadsTable = &schema.Table{ + Name: "announcement_reads", + Columns: AnnouncementReadsColumns, + PrimaryKey: []*schema.Column{AnnouncementReadsColumns[0]}, + ForeignKeys: []*schema.ForeignKey{ + { + Symbol: "announcement_reads_announcements_reads", + Columns: []*schema.Column{AnnouncementReadsColumns[3]}, + RefColumns: []*schema.Column{AnnouncementsColumns[0]}, + OnDelete: schema.NoAction, + }, + { + Symbol: "announcement_reads_users_announcement_reads", + Columns: []*schema.Column{AnnouncementReadsColumns[4]}, + RefColumns: []*schema.Column{UsersColumns[0]}, + OnDelete: schema.NoAction, + }, + }, + Indexes: []*schema.Index{ + { + Name: "announcementread_announcement_id", + Unique: false, + Columns: []*schema.Column{AnnouncementReadsColumns[3]}, + }, + { + Name: "announcementread_user_id", + Unique: false, + Columns: []*schema.Column{AnnouncementReadsColumns[4]}, + }, + { + Name: "announcementread_read_at", + Unique: false, + Columns: []*schema.Column{AnnouncementReadsColumns[1]}, + }, + { + Name: "announcementread_announcement_id_user_id", + Unique: true, + Columns: []*schema.Column{AnnouncementReadsColumns[3], AnnouncementReadsColumns[4]}, + }, + }, + } // GroupsColumns holds the columns for the "groups" table. GroupsColumns = []*schema.Column{ {Name: "id", Type: field.TypeInt64, Increment: true}, @@ -615,6 +707,9 @@ var ( {Name: "status", Type: field.TypeString, Size: 20, Default: "active"}, {Name: "username", Type: field.TypeString, Size: 100, Default: ""}, {Name: "notes", Type: field.TypeString, Default: "", SchemaType: map[string]string{"postgres": "text"}}, + {Name: "totp_secret_encrypted", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}}, + {Name: "totp_enabled", Type: field.TypeBool, Default: false}, + {Name: "totp_enabled_at", Type: field.TypeTime, Nullable: true}, } // UsersTable holds the schema information for the "users" table. UsersTable = &schema.Table{ @@ -842,6 +937,8 @@ var ( APIKeysTable, AccountsTable, AccountGroupsTable, + AnnouncementsTable, + AnnouncementReadsTable, GroupsTable, PromoCodesTable, PromoCodeUsagesTable, @@ -873,6 +970,14 @@ func init() { AccountGroupsTable.Annotation = &entsql.Annotation{ Table: "account_groups", } + AnnouncementsTable.Annotation = &entsql.Annotation{ + Table: "announcements", + } + AnnouncementReadsTable.ForeignKeys[0].RefTable = AnnouncementsTable + AnnouncementReadsTable.ForeignKeys[1].RefTable = UsersTable + AnnouncementReadsTable.Annotation = &entsql.Annotation{ + Table: "announcement_reads", + } GroupsTable.Annotation = &entsql.Annotation{ Table: "groups", } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index b3d1e410..f12ccb4f 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -14,6 +14,8 @@ import ( "entgo.io/ent/dialect/sql" "github.com/Wei-Shaw/sub2api/ent/account" "github.com/Wei-Shaw/sub2api/ent/accountgroup" + "github.com/Wei-Shaw/sub2api/ent/announcement" + "github.com/Wei-Shaw/sub2api/ent/announcementread" "github.com/Wei-Shaw/sub2api/ent/apikey" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/predicate" @@ -29,6 +31,7 @@ import ( "github.com/Wei-Shaw/sub2api/ent/userattributedefinition" "github.com/Wei-Shaw/sub2api/ent/userattributevalue" "github.com/Wei-Shaw/sub2api/ent/usersubscription" + "github.com/Wei-Shaw/sub2api/internal/domain" ) const ( @@ -43,6 +46,8 @@ const ( TypeAPIKey = "APIKey" TypeAccount = "Account" TypeAccountGroup = "AccountGroup" + TypeAnnouncement = "Announcement" + TypeAnnouncementRead = "AnnouncementRead" TypeGroup = "Group" TypePromoCode = "PromoCode" TypePromoCodeUsage = "PromoCodeUsage" @@ -3833,6 +3838,1671 @@ func (m *AccountGroupMutation) ResetEdge(name string) error { return fmt.Errorf("unknown AccountGroup edge %s", name) } +// AnnouncementMutation represents an operation that mutates the Announcement nodes in the graph. +type AnnouncementMutation struct { + config + op Op + typ string + id *int64 + title *string + content *string + status *string + targeting *domain.AnnouncementTargeting + starts_at *time.Time + ends_at *time.Time + created_by *int64 + addcreated_by *int64 + updated_by *int64 + addupdated_by *int64 + created_at *time.Time + updated_at *time.Time + clearedFields map[string]struct{} + reads map[int64]struct{} + removedreads map[int64]struct{} + clearedreads bool + done bool + oldValue func(context.Context) (*Announcement, error) + predicates []predicate.Announcement +} + +var _ ent.Mutation = (*AnnouncementMutation)(nil) + +// announcementOption allows management of the mutation configuration using functional options. +type announcementOption func(*AnnouncementMutation) + +// newAnnouncementMutation creates new mutation for the Announcement entity. +func newAnnouncementMutation(c config, op Op, opts ...announcementOption) *AnnouncementMutation { + m := &AnnouncementMutation{ + config: c, + op: op, + typ: TypeAnnouncement, + clearedFields: make(map[string]struct{}), + } + for _, opt := range opts { + opt(m) + } + return m +} + +// withAnnouncementID sets the ID field of the mutation. +func withAnnouncementID(id int64) announcementOption { + return func(m *AnnouncementMutation) { + var ( + err error + once sync.Once + value *Announcement + ) + m.oldValue = func(ctx context.Context) (*Announcement, error) { + once.Do(func() { + if m.done { + err = errors.New("querying old values post mutation is not allowed") + } else { + value, err = m.Client().Announcement.Get(ctx, id) + } + }) + return value, err + } + m.id = &id + } +} + +// withAnnouncement sets the old Announcement of the mutation. +func withAnnouncement(node *Announcement) announcementOption { + return func(m *AnnouncementMutation) { + m.oldValue = func(context.Context) (*Announcement, error) { + return node, nil + } + m.id = &node.ID + } +} + +// Client returns a new `ent.Client` from the mutation. If the mutation was +// executed in a transaction (ent.Tx), a transactional client is returned. +func (m AnnouncementMutation) Client() *Client { + client := &Client{config: m.config} + client.init() + return client +} + +// Tx returns an `ent.Tx` for mutations that were executed in transactions; +// it returns an error otherwise. +func (m AnnouncementMutation) Tx() (*Tx, error) { + if _, ok := m.driver.(*txDriver); !ok { + return nil, errors.New("ent: mutation is not running in a transaction") + } + tx := &Tx{config: m.config} + tx.init() + return tx, nil +} + +// ID returns the ID value in the mutation. Note that the ID is only available +// if it was provided to the builder or after it was returned from the database. +func (m *AnnouncementMutation) ID() (id int64, exists bool) { + if m.id == nil { + return + } + return *m.id, true +} + +// IDs queries the database and returns the entity ids that match the mutation's predicate. +// That means, if the mutation is applied within a transaction with an isolation level such +// as sql.LevelSerializable, the returned ids match the ids of the rows that will be updated +// or updated by the mutation. +func (m *AnnouncementMutation) IDs(ctx context.Context) ([]int64, error) { + switch { + case m.op.Is(OpUpdateOne | OpDeleteOne): + id, exists := m.ID() + if exists { + return []int64{id}, nil + } + fallthrough + case m.op.Is(OpUpdate | OpDelete): + return m.Client().Announcement.Query().Where(m.predicates...).IDs(ctx) + default: + return nil, fmt.Errorf("IDs is not allowed on %s operations", m.op) + } +} + +// SetTitle sets the "title" field. +func (m *AnnouncementMutation) SetTitle(s string) { + m.title = &s +} + +// Title returns the value of the "title" field in the mutation. +func (m *AnnouncementMutation) Title() (r string, exists bool) { + v := m.title + if v == nil { + return + } + return *v, true +} + +// OldTitle returns the old "title" field's value of the Announcement entity. +// If the Announcement object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AnnouncementMutation) OldTitle(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldTitle is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldTitle requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldTitle: %w", err) + } + return oldValue.Title, nil +} + +// ResetTitle resets all changes to the "title" field. +func (m *AnnouncementMutation) ResetTitle() { + m.title = nil +} + +// SetContent sets the "content" field. +func (m *AnnouncementMutation) SetContent(s string) { + m.content = &s +} + +// Content returns the value of the "content" field in the mutation. +func (m *AnnouncementMutation) Content() (r string, exists bool) { + v := m.content + if v == nil { + return + } + return *v, true +} + +// OldContent returns the old "content" field's value of the Announcement entity. +// If the Announcement object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AnnouncementMutation) OldContent(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldContent is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldContent requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldContent: %w", err) + } + return oldValue.Content, nil +} + +// ResetContent resets all changes to the "content" field. +func (m *AnnouncementMutation) ResetContent() { + m.content = nil +} + +// SetStatus sets the "status" field. +func (m *AnnouncementMutation) SetStatus(s string) { + m.status = &s +} + +// Status returns the value of the "status" field in the mutation. +func (m *AnnouncementMutation) Status() (r string, exists bool) { + v := m.status + if v == nil { + return + } + return *v, true +} + +// OldStatus returns the old "status" field's value of the Announcement entity. +// If the Announcement object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AnnouncementMutation) OldStatus(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldStatus is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldStatus requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldStatus: %w", err) + } + return oldValue.Status, nil +} + +// ResetStatus resets all changes to the "status" field. +func (m *AnnouncementMutation) ResetStatus() { + m.status = nil +} + +// SetTargeting sets the "targeting" field. +func (m *AnnouncementMutation) SetTargeting(dt domain.AnnouncementTargeting) { + m.targeting = &dt +} + +// Targeting returns the value of the "targeting" field in the mutation. +func (m *AnnouncementMutation) Targeting() (r domain.AnnouncementTargeting, exists bool) { + v := m.targeting + if v == nil { + return + } + return *v, true +} + +// OldTargeting returns the old "targeting" field's value of the Announcement entity. +// If the Announcement object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AnnouncementMutation) OldTargeting(ctx context.Context) (v domain.AnnouncementTargeting, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldTargeting is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldTargeting requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldTargeting: %w", err) + } + return oldValue.Targeting, nil +} + +// ClearTargeting clears the value of the "targeting" field. +func (m *AnnouncementMutation) ClearTargeting() { + m.targeting = nil + m.clearedFields[announcement.FieldTargeting] = struct{}{} +} + +// TargetingCleared returns if the "targeting" field was cleared in this mutation. +func (m *AnnouncementMutation) TargetingCleared() bool { + _, ok := m.clearedFields[announcement.FieldTargeting] + return ok +} + +// ResetTargeting resets all changes to the "targeting" field. +func (m *AnnouncementMutation) ResetTargeting() { + m.targeting = nil + delete(m.clearedFields, announcement.FieldTargeting) +} + +// SetStartsAt sets the "starts_at" field. +func (m *AnnouncementMutation) SetStartsAt(t time.Time) { + m.starts_at = &t +} + +// StartsAt returns the value of the "starts_at" field in the mutation. +func (m *AnnouncementMutation) StartsAt() (r time.Time, exists bool) { + v := m.starts_at + if v == nil { + return + } + return *v, true +} + +// OldStartsAt returns the old "starts_at" field's value of the Announcement entity. +// If the Announcement object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AnnouncementMutation) OldStartsAt(ctx context.Context) (v *time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldStartsAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldStartsAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldStartsAt: %w", err) + } + return oldValue.StartsAt, nil +} + +// ClearStartsAt clears the value of the "starts_at" field. +func (m *AnnouncementMutation) ClearStartsAt() { + m.starts_at = nil + m.clearedFields[announcement.FieldStartsAt] = struct{}{} +} + +// StartsAtCleared returns if the "starts_at" field was cleared in this mutation. +func (m *AnnouncementMutation) StartsAtCleared() bool { + _, ok := m.clearedFields[announcement.FieldStartsAt] + return ok +} + +// ResetStartsAt resets all changes to the "starts_at" field. +func (m *AnnouncementMutation) ResetStartsAt() { + m.starts_at = nil + delete(m.clearedFields, announcement.FieldStartsAt) +} + +// SetEndsAt sets the "ends_at" field. +func (m *AnnouncementMutation) SetEndsAt(t time.Time) { + m.ends_at = &t +} + +// EndsAt returns the value of the "ends_at" field in the mutation. +func (m *AnnouncementMutation) EndsAt() (r time.Time, exists bool) { + v := m.ends_at + if v == nil { + return + } + return *v, true +} + +// OldEndsAt returns the old "ends_at" field's value of the Announcement entity. +// If the Announcement object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AnnouncementMutation) OldEndsAt(ctx context.Context) (v *time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldEndsAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldEndsAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldEndsAt: %w", err) + } + return oldValue.EndsAt, nil +} + +// ClearEndsAt clears the value of the "ends_at" field. +func (m *AnnouncementMutation) ClearEndsAt() { + m.ends_at = nil + m.clearedFields[announcement.FieldEndsAt] = struct{}{} +} + +// EndsAtCleared returns if the "ends_at" field was cleared in this mutation. +func (m *AnnouncementMutation) EndsAtCleared() bool { + _, ok := m.clearedFields[announcement.FieldEndsAt] + return ok +} + +// ResetEndsAt resets all changes to the "ends_at" field. +func (m *AnnouncementMutation) ResetEndsAt() { + m.ends_at = nil + delete(m.clearedFields, announcement.FieldEndsAt) +} + +// SetCreatedBy sets the "created_by" field. +func (m *AnnouncementMutation) SetCreatedBy(i int64) { + m.created_by = &i + m.addcreated_by = nil +} + +// CreatedBy returns the value of the "created_by" field in the mutation. +func (m *AnnouncementMutation) CreatedBy() (r int64, exists bool) { + v := m.created_by + if v == nil { + return + } + return *v, true +} + +// OldCreatedBy returns the old "created_by" field's value of the Announcement entity. +// If the Announcement object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AnnouncementMutation) OldCreatedBy(ctx context.Context) (v *int64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldCreatedBy is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldCreatedBy requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldCreatedBy: %w", err) + } + return oldValue.CreatedBy, nil +} + +// AddCreatedBy adds i to the "created_by" field. +func (m *AnnouncementMutation) AddCreatedBy(i int64) { + if m.addcreated_by != nil { + *m.addcreated_by += i + } else { + m.addcreated_by = &i + } +} + +// AddedCreatedBy returns the value that was added to the "created_by" field in this mutation. +func (m *AnnouncementMutation) AddedCreatedBy() (r int64, exists bool) { + v := m.addcreated_by + if v == nil { + return + } + return *v, true +} + +// ClearCreatedBy clears the value of the "created_by" field. +func (m *AnnouncementMutation) ClearCreatedBy() { + m.created_by = nil + m.addcreated_by = nil + m.clearedFields[announcement.FieldCreatedBy] = struct{}{} +} + +// CreatedByCleared returns if the "created_by" field was cleared in this mutation. +func (m *AnnouncementMutation) CreatedByCleared() bool { + _, ok := m.clearedFields[announcement.FieldCreatedBy] + return ok +} + +// ResetCreatedBy resets all changes to the "created_by" field. +func (m *AnnouncementMutation) ResetCreatedBy() { + m.created_by = nil + m.addcreated_by = nil + delete(m.clearedFields, announcement.FieldCreatedBy) +} + +// SetUpdatedBy sets the "updated_by" field. +func (m *AnnouncementMutation) SetUpdatedBy(i int64) { + m.updated_by = &i + m.addupdated_by = nil +} + +// UpdatedBy returns the value of the "updated_by" field in the mutation. +func (m *AnnouncementMutation) UpdatedBy() (r int64, exists bool) { + v := m.updated_by + if v == nil { + return + } + return *v, true +} + +// OldUpdatedBy returns the old "updated_by" field's value of the Announcement entity. +// If the Announcement object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AnnouncementMutation) OldUpdatedBy(ctx context.Context) (v *int64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUpdatedBy is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUpdatedBy requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUpdatedBy: %w", err) + } + return oldValue.UpdatedBy, nil +} + +// AddUpdatedBy adds i to the "updated_by" field. +func (m *AnnouncementMutation) AddUpdatedBy(i int64) { + if m.addupdated_by != nil { + *m.addupdated_by += i + } else { + m.addupdated_by = &i + } +} + +// AddedUpdatedBy returns the value that was added to the "updated_by" field in this mutation. +func (m *AnnouncementMutation) AddedUpdatedBy() (r int64, exists bool) { + v := m.addupdated_by + if v == nil { + return + } + return *v, true +} + +// ClearUpdatedBy clears the value of the "updated_by" field. +func (m *AnnouncementMutation) ClearUpdatedBy() { + m.updated_by = nil + m.addupdated_by = nil + m.clearedFields[announcement.FieldUpdatedBy] = struct{}{} +} + +// UpdatedByCleared returns if the "updated_by" field was cleared in this mutation. +func (m *AnnouncementMutation) UpdatedByCleared() bool { + _, ok := m.clearedFields[announcement.FieldUpdatedBy] + return ok +} + +// ResetUpdatedBy resets all changes to the "updated_by" field. +func (m *AnnouncementMutation) ResetUpdatedBy() { + m.updated_by = nil + m.addupdated_by = nil + delete(m.clearedFields, announcement.FieldUpdatedBy) +} + +// SetCreatedAt sets the "created_at" field. +func (m *AnnouncementMutation) SetCreatedAt(t time.Time) { + m.created_at = &t +} + +// CreatedAt returns the value of the "created_at" field in the mutation. +func (m *AnnouncementMutation) CreatedAt() (r time.Time, exists bool) { + v := m.created_at + if v == nil { + return + } + return *v, true +} + +// OldCreatedAt returns the old "created_at" field's value of the Announcement entity. +// If the Announcement object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AnnouncementMutation) OldCreatedAt(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldCreatedAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldCreatedAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldCreatedAt: %w", err) + } + return oldValue.CreatedAt, nil +} + +// ResetCreatedAt resets all changes to the "created_at" field. +func (m *AnnouncementMutation) ResetCreatedAt() { + m.created_at = nil +} + +// SetUpdatedAt sets the "updated_at" field. +func (m *AnnouncementMutation) SetUpdatedAt(t time.Time) { + m.updated_at = &t +} + +// UpdatedAt returns the value of the "updated_at" field in the mutation. +func (m *AnnouncementMutation) UpdatedAt() (r time.Time, exists bool) { + v := m.updated_at + if v == nil { + return + } + return *v, true +} + +// OldUpdatedAt returns the old "updated_at" field's value of the Announcement entity. +// If the Announcement object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AnnouncementMutation) OldUpdatedAt(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUpdatedAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUpdatedAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUpdatedAt: %w", err) + } + return oldValue.UpdatedAt, nil +} + +// ResetUpdatedAt resets all changes to the "updated_at" field. +func (m *AnnouncementMutation) ResetUpdatedAt() { + m.updated_at = nil +} + +// AddReadIDs adds the "reads" edge to the AnnouncementRead entity by ids. +func (m *AnnouncementMutation) AddReadIDs(ids ...int64) { + if m.reads == nil { + m.reads = make(map[int64]struct{}) + } + for i := range ids { + m.reads[ids[i]] = struct{}{} + } +} + +// ClearReads clears the "reads" edge to the AnnouncementRead entity. +func (m *AnnouncementMutation) ClearReads() { + m.clearedreads = true +} + +// ReadsCleared reports if the "reads" edge to the AnnouncementRead entity was cleared. +func (m *AnnouncementMutation) ReadsCleared() bool { + return m.clearedreads +} + +// RemoveReadIDs removes the "reads" edge to the AnnouncementRead entity by IDs. +func (m *AnnouncementMutation) RemoveReadIDs(ids ...int64) { + if m.removedreads == nil { + m.removedreads = make(map[int64]struct{}) + } + for i := range ids { + delete(m.reads, ids[i]) + m.removedreads[ids[i]] = struct{}{} + } +} + +// RemovedReads returns the removed IDs of the "reads" edge to the AnnouncementRead entity. +func (m *AnnouncementMutation) RemovedReadsIDs() (ids []int64) { + for id := range m.removedreads { + ids = append(ids, id) + } + return +} + +// ReadsIDs returns the "reads" edge IDs in the mutation. +func (m *AnnouncementMutation) ReadsIDs() (ids []int64) { + for id := range m.reads { + ids = append(ids, id) + } + return +} + +// ResetReads resets all changes to the "reads" edge. +func (m *AnnouncementMutation) ResetReads() { + m.reads = nil + m.clearedreads = false + m.removedreads = nil +} + +// Where appends a list predicates to the AnnouncementMutation builder. +func (m *AnnouncementMutation) Where(ps ...predicate.Announcement) { + m.predicates = append(m.predicates, ps...) +} + +// WhereP appends storage-level predicates to the AnnouncementMutation builder. Using this method, +// users can use type-assertion to append predicates that do not depend on any generated package. +func (m *AnnouncementMutation) WhereP(ps ...func(*sql.Selector)) { + p := make([]predicate.Announcement, len(ps)) + for i := range ps { + p[i] = ps[i] + } + m.Where(p...) +} + +// Op returns the operation name. +func (m *AnnouncementMutation) Op() Op { + return m.op +} + +// SetOp allows setting the mutation operation. +func (m *AnnouncementMutation) SetOp(op Op) { + m.op = op +} + +// Type returns the node type of this mutation (Announcement). +func (m *AnnouncementMutation) Type() string { + return m.typ +} + +// Fields returns all fields that were changed during this mutation. Note that in +// order to get all numeric fields that were incremented/decremented, call +// AddedFields(). +func (m *AnnouncementMutation) Fields() []string { + fields := make([]string, 0, 10) + if m.title != nil { + fields = append(fields, announcement.FieldTitle) + } + if m.content != nil { + fields = append(fields, announcement.FieldContent) + } + if m.status != nil { + fields = append(fields, announcement.FieldStatus) + } + if m.targeting != nil { + fields = append(fields, announcement.FieldTargeting) + } + if m.starts_at != nil { + fields = append(fields, announcement.FieldStartsAt) + } + if m.ends_at != nil { + fields = append(fields, announcement.FieldEndsAt) + } + if m.created_by != nil { + fields = append(fields, announcement.FieldCreatedBy) + } + if m.updated_by != nil { + fields = append(fields, announcement.FieldUpdatedBy) + } + if m.created_at != nil { + fields = append(fields, announcement.FieldCreatedAt) + } + if m.updated_at != nil { + fields = append(fields, announcement.FieldUpdatedAt) + } + return fields +} + +// Field returns the value of a field with the given name. The second boolean +// return value indicates that this field was not set, or was not defined in the +// schema. +func (m *AnnouncementMutation) Field(name string) (ent.Value, bool) { + switch name { + case announcement.FieldTitle: + return m.Title() + case announcement.FieldContent: + return m.Content() + case announcement.FieldStatus: + return m.Status() + case announcement.FieldTargeting: + return m.Targeting() + case announcement.FieldStartsAt: + return m.StartsAt() + case announcement.FieldEndsAt: + return m.EndsAt() + case announcement.FieldCreatedBy: + return m.CreatedBy() + case announcement.FieldUpdatedBy: + return m.UpdatedBy() + case announcement.FieldCreatedAt: + return m.CreatedAt() + case announcement.FieldUpdatedAt: + return m.UpdatedAt() + } + return nil, false +} + +// OldField returns the old value of the field from the database. An error is +// returned if the mutation operation is not UpdateOne, or the query to the +// database failed. +func (m *AnnouncementMutation) OldField(ctx context.Context, name string) (ent.Value, error) { + switch name { + case announcement.FieldTitle: + return m.OldTitle(ctx) + case announcement.FieldContent: + return m.OldContent(ctx) + case announcement.FieldStatus: + return m.OldStatus(ctx) + case announcement.FieldTargeting: + return m.OldTargeting(ctx) + case announcement.FieldStartsAt: + return m.OldStartsAt(ctx) + case announcement.FieldEndsAt: + return m.OldEndsAt(ctx) + case announcement.FieldCreatedBy: + return m.OldCreatedBy(ctx) + case announcement.FieldUpdatedBy: + return m.OldUpdatedBy(ctx) + case announcement.FieldCreatedAt: + return m.OldCreatedAt(ctx) + case announcement.FieldUpdatedAt: + return m.OldUpdatedAt(ctx) + } + return nil, fmt.Errorf("unknown Announcement field %s", name) +} + +// SetField sets the value of a field with the given name. It returns an error if +// the field is not defined in the schema, or if the type mismatched the field +// type. +func (m *AnnouncementMutation) SetField(name string, value ent.Value) error { + switch name { + case announcement.FieldTitle: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetTitle(v) + return nil + case announcement.FieldContent: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetContent(v) + return nil + case announcement.FieldStatus: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetStatus(v) + return nil + case announcement.FieldTargeting: + v, ok := value.(domain.AnnouncementTargeting) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetTargeting(v) + return nil + case announcement.FieldStartsAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetStartsAt(v) + return nil + case announcement.FieldEndsAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetEndsAt(v) + return nil + case announcement.FieldCreatedBy: + v, ok := value.(int64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetCreatedBy(v) + return nil + case announcement.FieldUpdatedBy: + v, ok := value.(int64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUpdatedBy(v) + return nil + case announcement.FieldCreatedAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetCreatedAt(v) + return nil + case announcement.FieldUpdatedAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUpdatedAt(v) + return nil + } + return fmt.Errorf("unknown Announcement field %s", name) +} + +// AddedFields returns all numeric fields that were incremented/decremented during +// this mutation. +func (m *AnnouncementMutation) AddedFields() []string { + var fields []string + if m.addcreated_by != nil { + fields = append(fields, announcement.FieldCreatedBy) + } + if m.addupdated_by != nil { + fields = append(fields, announcement.FieldUpdatedBy) + } + return fields +} + +// AddedField returns the numeric value that was incremented/decremented on a field +// with the given name. The second boolean return value indicates that this field +// was not set, or was not defined in the schema. +func (m *AnnouncementMutation) AddedField(name string) (ent.Value, bool) { + switch name { + case announcement.FieldCreatedBy: + return m.AddedCreatedBy() + case announcement.FieldUpdatedBy: + return m.AddedUpdatedBy() + } + return nil, false +} + +// AddField adds the value to the field with the given name. It returns an error if +// the field is not defined in the schema, or if the type mismatched the field +// type. +func (m *AnnouncementMutation) AddField(name string, value ent.Value) error { + switch name { + case announcement.FieldCreatedBy: + v, ok := value.(int64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddCreatedBy(v) + return nil + case announcement.FieldUpdatedBy: + v, ok := value.(int64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddUpdatedBy(v) + return nil + } + return fmt.Errorf("unknown Announcement numeric field %s", name) +} + +// ClearedFields returns all nullable fields that were cleared during this +// mutation. +func (m *AnnouncementMutation) ClearedFields() []string { + var fields []string + if m.FieldCleared(announcement.FieldTargeting) { + fields = append(fields, announcement.FieldTargeting) + } + if m.FieldCleared(announcement.FieldStartsAt) { + fields = append(fields, announcement.FieldStartsAt) + } + if m.FieldCleared(announcement.FieldEndsAt) { + fields = append(fields, announcement.FieldEndsAt) + } + if m.FieldCleared(announcement.FieldCreatedBy) { + fields = append(fields, announcement.FieldCreatedBy) + } + if m.FieldCleared(announcement.FieldUpdatedBy) { + fields = append(fields, announcement.FieldUpdatedBy) + } + return fields +} + +// FieldCleared returns a boolean indicating if a field with the given name was +// cleared in this mutation. +func (m *AnnouncementMutation) FieldCleared(name string) bool { + _, ok := m.clearedFields[name] + return ok +} + +// ClearField clears the value of the field with the given name. It returns an +// error if the field is not defined in the schema. +func (m *AnnouncementMutation) ClearField(name string) error { + switch name { + case announcement.FieldTargeting: + m.ClearTargeting() + return nil + case announcement.FieldStartsAt: + m.ClearStartsAt() + return nil + case announcement.FieldEndsAt: + m.ClearEndsAt() + return nil + case announcement.FieldCreatedBy: + m.ClearCreatedBy() + return nil + case announcement.FieldUpdatedBy: + m.ClearUpdatedBy() + return nil + } + return fmt.Errorf("unknown Announcement nullable field %s", name) +} + +// ResetField resets all changes in the mutation for the field with the given name. +// It returns an error if the field is not defined in the schema. +func (m *AnnouncementMutation) ResetField(name string) error { + switch name { + case announcement.FieldTitle: + m.ResetTitle() + return nil + case announcement.FieldContent: + m.ResetContent() + return nil + case announcement.FieldStatus: + m.ResetStatus() + return nil + case announcement.FieldTargeting: + m.ResetTargeting() + return nil + case announcement.FieldStartsAt: + m.ResetStartsAt() + return nil + case announcement.FieldEndsAt: + m.ResetEndsAt() + return nil + case announcement.FieldCreatedBy: + m.ResetCreatedBy() + return nil + case announcement.FieldUpdatedBy: + m.ResetUpdatedBy() + return nil + case announcement.FieldCreatedAt: + m.ResetCreatedAt() + return nil + case announcement.FieldUpdatedAt: + m.ResetUpdatedAt() + return nil + } + return fmt.Errorf("unknown Announcement field %s", name) +} + +// AddedEdges returns all edge names that were set/added in this mutation. +func (m *AnnouncementMutation) AddedEdges() []string { + edges := make([]string, 0, 1) + if m.reads != nil { + edges = append(edges, announcement.EdgeReads) + } + return edges +} + +// AddedIDs returns all IDs (to other nodes) that were added for the given edge +// name in this mutation. +func (m *AnnouncementMutation) AddedIDs(name string) []ent.Value { + switch name { + case announcement.EdgeReads: + ids := make([]ent.Value, 0, len(m.reads)) + for id := range m.reads { + ids = append(ids, id) + } + return ids + } + return nil +} + +// RemovedEdges returns all edge names that were removed in this mutation. +func (m *AnnouncementMutation) RemovedEdges() []string { + edges := make([]string, 0, 1) + if m.removedreads != nil { + edges = append(edges, announcement.EdgeReads) + } + return edges +} + +// RemovedIDs returns all IDs (to other nodes) that were removed for the edge with +// the given name in this mutation. +func (m *AnnouncementMutation) RemovedIDs(name string) []ent.Value { + switch name { + case announcement.EdgeReads: + ids := make([]ent.Value, 0, len(m.removedreads)) + for id := range m.removedreads { + ids = append(ids, id) + } + return ids + } + return nil +} + +// ClearedEdges returns all edge names that were cleared in this mutation. +func (m *AnnouncementMutation) ClearedEdges() []string { + edges := make([]string, 0, 1) + if m.clearedreads { + edges = append(edges, announcement.EdgeReads) + } + return edges +} + +// EdgeCleared returns a boolean which indicates if the edge with the given name +// was cleared in this mutation. +func (m *AnnouncementMutation) EdgeCleared(name string) bool { + switch name { + case announcement.EdgeReads: + return m.clearedreads + } + return false +} + +// ClearEdge clears the value of the edge with the given name. It returns an error +// if that edge is not defined in the schema. +func (m *AnnouncementMutation) ClearEdge(name string) error { + switch name { + } + return fmt.Errorf("unknown Announcement unique edge %s", name) +} + +// ResetEdge resets all changes to the edge with the given name in this mutation. +// It returns an error if the edge is not defined in the schema. +func (m *AnnouncementMutation) ResetEdge(name string) error { + switch name { + case announcement.EdgeReads: + m.ResetReads() + return nil + } + return fmt.Errorf("unknown Announcement edge %s", name) +} + +// AnnouncementReadMutation represents an operation that mutates the AnnouncementRead nodes in the graph. +type AnnouncementReadMutation struct { + config + op Op + typ string + id *int64 + read_at *time.Time + created_at *time.Time + clearedFields map[string]struct{} + announcement *int64 + clearedannouncement bool + user *int64 + cleareduser bool + done bool + oldValue func(context.Context) (*AnnouncementRead, error) + predicates []predicate.AnnouncementRead +} + +var _ ent.Mutation = (*AnnouncementReadMutation)(nil) + +// announcementreadOption allows management of the mutation configuration using functional options. +type announcementreadOption func(*AnnouncementReadMutation) + +// newAnnouncementReadMutation creates new mutation for the AnnouncementRead entity. +func newAnnouncementReadMutation(c config, op Op, opts ...announcementreadOption) *AnnouncementReadMutation { + m := &AnnouncementReadMutation{ + config: c, + op: op, + typ: TypeAnnouncementRead, + clearedFields: make(map[string]struct{}), + } + for _, opt := range opts { + opt(m) + } + return m +} + +// withAnnouncementReadID sets the ID field of the mutation. +func withAnnouncementReadID(id int64) announcementreadOption { + return func(m *AnnouncementReadMutation) { + var ( + err error + once sync.Once + value *AnnouncementRead + ) + m.oldValue = func(ctx context.Context) (*AnnouncementRead, error) { + once.Do(func() { + if m.done { + err = errors.New("querying old values post mutation is not allowed") + } else { + value, err = m.Client().AnnouncementRead.Get(ctx, id) + } + }) + return value, err + } + m.id = &id + } +} + +// withAnnouncementRead sets the old AnnouncementRead of the mutation. +func withAnnouncementRead(node *AnnouncementRead) announcementreadOption { + return func(m *AnnouncementReadMutation) { + m.oldValue = func(context.Context) (*AnnouncementRead, error) { + return node, nil + } + m.id = &node.ID + } +} + +// Client returns a new `ent.Client` from the mutation. If the mutation was +// executed in a transaction (ent.Tx), a transactional client is returned. +func (m AnnouncementReadMutation) Client() *Client { + client := &Client{config: m.config} + client.init() + return client +} + +// Tx returns an `ent.Tx` for mutations that were executed in transactions; +// it returns an error otherwise. +func (m AnnouncementReadMutation) Tx() (*Tx, error) { + if _, ok := m.driver.(*txDriver); !ok { + return nil, errors.New("ent: mutation is not running in a transaction") + } + tx := &Tx{config: m.config} + tx.init() + return tx, nil +} + +// ID returns the ID value in the mutation. Note that the ID is only available +// if it was provided to the builder or after it was returned from the database. +func (m *AnnouncementReadMutation) ID() (id int64, exists bool) { + if m.id == nil { + return + } + return *m.id, true +} + +// IDs queries the database and returns the entity ids that match the mutation's predicate. +// That means, if the mutation is applied within a transaction with an isolation level such +// as sql.LevelSerializable, the returned ids match the ids of the rows that will be updated +// or updated by the mutation. +func (m *AnnouncementReadMutation) IDs(ctx context.Context) ([]int64, error) { + switch { + case m.op.Is(OpUpdateOne | OpDeleteOne): + id, exists := m.ID() + if exists { + return []int64{id}, nil + } + fallthrough + case m.op.Is(OpUpdate | OpDelete): + return m.Client().AnnouncementRead.Query().Where(m.predicates...).IDs(ctx) + default: + return nil, fmt.Errorf("IDs is not allowed on %s operations", m.op) + } +} + +// SetAnnouncementID sets the "announcement_id" field. +func (m *AnnouncementReadMutation) SetAnnouncementID(i int64) { + m.announcement = &i +} + +// AnnouncementID returns the value of the "announcement_id" field in the mutation. +func (m *AnnouncementReadMutation) AnnouncementID() (r int64, exists bool) { + v := m.announcement + if v == nil { + return + } + return *v, true +} + +// OldAnnouncementID returns the old "announcement_id" field's value of the AnnouncementRead entity. +// If the AnnouncementRead object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AnnouncementReadMutation) OldAnnouncementID(ctx context.Context) (v int64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldAnnouncementID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldAnnouncementID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldAnnouncementID: %w", err) + } + return oldValue.AnnouncementID, nil +} + +// ResetAnnouncementID resets all changes to the "announcement_id" field. +func (m *AnnouncementReadMutation) ResetAnnouncementID() { + m.announcement = nil +} + +// SetUserID sets the "user_id" field. +func (m *AnnouncementReadMutation) SetUserID(i int64) { + m.user = &i +} + +// UserID returns the value of the "user_id" field in the mutation. +func (m *AnnouncementReadMutation) UserID() (r int64, exists bool) { + v := m.user + if v == nil { + return + } + return *v, true +} + +// OldUserID returns the old "user_id" field's value of the AnnouncementRead entity. +// If the AnnouncementRead object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AnnouncementReadMutation) OldUserID(ctx context.Context) (v int64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUserID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUserID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUserID: %w", err) + } + return oldValue.UserID, nil +} + +// ResetUserID resets all changes to the "user_id" field. +func (m *AnnouncementReadMutation) ResetUserID() { + m.user = nil +} + +// SetReadAt sets the "read_at" field. +func (m *AnnouncementReadMutation) SetReadAt(t time.Time) { + m.read_at = &t +} + +// ReadAt returns the value of the "read_at" field in the mutation. +func (m *AnnouncementReadMutation) ReadAt() (r time.Time, exists bool) { + v := m.read_at + if v == nil { + return + } + return *v, true +} + +// OldReadAt returns the old "read_at" field's value of the AnnouncementRead entity. +// If the AnnouncementRead object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AnnouncementReadMutation) OldReadAt(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldReadAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldReadAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldReadAt: %w", err) + } + return oldValue.ReadAt, nil +} + +// ResetReadAt resets all changes to the "read_at" field. +func (m *AnnouncementReadMutation) ResetReadAt() { + m.read_at = nil +} + +// SetCreatedAt sets the "created_at" field. +func (m *AnnouncementReadMutation) SetCreatedAt(t time.Time) { + m.created_at = &t +} + +// CreatedAt returns the value of the "created_at" field in the mutation. +func (m *AnnouncementReadMutation) CreatedAt() (r time.Time, exists bool) { + v := m.created_at + if v == nil { + return + } + return *v, true +} + +// OldCreatedAt returns the old "created_at" field's value of the AnnouncementRead entity. +// If the AnnouncementRead object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *AnnouncementReadMutation) OldCreatedAt(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldCreatedAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldCreatedAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldCreatedAt: %w", err) + } + return oldValue.CreatedAt, nil +} + +// ResetCreatedAt resets all changes to the "created_at" field. +func (m *AnnouncementReadMutation) ResetCreatedAt() { + m.created_at = nil +} + +// ClearAnnouncement clears the "announcement" edge to the Announcement entity. +func (m *AnnouncementReadMutation) ClearAnnouncement() { + m.clearedannouncement = true + m.clearedFields[announcementread.FieldAnnouncementID] = struct{}{} +} + +// AnnouncementCleared reports if the "announcement" edge to the Announcement entity was cleared. +func (m *AnnouncementReadMutation) AnnouncementCleared() bool { + return m.clearedannouncement +} + +// AnnouncementIDs returns the "announcement" edge IDs in the mutation. +// Note that IDs always returns len(IDs) <= 1 for unique edges, and you should use +// AnnouncementID instead. It exists only for internal usage by the builders. +func (m *AnnouncementReadMutation) AnnouncementIDs() (ids []int64) { + if id := m.announcement; id != nil { + ids = append(ids, *id) + } + return +} + +// ResetAnnouncement resets all changes to the "announcement" edge. +func (m *AnnouncementReadMutation) ResetAnnouncement() { + m.announcement = nil + m.clearedannouncement = false +} + +// ClearUser clears the "user" edge to the User entity. +func (m *AnnouncementReadMutation) ClearUser() { + m.cleareduser = true + m.clearedFields[announcementread.FieldUserID] = struct{}{} +} + +// UserCleared reports if the "user" edge to the User entity was cleared. +func (m *AnnouncementReadMutation) UserCleared() bool { + return m.cleareduser +} + +// UserIDs returns the "user" edge IDs in the mutation. +// Note that IDs always returns len(IDs) <= 1 for unique edges, and you should use +// UserID instead. It exists only for internal usage by the builders. +func (m *AnnouncementReadMutation) UserIDs() (ids []int64) { + if id := m.user; id != nil { + ids = append(ids, *id) + } + return +} + +// ResetUser resets all changes to the "user" edge. +func (m *AnnouncementReadMutation) ResetUser() { + m.user = nil + m.cleareduser = false +} + +// Where appends a list predicates to the AnnouncementReadMutation builder. +func (m *AnnouncementReadMutation) Where(ps ...predicate.AnnouncementRead) { + m.predicates = append(m.predicates, ps...) +} + +// WhereP appends storage-level predicates to the AnnouncementReadMutation builder. Using this method, +// users can use type-assertion to append predicates that do not depend on any generated package. +func (m *AnnouncementReadMutation) WhereP(ps ...func(*sql.Selector)) { + p := make([]predicate.AnnouncementRead, len(ps)) + for i := range ps { + p[i] = ps[i] + } + m.Where(p...) +} + +// Op returns the operation name. +func (m *AnnouncementReadMutation) Op() Op { + return m.op +} + +// SetOp allows setting the mutation operation. +func (m *AnnouncementReadMutation) SetOp(op Op) { + m.op = op +} + +// Type returns the node type of this mutation (AnnouncementRead). +func (m *AnnouncementReadMutation) Type() string { + return m.typ +} + +// Fields returns all fields that were changed during this mutation. Note that in +// order to get all numeric fields that were incremented/decremented, call +// AddedFields(). +func (m *AnnouncementReadMutation) Fields() []string { + fields := make([]string, 0, 4) + if m.announcement != nil { + fields = append(fields, announcementread.FieldAnnouncementID) + } + if m.user != nil { + fields = append(fields, announcementread.FieldUserID) + } + if m.read_at != nil { + fields = append(fields, announcementread.FieldReadAt) + } + if m.created_at != nil { + fields = append(fields, announcementread.FieldCreatedAt) + } + return fields +} + +// Field returns the value of a field with the given name. The second boolean +// return value indicates that this field was not set, or was not defined in the +// schema. +func (m *AnnouncementReadMutation) Field(name string) (ent.Value, bool) { + switch name { + case announcementread.FieldAnnouncementID: + return m.AnnouncementID() + case announcementread.FieldUserID: + return m.UserID() + case announcementread.FieldReadAt: + return m.ReadAt() + case announcementread.FieldCreatedAt: + return m.CreatedAt() + } + return nil, false +} + +// OldField returns the old value of the field from the database. An error is +// returned if the mutation operation is not UpdateOne, or the query to the +// database failed. +func (m *AnnouncementReadMutation) OldField(ctx context.Context, name string) (ent.Value, error) { + switch name { + case announcementread.FieldAnnouncementID: + return m.OldAnnouncementID(ctx) + case announcementread.FieldUserID: + return m.OldUserID(ctx) + case announcementread.FieldReadAt: + return m.OldReadAt(ctx) + case announcementread.FieldCreatedAt: + return m.OldCreatedAt(ctx) + } + return nil, fmt.Errorf("unknown AnnouncementRead field %s", name) +} + +// SetField sets the value of a field with the given name. It returns an error if +// the field is not defined in the schema, or if the type mismatched the field +// type. +func (m *AnnouncementReadMutation) SetField(name string, value ent.Value) error { + switch name { + case announcementread.FieldAnnouncementID: + v, ok := value.(int64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetAnnouncementID(v) + return nil + case announcementread.FieldUserID: + v, ok := value.(int64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUserID(v) + return nil + case announcementread.FieldReadAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetReadAt(v) + return nil + case announcementread.FieldCreatedAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetCreatedAt(v) + return nil + } + return fmt.Errorf("unknown AnnouncementRead field %s", name) +} + +// AddedFields returns all numeric fields that were incremented/decremented during +// this mutation. +func (m *AnnouncementReadMutation) AddedFields() []string { + var fields []string + return fields +} + +// AddedField returns the numeric value that was incremented/decremented on a field +// with the given name. The second boolean return value indicates that this field +// was not set, or was not defined in the schema. +func (m *AnnouncementReadMutation) AddedField(name string) (ent.Value, bool) { + switch name { + } + return nil, false +} + +// AddField adds the value to the field with the given name. It returns an error if +// the field is not defined in the schema, or if the type mismatched the field +// type. +func (m *AnnouncementReadMutation) AddField(name string, value ent.Value) error { + switch name { + } + return fmt.Errorf("unknown AnnouncementRead numeric field %s", name) +} + +// ClearedFields returns all nullable fields that were cleared during this +// mutation. +func (m *AnnouncementReadMutation) ClearedFields() []string { + return nil +} + +// FieldCleared returns a boolean indicating if a field with the given name was +// cleared in this mutation. +func (m *AnnouncementReadMutation) FieldCleared(name string) bool { + _, ok := m.clearedFields[name] + return ok +} + +// ClearField clears the value of the field with the given name. It returns an +// error if the field is not defined in the schema. +func (m *AnnouncementReadMutation) ClearField(name string) error { + return fmt.Errorf("unknown AnnouncementRead nullable field %s", name) +} + +// ResetField resets all changes in the mutation for the field with the given name. +// It returns an error if the field is not defined in the schema. +func (m *AnnouncementReadMutation) ResetField(name string) error { + switch name { + case announcementread.FieldAnnouncementID: + m.ResetAnnouncementID() + return nil + case announcementread.FieldUserID: + m.ResetUserID() + return nil + case announcementread.FieldReadAt: + m.ResetReadAt() + return nil + case announcementread.FieldCreatedAt: + m.ResetCreatedAt() + return nil + } + return fmt.Errorf("unknown AnnouncementRead field %s", name) +} + +// AddedEdges returns all edge names that were set/added in this mutation. +func (m *AnnouncementReadMutation) AddedEdges() []string { + edges := make([]string, 0, 2) + if m.announcement != nil { + edges = append(edges, announcementread.EdgeAnnouncement) + } + if m.user != nil { + edges = append(edges, announcementread.EdgeUser) + } + return edges +} + +// AddedIDs returns all IDs (to other nodes) that were added for the given edge +// name in this mutation. +func (m *AnnouncementReadMutation) AddedIDs(name string) []ent.Value { + switch name { + case announcementread.EdgeAnnouncement: + if id := m.announcement; id != nil { + return []ent.Value{*id} + } + case announcementread.EdgeUser: + if id := m.user; id != nil { + return []ent.Value{*id} + } + } + return nil +} + +// RemovedEdges returns all edge names that were removed in this mutation. +func (m *AnnouncementReadMutation) RemovedEdges() []string { + edges := make([]string, 0, 2) + return edges +} + +// RemovedIDs returns all IDs (to other nodes) that were removed for the edge with +// the given name in this mutation. +func (m *AnnouncementReadMutation) RemovedIDs(name string) []ent.Value { + return nil +} + +// ClearedEdges returns all edge names that were cleared in this mutation. +func (m *AnnouncementReadMutation) ClearedEdges() []string { + edges := make([]string, 0, 2) + if m.clearedannouncement { + edges = append(edges, announcementread.EdgeAnnouncement) + } + if m.cleareduser { + edges = append(edges, announcementread.EdgeUser) + } + return edges +} + +// EdgeCleared returns a boolean which indicates if the edge with the given name +// was cleared in this mutation. +func (m *AnnouncementReadMutation) EdgeCleared(name string) bool { + switch name { + case announcementread.EdgeAnnouncement: + return m.clearedannouncement + case announcementread.EdgeUser: + return m.cleareduser + } + return false +} + +// ClearEdge clears the value of the edge with the given name. It returns an error +// if that edge is not defined in the schema. +func (m *AnnouncementReadMutation) ClearEdge(name string) error { + switch name { + case announcementread.EdgeAnnouncement: + m.ClearAnnouncement() + return nil + case announcementread.EdgeUser: + m.ClearUser() + return nil + } + return fmt.Errorf("unknown AnnouncementRead unique edge %s", name) +} + +// ResetEdge resets all changes to the edge with the given name in this mutation. +// It returns an error if the edge is not defined in the schema. +func (m *AnnouncementReadMutation) ResetEdge(name string) error { + switch name { + case announcementread.EdgeAnnouncement: + m.ResetAnnouncement() + return nil + case announcementread.EdgeUser: + m.ResetUser() + return nil + } + return fmt.Errorf("unknown AnnouncementRead edge %s", name) +} + // GroupMutation represents an operation that mutates the Group nodes in the graph. type GroupMutation struct { config @@ -14861,6 +16531,9 @@ type UserMutation struct { status *string username *string notes *string + totp_secret_encrypted *string + totp_enabled *bool + totp_enabled_at *time.Time clearedFields map[string]struct{} api_keys map[int64]struct{} removedapi_keys map[int64]struct{} @@ -14874,6 +16547,9 @@ type UserMutation struct { assigned_subscriptions map[int64]struct{} removedassigned_subscriptions map[int64]struct{} clearedassigned_subscriptions bool + announcement_reads map[int64]struct{} + removedannouncement_reads map[int64]struct{} + clearedannouncement_reads bool allowed_groups map[int64]struct{} removedallowed_groups map[int64]struct{} clearedallowed_groups bool @@ -15438,6 +17114,140 @@ func (m *UserMutation) ResetNotes() { m.notes = nil } +// SetTotpSecretEncrypted sets the "totp_secret_encrypted" field. +func (m *UserMutation) SetTotpSecretEncrypted(s string) { + m.totp_secret_encrypted = &s +} + +// TotpSecretEncrypted returns the value of the "totp_secret_encrypted" field in the mutation. +func (m *UserMutation) TotpSecretEncrypted() (r string, exists bool) { + v := m.totp_secret_encrypted + if v == nil { + return + } + return *v, true +} + +// OldTotpSecretEncrypted returns the old "totp_secret_encrypted" field's value of the User entity. +// If the User object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserMutation) OldTotpSecretEncrypted(ctx context.Context) (v *string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldTotpSecretEncrypted is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldTotpSecretEncrypted requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldTotpSecretEncrypted: %w", err) + } + return oldValue.TotpSecretEncrypted, nil +} + +// ClearTotpSecretEncrypted clears the value of the "totp_secret_encrypted" field. +func (m *UserMutation) ClearTotpSecretEncrypted() { + m.totp_secret_encrypted = nil + m.clearedFields[user.FieldTotpSecretEncrypted] = struct{}{} +} + +// TotpSecretEncryptedCleared returns if the "totp_secret_encrypted" field was cleared in this mutation. +func (m *UserMutation) TotpSecretEncryptedCleared() bool { + _, ok := m.clearedFields[user.FieldTotpSecretEncrypted] + return ok +} + +// ResetTotpSecretEncrypted resets all changes to the "totp_secret_encrypted" field. +func (m *UserMutation) ResetTotpSecretEncrypted() { + m.totp_secret_encrypted = nil + delete(m.clearedFields, user.FieldTotpSecretEncrypted) +} + +// SetTotpEnabled sets the "totp_enabled" field. +func (m *UserMutation) SetTotpEnabled(b bool) { + m.totp_enabled = &b +} + +// TotpEnabled returns the value of the "totp_enabled" field in the mutation. +func (m *UserMutation) TotpEnabled() (r bool, exists bool) { + v := m.totp_enabled + if v == nil { + return + } + return *v, true +} + +// OldTotpEnabled returns the old "totp_enabled" field's value of the User entity. +// If the User object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserMutation) OldTotpEnabled(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldTotpEnabled is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldTotpEnabled requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldTotpEnabled: %w", err) + } + return oldValue.TotpEnabled, nil +} + +// ResetTotpEnabled resets all changes to the "totp_enabled" field. +func (m *UserMutation) ResetTotpEnabled() { + m.totp_enabled = nil +} + +// SetTotpEnabledAt sets the "totp_enabled_at" field. +func (m *UserMutation) SetTotpEnabledAt(t time.Time) { + m.totp_enabled_at = &t +} + +// TotpEnabledAt returns the value of the "totp_enabled_at" field in the mutation. +func (m *UserMutation) TotpEnabledAt() (r time.Time, exists bool) { + v := m.totp_enabled_at + if v == nil { + return + } + return *v, true +} + +// OldTotpEnabledAt returns the old "totp_enabled_at" field's value of the User entity. +// If the User object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserMutation) OldTotpEnabledAt(ctx context.Context) (v *time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldTotpEnabledAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldTotpEnabledAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldTotpEnabledAt: %w", err) + } + return oldValue.TotpEnabledAt, nil +} + +// ClearTotpEnabledAt clears the value of the "totp_enabled_at" field. +func (m *UserMutation) ClearTotpEnabledAt() { + m.totp_enabled_at = nil + m.clearedFields[user.FieldTotpEnabledAt] = struct{}{} +} + +// TotpEnabledAtCleared returns if the "totp_enabled_at" field was cleared in this mutation. +func (m *UserMutation) TotpEnabledAtCleared() bool { + _, ok := m.clearedFields[user.FieldTotpEnabledAt] + return ok +} + +// ResetTotpEnabledAt resets all changes to the "totp_enabled_at" field. +func (m *UserMutation) ResetTotpEnabledAt() { + m.totp_enabled_at = nil + delete(m.clearedFields, user.FieldTotpEnabledAt) +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by ids. func (m *UserMutation) AddAPIKeyIDs(ids ...int64) { if m.api_keys == nil { @@ -15654,6 +17464,60 @@ func (m *UserMutation) ResetAssignedSubscriptions() { m.removedassigned_subscriptions = nil } +// AddAnnouncementReadIDs adds the "announcement_reads" edge to the AnnouncementRead entity by ids. +func (m *UserMutation) AddAnnouncementReadIDs(ids ...int64) { + if m.announcement_reads == nil { + m.announcement_reads = make(map[int64]struct{}) + } + for i := range ids { + m.announcement_reads[ids[i]] = struct{}{} + } +} + +// ClearAnnouncementReads clears the "announcement_reads" edge to the AnnouncementRead entity. +func (m *UserMutation) ClearAnnouncementReads() { + m.clearedannouncement_reads = true +} + +// AnnouncementReadsCleared reports if the "announcement_reads" edge to the AnnouncementRead entity was cleared. +func (m *UserMutation) AnnouncementReadsCleared() bool { + return m.clearedannouncement_reads +} + +// RemoveAnnouncementReadIDs removes the "announcement_reads" edge to the AnnouncementRead entity by IDs. +func (m *UserMutation) RemoveAnnouncementReadIDs(ids ...int64) { + if m.removedannouncement_reads == nil { + m.removedannouncement_reads = make(map[int64]struct{}) + } + for i := range ids { + delete(m.announcement_reads, ids[i]) + m.removedannouncement_reads[ids[i]] = struct{}{} + } +} + +// RemovedAnnouncementReads returns the removed IDs of the "announcement_reads" edge to the AnnouncementRead entity. +func (m *UserMutation) RemovedAnnouncementReadsIDs() (ids []int64) { + for id := range m.removedannouncement_reads { + ids = append(ids, id) + } + return +} + +// AnnouncementReadsIDs returns the "announcement_reads" edge IDs in the mutation. +func (m *UserMutation) AnnouncementReadsIDs() (ids []int64) { + for id := range m.announcement_reads { + ids = append(ids, id) + } + return +} + +// ResetAnnouncementReads resets all changes to the "announcement_reads" edge. +func (m *UserMutation) ResetAnnouncementReads() { + m.announcement_reads = nil + m.clearedannouncement_reads = false + m.removedannouncement_reads = nil +} + // AddAllowedGroupIDs adds the "allowed_groups" edge to the Group entity by ids. func (m *UserMutation) AddAllowedGroupIDs(ids ...int64) { if m.allowed_groups == nil { @@ -15904,7 +17768,7 @@ func (m *UserMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *UserMutation) Fields() []string { - fields := make([]string, 0, 11) + fields := make([]string, 0, 14) if m.created_at != nil { fields = append(fields, user.FieldCreatedAt) } @@ -15938,6 +17802,15 @@ func (m *UserMutation) Fields() []string { if m.notes != nil { fields = append(fields, user.FieldNotes) } + if m.totp_secret_encrypted != nil { + fields = append(fields, user.FieldTotpSecretEncrypted) + } + if m.totp_enabled != nil { + fields = append(fields, user.FieldTotpEnabled) + } + if m.totp_enabled_at != nil { + fields = append(fields, user.FieldTotpEnabledAt) + } return fields } @@ -15968,6 +17841,12 @@ func (m *UserMutation) Field(name string) (ent.Value, bool) { return m.Username() case user.FieldNotes: return m.Notes() + case user.FieldTotpSecretEncrypted: + return m.TotpSecretEncrypted() + case user.FieldTotpEnabled: + return m.TotpEnabled() + case user.FieldTotpEnabledAt: + return m.TotpEnabledAt() } return nil, false } @@ -15999,6 +17878,12 @@ func (m *UserMutation) OldField(ctx context.Context, name string) (ent.Value, er return m.OldUsername(ctx) case user.FieldNotes: return m.OldNotes(ctx) + case user.FieldTotpSecretEncrypted: + return m.OldTotpSecretEncrypted(ctx) + case user.FieldTotpEnabled: + return m.OldTotpEnabled(ctx) + case user.FieldTotpEnabledAt: + return m.OldTotpEnabledAt(ctx) } return nil, fmt.Errorf("unknown User field %s", name) } @@ -16085,6 +17970,27 @@ func (m *UserMutation) SetField(name string, value ent.Value) error { } m.SetNotes(v) return nil + case user.FieldTotpSecretEncrypted: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetTotpSecretEncrypted(v) + return nil + case user.FieldTotpEnabled: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetTotpEnabled(v) + return nil + case user.FieldTotpEnabledAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetTotpEnabledAt(v) + return nil } return fmt.Errorf("unknown User field %s", name) } @@ -16145,6 +18051,12 @@ func (m *UserMutation) ClearedFields() []string { if m.FieldCleared(user.FieldDeletedAt) { fields = append(fields, user.FieldDeletedAt) } + if m.FieldCleared(user.FieldTotpSecretEncrypted) { + fields = append(fields, user.FieldTotpSecretEncrypted) + } + if m.FieldCleared(user.FieldTotpEnabledAt) { + fields = append(fields, user.FieldTotpEnabledAt) + } return fields } @@ -16162,6 +18074,12 @@ func (m *UserMutation) ClearField(name string) error { case user.FieldDeletedAt: m.ClearDeletedAt() return nil + case user.FieldTotpSecretEncrypted: + m.ClearTotpSecretEncrypted() + return nil + case user.FieldTotpEnabledAt: + m.ClearTotpEnabledAt() + return nil } return fmt.Errorf("unknown User nullable field %s", name) } @@ -16203,13 +18121,22 @@ func (m *UserMutation) ResetField(name string) error { case user.FieldNotes: m.ResetNotes() return nil + case user.FieldTotpSecretEncrypted: + m.ResetTotpSecretEncrypted() + return nil + case user.FieldTotpEnabled: + m.ResetTotpEnabled() + return nil + case user.FieldTotpEnabledAt: + m.ResetTotpEnabledAt() + return nil } return fmt.Errorf("unknown User field %s", name) } // AddedEdges returns all edge names that were set/added in this mutation. func (m *UserMutation) AddedEdges() []string { - edges := make([]string, 0, 8) + edges := make([]string, 0, 9) if m.api_keys != nil { edges = append(edges, user.EdgeAPIKeys) } @@ -16222,6 +18149,9 @@ func (m *UserMutation) AddedEdges() []string { if m.assigned_subscriptions != nil { edges = append(edges, user.EdgeAssignedSubscriptions) } + if m.announcement_reads != nil { + edges = append(edges, user.EdgeAnnouncementReads) + } if m.allowed_groups != nil { edges = append(edges, user.EdgeAllowedGroups) } @@ -16265,6 +18195,12 @@ func (m *UserMutation) AddedIDs(name string) []ent.Value { ids = append(ids, id) } return ids + case user.EdgeAnnouncementReads: + ids := make([]ent.Value, 0, len(m.announcement_reads)) + for id := range m.announcement_reads { + ids = append(ids, id) + } + return ids case user.EdgeAllowedGroups: ids := make([]ent.Value, 0, len(m.allowed_groups)) for id := range m.allowed_groups { @@ -16295,7 +18231,7 @@ func (m *UserMutation) AddedIDs(name string) []ent.Value { // RemovedEdges returns all edge names that were removed in this mutation. func (m *UserMutation) RemovedEdges() []string { - edges := make([]string, 0, 8) + edges := make([]string, 0, 9) if m.removedapi_keys != nil { edges = append(edges, user.EdgeAPIKeys) } @@ -16308,6 +18244,9 @@ func (m *UserMutation) RemovedEdges() []string { if m.removedassigned_subscriptions != nil { edges = append(edges, user.EdgeAssignedSubscriptions) } + if m.removedannouncement_reads != nil { + edges = append(edges, user.EdgeAnnouncementReads) + } if m.removedallowed_groups != nil { edges = append(edges, user.EdgeAllowedGroups) } @@ -16351,6 +18290,12 @@ func (m *UserMutation) RemovedIDs(name string) []ent.Value { ids = append(ids, id) } return ids + case user.EdgeAnnouncementReads: + ids := make([]ent.Value, 0, len(m.removedannouncement_reads)) + for id := range m.removedannouncement_reads { + ids = append(ids, id) + } + return ids case user.EdgeAllowedGroups: ids := make([]ent.Value, 0, len(m.removedallowed_groups)) for id := range m.removedallowed_groups { @@ -16381,7 +18326,7 @@ func (m *UserMutation) RemovedIDs(name string) []ent.Value { // ClearedEdges returns all edge names that were cleared in this mutation. func (m *UserMutation) ClearedEdges() []string { - edges := make([]string, 0, 8) + edges := make([]string, 0, 9) if m.clearedapi_keys { edges = append(edges, user.EdgeAPIKeys) } @@ -16394,6 +18339,9 @@ func (m *UserMutation) ClearedEdges() []string { if m.clearedassigned_subscriptions { edges = append(edges, user.EdgeAssignedSubscriptions) } + if m.clearedannouncement_reads { + edges = append(edges, user.EdgeAnnouncementReads) + } if m.clearedallowed_groups { edges = append(edges, user.EdgeAllowedGroups) } @@ -16421,6 +18369,8 @@ func (m *UserMutation) EdgeCleared(name string) bool { return m.clearedsubscriptions case user.EdgeAssignedSubscriptions: return m.clearedassigned_subscriptions + case user.EdgeAnnouncementReads: + return m.clearedannouncement_reads case user.EdgeAllowedGroups: return m.clearedallowed_groups case user.EdgeUsageLogs: @@ -16457,6 +18407,9 @@ func (m *UserMutation) ResetEdge(name string) error { case user.EdgeAssignedSubscriptions: m.ResetAssignedSubscriptions() return nil + case user.EdgeAnnouncementReads: + m.ResetAnnouncementReads() + return nil case user.EdgeAllowedGroups: m.ResetAllowedGroups() return nil diff --git a/backend/ent/predicate/predicate.go b/backend/ent/predicate/predicate.go index 785cb4e6..613c5913 100644 --- a/backend/ent/predicate/predicate.go +++ b/backend/ent/predicate/predicate.go @@ -15,6 +15,12 @@ type Account func(*sql.Selector) // AccountGroup is the predicate function for accountgroup builders. type AccountGroup func(*sql.Selector) +// Announcement is the predicate function for announcement builders. +type Announcement func(*sql.Selector) + +// AnnouncementRead is the predicate function for announcementread builders. +type AnnouncementRead func(*sql.Selector) + // Group is the predicate function for group builders. type Group func(*sql.Selector) diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 15b02ad1..aeced47a 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -7,6 +7,8 @@ import ( "github.com/Wei-Shaw/sub2api/ent/account" "github.com/Wei-Shaw/sub2api/ent/accountgroup" + "github.com/Wei-Shaw/sub2api/ent/announcement" + "github.com/Wei-Shaw/sub2api/ent/announcementread" "github.com/Wei-Shaw/sub2api/ent/apikey" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/promocode" @@ -210,6 +212,56 @@ func init() { accountgroupDescCreatedAt := accountgroupFields[3].Descriptor() // accountgroup.DefaultCreatedAt holds the default value on creation for the created_at field. accountgroup.DefaultCreatedAt = accountgroupDescCreatedAt.Default.(func() time.Time) + announcementFields := schema.Announcement{}.Fields() + _ = announcementFields + // announcementDescTitle is the schema descriptor for title field. + announcementDescTitle := announcementFields[0].Descriptor() + // announcement.TitleValidator is a validator for the "title" field. It is called by the builders before save. + announcement.TitleValidator = func() func(string) error { + validators := announcementDescTitle.Validators + fns := [...]func(string) error{ + validators[0].(func(string) error), + validators[1].(func(string) error), + } + return func(title string) error { + for _, fn := range fns { + if err := fn(title); err != nil { + return err + } + } + return nil + } + }() + // announcementDescContent is the schema descriptor for content field. + announcementDescContent := announcementFields[1].Descriptor() + // announcement.ContentValidator is a validator for the "content" field. It is called by the builders before save. + announcement.ContentValidator = announcementDescContent.Validators[0].(func(string) error) + // announcementDescStatus is the schema descriptor for status field. + announcementDescStatus := announcementFields[2].Descriptor() + // announcement.DefaultStatus holds the default value on creation for the status field. + announcement.DefaultStatus = announcementDescStatus.Default.(string) + // announcement.StatusValidator is a validator for the "status" field. It is called by the builders before save. + announcement.StatusValidator = announcementDescStatus.Validators[0].(func(string) error) + // announcementDescCreatedAt is the schema descriptor for created_at field. + announcementDescCreatedAt := announcementFields[8].Descriptor() + // announcement.DefaultCreatedAt holds the default value on creation for the created_at field. + announcement.DefaultCreatedAt = announcementDescCreatedAt.Default.(func() time.Time) + // announcementDescUpdatedAt is the schema descriptor for updated_at field. + announcementDescUpdatedAt := announcementFields[9].Descriptor() + // announcement.DefaultUpdatedAt holds the default value on creation for the updated_at field. + announcement.DefaultUpdatedAt = announcementDescUpdatedAt.Default.(func() time.Time) + // announcement.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field. + announcement.UpdateDefaultUpdatedAt = announcementDescUpdatedAt.UpdateDefault.(func() time.Time) + announcementreadFields := schema.AnnouncementRead{}.Fields() + _ = announcementreadFields + // announcementreadDescReadAt is the schema descriptor for read_at field. + announcementreadDescReadAt := announcementreadFields[2].Descriptor() + // announcementread.DefaultReadAt holds the default value on creation for the read_at field. + announcementread.DefaultReadAt = announcementreadDescReadAt.Default.(func() time.Time) + // announcementreadDescCreatedAt is the schema descriptor for created_at field. + announcementreadDescCreatedAt := announcementreadFields[3].Descriptor() + // announcementread.DefaultCreatedAt holds the default value on creation for the created_at field. + announcementread.DefaultCreatedAt = announcementreadDescCreatedAt.Default.(func() time.Time) groupMixin := schema.Group{}.Mixin() groupMixinHooks1 := groupMixin[1].Hooks() group.Hooks[0] = groupMixinHooks1[0] @@ -740,6 +792,10 @@ func init() { userDescNotes := userFields[7].Descriptor() // user.DefaultNotes holds the default value on creation for the notes field. user.DefaultNotes = userDescNotes.Default.(string) + // userDescTotpEnabled is the schema descriptor for totp_enabled field. + userDescTotpEnabled := userFields[9].Descriptor() + // user.DefaultTotpEnabled holds the default value on creation for the totp_enabled field. + user.DefaultTotpEnabled = userDescTotpEnabled.Default.(bool) userallowedgroupFields := schema.UserAllowedGroup{}.Fields() _ = userallowedgroupFields // userallowedgroupDescCreatedAt is the schema descriptor for created_at field. diff --git a/backend/ent/schema/account.go b/backend/ent/schema/account.go index dd79ba96..1cfecc2d 100644 --- a/backend/ent/schema/account.go +++ b/backend/ent/schema/account.go @@ -4,7 +4,7 @@ package schema import ( "github.com/Wei-Shaw/sub2api/ent/schema/mixins" - "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/Wei-Shaw/sub2api/internal/domain" "entgo.io/ent" "entgo.io/ent/dialect" @@ -111,7 +111,7 @@ func (Account) Fields() []ent.Field { // status: 账户状态,如 "active", "error", "disabled" field.String("status"). MaxLen(20). - Default(service.StatusActive), + Default(domain.StatusActive), // error_message: 错误信息,记录账户异常时的详细信息 field.String("error_message"). diff --git a/backend/ent/schema/announcement.go b/backend/ent/schema/announcement.go new file mode 100644 index 00000000..1568778f --- /dev/null +++ b/backend/ent/schema/announcement.go @@ -0,0 +1,90 @@ +package schema + +import ( + "time" + + "github.com/Wei-Shaw/sub2api/internal/domain" + + "entgo.io/ent" + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/entsql" + "entgo.io/ent/schema" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/index" +) + +// Announcement holds the schema definition for the Announcement entity. +// +// 删除策略:硬删除(已读记录通过外键级联删除) +type Announcement struct { + ent.Schema +} + +func (Announcement) Annotations() []schema.Annotation { + return []schema.Annotation{ + entsql.Annotation{Table: "announcements"}, + } +} + +func (Announcement) Fields() []ent.Field { + return []ent.Field{ + field.String("title"). + MaxLen(200). + NotEmpty(). + Comment("公告标题"), + field.String("content"). + SchemaType(map[string]string{dialect.Postgres: "text"}). + NotEmpty(). + Comment("公告内容(支持 Markdown)"), + field.String("status"). + MaxLen(20). + Default(domain.AnnouncementStatusDraft). + Comment("状态: draft, active, archived"), + field.JSON("targeting", domain.AnnouncementTargeting{}). + Optional(). + SchemaType(map[string]string{dialect.Postgres: "jsonb"}). + Comment("展示条件(JSON 规则)"), + field.Time("starts_at"). + Optional(). + Nillable(). + SchemaType(map[string]string{dialect.Postgres: "timestamptz"}). + Comment("开始展示时间(为空表示立即生效)"), + field.Time("ends_at"). + Optional(). + Nillable(). + SchemaType(map[string]string{dialect.Postgres: "timestamptz"}). + Comment("结束展示时间(为空表示永久生效)"), + field.Int64("created_by"). + Optional(). + Nillable(). + Comment("创建人用户ID(管理员)"), + field.Int64("updated_by"). + Optional(). + Nillable(). + Comment("更新人用户ID(管理员)"), + field.Time("created_at"). + Immutable(). + Default(time.Now). + SchemaType(map[string]string{dialect.Postgres: "timestamptz"}), + field.Time("updated_at"). + Default(time.Now). + UpdateDefault(time.Now). + SchemaType(map[string]string{dialect.Postgres: "timestamptz"}), + } +} + +func (Announcement) Edges() []ent.Edge { + return []ent.Edge{ + edge.To("reads", AnnouncementRead.Type), + } +} + +func (Announcement) Indexes() []ent.Index { + return []ent.Index{ + index.Fields("status"), + index.Fields("created_at"), + index.Fields("starts_at"), + index.Fields("ends_at"), + } +} diff --git a/backend/ent/schema/announcement_read.go b/backend/ent/schema/announcement_read.go new file mode 100644 index 00000000..e0b50777 --- /dev/null +++ b/backend/ent/schema/announcement_read.go @@ -0,0 +1,65 @@ +package schema + +import ( + "time" + + "entgo.io/ent" + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/entsql" + "entgo.io/ent/schema" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/index" +) + +// AnnouncementRead holds the schema definition for the AnnouncementRead entity. +// +// 记录用户对公告的已读状态(首次已读时间)。 +type AnnouncementRead struct { + ent.Schema +} + +func (AnnouncementRead) Annotations() []schema.Annotation { + return []schema.Annotation{ + entsql.Annotation{Table: "announcement_reads"}, + } +} + +func (AnnouncementRead) Fields() []ent.Field { + return []ent.Field{ + field.Int64("announcement_id"), + field.Int64("user_id"), + field.Time("read_at"). + Default(time.Now). + SchemaType(map[string]string{dialect.Postgres: "timestamptz"}). + Comment("用户首次已读时间"), + field.Time("created_at"). + Immutable(). + Default(time.Now). + SchemaType(map[string]string{dialect.Postgres: "timestamptz"}), + } +} + +func (AnnouncementRead) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("announcement", Announcement.Type). + Ref("reads"). + Field("announcement_id"). + Unique(). + Required(), + edge.From("user", User.Type). + Ref("announcement_reads"). + Field("user_id"). + Unique(). + Required(), + } +} + +func (AnnouncementRead) Indexes() []ent.Index { + return []ent.Index{ + index.Fields("announcement_id"), + index.Fields("user_id"), + index.Fields("read_at"), + index.Fields("announcement_id", "user_id").Unique(), + } +} diff --git a/backend/ent/schema/api_key.go b/backend/ent/schema/api_key.go index 1b206089..1c2d4bd4 100644 --- a/backend/ent/schema/api_key.go +++ b/backend/ent/schema/api_key.go @@ -2,7 +2,7 @@ package schema import ( "github.com/Wei-Shaw/sub2api/ent/schema/mixins" - "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/Wei-Shaw/sub2api/internal/domain" "entgo.io/ent" "entgo.io/ent/dialect/entsql" @@ -45,7 +45,7 @@ func (APIKey) Fields() []ent.Field { Nillable(), field.String("status"). MaxLen(20). - Default(service.StatusActive), + Default(domain.StatusActive), field.JSON("ip_whitelist", []string{}). Optional(). Comment("Allowed IPs/CIDRs, e.g. [\"192.168.1.100\", \"10.0.0.0/8\"]"), diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index 7fa04b8a..65b57754 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -2,7 +2,7 @@ package schema import ( "github.com/Wei-Shaw/sub2api/ent/schema/mixins" - "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/Wei-Shaw/sub2api/internal/domain" "entgo.io/ent" "entgo.io/ent/dialect" @@ -49,15 +49,15 @@ func (Group) Fields() []ent.Field { Default(false), field.String("status"). MaxLen(20). - Default(service.StatusActive), + Default(domain.StatusActive), // Subscription-related fields (added by migration 003) field.String("platform"). MaxLen(50). - Default(service.PlatformAnthropic), + Default(domain.PlatformAnthropic), field.String("subscription_type"). MaxLen(20). - Default(service.SubscriptionTypeStandard), + Default(domain.SubscriptionTypeStandard), field.Float("daily_limit_usd"). Optional(). Nillable(). diff --git a/backend/ent/schema/promo_code.go b/backend/ent/schema/promo_code.go index c3bb824b..3dd08c0e 100644 --- a/backend/ent/schema/promo_code.go +++ b/backend/ent/schema/promo_code.go @@ -3,7 +3,7 @@ package schema import ( "time" - "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/Wei-Shaw/sub2api/internal/domain" "entgo.io/ent" "entgo.io/ent/dialect" @@ -49,7 +49,7 @@ func (PromoCode) Fields() []ent.Field { Comment("已使用次数"), field.String("status"). MaxLen(20). - Default(service.PromoCodeStatusActive). + Default(domain.PromoCodeStatusActive). Comment("状态: active, disabled"), field.Time("expires_at"). Optional(). diff --git a/backend/ent/schema/redeem_code.go b/backend/ent/schema/redeem_code.go index b4664e06..6fb86148 100644 --- a/backend/ent/schema/redeem_code.go +++ b/backend/ent/schema/redeem_code.go @@ -3,7 +3,7 @@ package schema import ( "time" - "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/Wei-Shaw/sub2api/internal/domain" "entgo.io/ent" "entgo.io/ent/dialect" @@ -41,13 +41,13 @@ func (RedeemCode) Fields() []ent.Field { Unique(), field.String("type"). MaxLen(20). - Default(service.RedeemTypeBalance), + Default(domain.RedeemTypeBalance), field.Float("value"). SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}). Default(0), field.String("status"). MaxLen(20). - Default(service.StatusUnused), + Default(domain.StatusUnused), field.Int64("used_by"). Optional(). Nillable(), diff --git a/backend/ent/schema/user.go b/backend/ent/schema/user.go index 79dc2286..d443ef45 100644 --- a/backend/ent/schema/user.go +++ b/backend/ent/schema/user.go @@ -2,7 +2,7 @@ package schema import ( "github.com/Wei-Shaw/sub2api/ent/schema/mixins" - "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/Wei-Shaw/sub2api/internal/domain" "entgo.io/ent" "entgo.io/ent/dialect" @@ -43,7 +43,7 @@ func (User) Fields() []ent.Field { NotEmpty(), field.String("role"). MaxLen(20). - Default(service.RoleUser), + Default(domain.RoleUser), field.Float("balance"). SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}). Default(0), @@ -51,7 +51,7 @@ func (User) Fields() []ent.Field { Default(5), field.String("status"). MaxLen(20). - Default(service.StatusActive), + Default(domain.StatusActive), // Optional profile fields (added later; default '' in DB migration) field.String("username"). @@ -61,6 +61,17 @@ func (User) Fields() []ent.Field { field.String("notes"). SchemaType(map[string]string{dialect.Postgres: "text"}). Default(""), + + // TOTP 双因素认证字段 + field.String("totp_secret_encrypted"). + SchemaType(map[string]string{dialect.Postgres: "text"}). + Optional(). + Nillable(), + field.Bool("totp_enabled"). + Default(false), + field.Time("totp_enabled_at"). + Optional(). + Nillable(), } } @@ -70,6 +81,7 @@ func (User) Edges() []ent.Edge { edge.To("redeem_codes", RedeemCode.Type), edge.To("subscriptions", UserSubscription.Type), edge.To("assigned_subscriptions", UserSubscription.Type), + edge.To("announcement_reads", AnnouncementRead.Type), edge.To("allowed_groups", Group.Type). Through("user_allowed_groups", UserAllowedGroup.Type), edge.To("usage_logs", UsageLog.Type), diff --git a/backend/ent/schema/user_subscription.go b/backend/ent/schema/user_subscription.go index b21f4083..fa13612b 100644 --- a/backend/ent/schema/user_subscription.go +++ b/backend/ent/schema/user_subscription.go @@ -4,7 +4,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/ent/schema/mixins" - "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/Wei-Shaw/sub2api/internal/domain" "entgo.io/ent" "entgo.io/ent/dialect" @@ -44,7 +44,7 @@ func (UserSubscription) Fields() []ent.Field { SchemaType(map[string]string{dialect.Postgres: "timestamptz"}), field.String("status"). MaxLen(20). - Default(service.SubscriptionStatusActive), + Default(domain.SubscriptionStatusActive), field.Time("daily_window_start"). Optional(). diff --git a/backend/ent/tx.go b/backend/ent/tx.go index 7ff16ec8..702bdf90 100644 --- a/backend/ent/tx.go +++ b/backend/ent/tx.go @@ -20,6 +20,10 @@ type Tx struct { Account *AccountClient // AccountGroup is the client for interacting with the AccountGroup builders. AccountGroup *AccountGroupClient + // Announcement is the client for interacting with the Announcement builders. + Announcement *AnnouncementClient + // AnnouncementRead is the client for interacting with the AnnouncementRead builders. + AnnouncementRead *AnnouncementReadClient // Group is the client for interacting with the Group builders. Group *GroupClient // PromoCode is the client for interacting with the PromoCode builders. @@ -180,6 +184,8 @@ func (tx *Tx) init() { tx.APIKey = NewAPIKeyClient(tx.config) tx.Account = NewAccountClient(tx.config) tx.AccountGroup = NewAccountGroupClient(tx.config) + tx.Announcement = NewAnnouncementClient(tx.config) + tx.AnnouncementRead = NewAnnouncementReadClient(tx.config) tx.Group = NewGroupClient(tx.config) tx.PromoCode = NewPromoCodeClient(tx.config) tx.PromoCodeUsage = NewPromoCodeUsageClient(tx.config) diff --git a/backend/ent/user.go b/backend/ent/user.go index 0b9a48cc..2435aa1b 100644 --- a/backend/ent/user.go +++ b/backend/ent/user.go @@ -39,6 +39,12 @@ type User struct { Username string `json:"username,omitempty"` // Notes holds the value of the "notes" field. Notes string `json:"notes,omitempty"` + // TotpSecretEncrypted holds the value of the "totp_secret_encrypted" field. + TotpSecretEncrypted *string `json:"totp_secret_encrypted,omitempty"` + // TotpEnabled holds the value of the "totp_enabled" field. + TotpEnabled bool `json:"totp_enabled,omitempty"` + // TotpEnabledAt holds the value of the "totp_enabled_at" field. + TotpEnabledAt *time.Time `json:"totp_enabled_at,omitempty"` // Edges holds the relations/edges for other nodes in the graph. // The values are being populated by the UserQuery when eager-loading is set. Edges UserEdges `json:"edges"` @@ -55,6 +61,8 @@ type UserEdges struct { Subscriptions []*UserSubscription `json:"subscriptions,omitempty"` // AssignedSubscriptions holds the value of the assigned_subscriptions edge. AssignedSubscriptions []*UserSubscription `json:"assigned_subscriptions,omitempty"` + // AnnouncementReads holds the value of the announcement_reads edge. + AnnouncementReads []*AnnouncementRead `json:"announcement_reads,omitempty"` // AllowedGroups holds the value of the allowed_groups edge. AllowedGroups []*Group `json:"allowed_groups,omitempty"` // UsageLogs holds the value of the usage_logs edge. @@ -67,7 +75,7 @@ type UserEdges struct { UserAllowedGroups []*UserAllowedGroup `json:"user_allowed_groups,omitempty"` // loadedTypes holds the information for reporting if a // type was loaded (or requested) in eager-loading or not. - loadedTypes [9]bool + loadedTypes [10]bool } // APIKeysOrErr returns the APIKeys value or an error if the edge @@ -106,10 +114,19 @@ func (e UserEdges) AssignedSubscriptionsOrErr() ([]*UserSubscription, error) { return nil, &NotLoadedError{edge: "assigned_subscriptions"} } +// AnnouncementReadsOrErr returns the AnnouncementReads value or an error if the edge +// was not loaded in eager-loading. +func (e UserEdges) AnnouncementReadsOrErr() ([]*AnnouncementRead, error) { + if e.loadedTypes[4] { + return e.AnnouncementReads, nil + } + return nil, &NotLoadedError{edge: "announcement_reads"} +} + // AllowedGroupsOrErr returns the AllowedGroups value or an error if the edge // was not loaded in eager-loading. func (e UserEdges) AllowedGroupsOrErr() ([]*Group, error) { - if e.loadedTypes[4] { + if e.loadedTypes[5] { return e.AllowedGroups, nil } return nil, &NotLoadedError{edge: "allowed_groups"} @@ -118,7 +135,7 @@ func (e UserEdges) AllowedGroupsOrErr() ([]*Group, error) { // UsageLogsOrErr returns the UsageLogs value or an error if the edge // was not loaded in eager-loading. func (e UserEdges) UsageLogsOrErr() ([]*UsageLog, error) { - if e.loadedTypes[5] { + if e.loadedTypes[6] { return e.UsageLogs, nil } return nil, &NotLoadedError{edge: "usage_logs"} @@ -127,7 +144,7 @@ func (e UserEdges) UsageLogsOrErr() ([]*UsageLog, error) { // AttributeValuesOrErr returns the AttributeValues value or an error if the edge // was not loaded in eager-loading. func (e UserEdges) AttributeValuesOrErr() ([]*UserAttributeValue, error) { - if e.loadedTypes[6] { + if e.loadedTypes[7] { return e.AttributeValues, nil } return nil, &NotLoadedError{edge: "attribute_values"} @@ -136,7 +153,7 @@ func (e UserEdges) AttributeValuesOrErr() ([]*UserAttributeValue, error) { // PromoCodeUsagesOrErr returns the PromoCodeUsages value or an error if the edge // was not loaded in eager-loading. func (e UserEdges) PromoCodeUsagesOrErr() ([]*PromoCodeUsage, error) { - if e.loadedTypes[7] { + if e.loadedTypes[8] { return e.PromoCodeUsages, nil } return nil, &NotLoadedError{edge: "promo_code_usages"} @@ -145,7 +162,7 @@ func (e UserEdges) PromoCodeUsagesOrErr() ([]*PromoCodeUsage, error) { // UserAllowedGroupsOrErr returns the UserAllowedGroups value or an error if the edge // was not loaded in eager-loading. func (e UserEdges) UserAllowedGroupsOrErr() ([]*UserAllowedGroup, error) { - if e.loadedTypes[8] { + if e.loadedTypes[9] { return e.UserAllowedGroups, nil } return nil, &NotLoadedError{edge: "user_allowed_groups"} @@ -156,13 +173,15 @@ func (*User) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { + case user.FieldTotpEnabled: + values[i] = new(sql.NullBool) case user.FieldBalance: values[i] = new(sql.NullFloat64) case user.FieldID, user.FieldConcurrency: values[i] = new(sql.NullInt64) - case user.FieldEmail, user.FieldPasswordHash, user.FieldRole, user.FieldStatus, user.FieldUsername, user.FieldNotes: + case user.FieldEmail, user.FieldPasswordHash, user.FieldRole, user.FieldStatus, user.FieldUsername, user.FieldNotes, user.FieldTotpSecretEncrypted: values[i] = new(sql.NullString) - case user.FieldCreatedAt, user.FieldUpdatedAt, user.FieldDeletedAt: + case user.FieldCreatedAt, user.FieldUpdatedAt, user.FieldDeletedAt, user.FieldTotpEnabledAt: values[i] = new(sql.NullTime) default: values[i] = new(sql.UnknownType) @@ -252,6 +271,26 @@ func (_m *User) assignValues(columns []string, values []any) error { } else if value.Valid { _m.Notes = value.String } + case user.FieldTotpSecretEncrypted: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field totp_secret_encrypted", values[i]) + } else if value.Valid { + _m.TotpSecretEncrypted = new(string) + *_m.TotpSecretEncrypted = value.String + } + case user.FieldTotpEnabled: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field totp_enabled", values[i]) + } else if value.Valid { + _m.TotpEnabled = value.Bool + } + case user.FieldTotpEnabledAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field totp_enabled_at", values[i]) + } else if value.Valid { + _m.TotpEnabledAt = new(time.Time) + *_m.TotpEnabledAt = value.Time + } default: _m.selectValues.Set(columns[i], values[i]) } @@ -285,6 +324,11 @@ func (_m *User) QueryAssignedSubscriptions() *UserSubscriptionQuery { return NewUserClient(_m.config).QueryAssignedSubscriptions(_m) } +// QueryAnnouncementReads queries the "announcement_reads" edge of the User entity. +func (_m *User) QueryAnnouncementReads() *AnnouncementReadQuery { + return NewUserClient(_m.config).QueryAnnouncementReads(_m) +} + // QueryAllowedGroups queries the "allowed_groups" edge of the User entity. func (_m *User) QueryAllowedGroups() *GroupQuery { return NewUserClient(_m.config).QueryAllowedGroups(_m) @@ -367,6 +411,19 @@ func (_m *User) String() string { builder.WriteString(", ") builder.WriteString("notes=") builder.WriteString(_m.Notes) + builder.WriteString(", ") + if v := _m.TotpSecretEncrypted; v != nil { + builder.WriteString("totp_secret_encrypted=") + builder.WriteString(*v) + } + builder.WriteString(", ") + builder.WriteString("totp_enabled=") + builder.WriteString(fmt.Sprintf("%v", _m.TotpEnabled)) + builder.WriteString(", ") + if v := _m.TotpEnabledAt; v != nil { + builder.WriteString("totp_enabled_at=") + builder.WriteString(v.Format(time.ANSIC)) + } builder.WriteByte(')') return builder.String() } diff --git a/backend/ent/user/user.go b/backend/ent/user/user.go index 1be1d871..ae9418ff 100644 --- a/backend/ent/user/user.go +++ b/backend/ent/user/user.go @@ -37,6 +37,12 @@ const ( FieldUsername = "username" // FieldNotes holds the string denoting the notes field in the database. FieldNotes = "notes" + // FieldTotpSecretEncrypted holds the string denoting the totp_secret_encrypted field in the database. + FieldTotpSecretEncrypted = "totp_secret_encrypted" + // FieldTotpEnabled holds the string denoting the totp_enabled field in the database. + FieldTotpEnabled = "totp_enabled" + // FieldTotpEnabledAt holds the string denoting the totp_enabled_at field in the database. + FieldTotpEnabledAt = "totp_enabled_at" // EdgeAPIKeys holds the string denoting the api_keys edge name in mutations. EdgeAPIKeys = "api_keys" // EdgeRedeemCodes holds the string denoting the redeem_codes edge name in mutations. @@ -45,6 +51,8 @@ const ( EdgeSubscriptions = "subscriptions" // EdgeAssignedSubscriptions holds the string denoting the assigned_subscriptions edge name in mutations. EdgeAssignedSubscriptions = "assigned_subscriptions" + // EdgeAnnouncementReads holds the string denoting the announcement_reads edge name in mutations. + EdgeAnnouncementReads = "announcement_reads" // EdgeAllowedGroups holds the string denoting the allowed_groups edge name in mutations. EdgeAllowedGroups = "allowed_groups" // EdgeUsageLogs holds the string denoting the usage_logs edge name in mutations. @@ -85,6 +93,13 @@ const ( AssignedSubscriptionsInverseTable = "user_subscriptions" // AssignedSubscriptionsColumn is the table column denoting the assigned_subscriptions relation/edge. AssignedSubscriptionsColumn = "assigned_by" + // AnnouncementReadsTable is the table that holds the announcement_reads relation/edge. + AnnouncementReadsTable = "announcement_reads" + // AnnouncementReadsInverseTable is the table name for the AnnouncementRead entity. + // It exists in this package in order to avoid circular dependency with the "announcementread" package. + AnnouncementReadsInverseTable = "announcement_reads" + // AnnouncementReadsColumn is the table column denoting the announcement_reads relation/edge. + AnnouncementReadsColumn = "user_id" // AllowedGroupsTable is the table that holds the allowed_groups relation/edge. The primary key declared below. AllowedGroupsTable = "user_allowed_groups" // AllowedGroupsInverseTable is the table name for the Group entity. @@ -134,6 +149,9 @@ var Columns = []string{ FieldStatus, FieldUsername, FieldNotes, + FieldTotpSecretEncrypted, + FieldTotpEnabled, + FieldTotpEnabledAt, } var ( @@ -188,6 +206,8 @@ var ( UsernameValidator func(string) error // DefaultNotes holds the default value on creation for the "notes" field. DefaultNotes string + // DefaultTotpEnabled holds the default value on creation for the "totp_enabled" field. + DefaultTotpEnabled bool ) // OrderOption defines the ordering options for the User queries. @@ -253,6 +273,21 @@ func ByNotes(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldNotes, opts...).ToFunc() } +// ByTotpSecretEncrypted orders the results by the totp_secret_encrypted field. +func ByTotpSecretEncrypted(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldTotpSecretEncrypted, opts...).ToFunc() +} + +// ByTotpEnabled orders the results by the totp_enabled field. +func ByTotpEnabled(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldTotpEnabled, opts...).ToFunc() +} + +// ByTotpEnabledAt orders the results by the totp_enabled_at field. +func ByTotpEnabledAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldTotpEnabledAt, opts...).ToFunc() +} + // ByAPIKeysCount orders the results by api_keys count. func ByAPIKeysCount(opts ...sql.OrderTermOption) OrderOption { return func(s *sql.Selector) { @@ -309,6 +344,20 @@ func ByAssignedSubscriptions(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOp } } +// ByAnnouncementReadsCount orders the results by announcement_reads count. +func ByAnnouncementReadsCount(opts ...sql.OrderTermOption) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborsCount(s, newAnnouncementReadsStep(), opts...) + } +} + +// ByAnnouncementReads orders the results by announcement_reads terms. +func ByAnnouncementReads(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborTerms(s, newAnnouncementReadsStep(), append([]sql.OrderTerm{term}, terms...)...) + } +} + // ByAllowedGroupsCount orders the results by allowed_groups count. func ByAllowedGroupsCount(opts ...sql.OrderTermOption) OrderOption { return func(s *sql.Selector) { @@ -406,6 +455,13 @@ func newAssignedSubscriptionsStep() *sqlgraph.Step { sqlgraph.Edge(sqlgraph.O2M, false, AssignedSubscriptionsTable, AssignedSubscriptionsColumn), ) } +func newAnnouncementReadsStep() *sqlgraph.Step { + return sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.To(AnnouncementReadsInverseTable, FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, AnnouncementReadsTable, AnnouncementReadsColumn), + ) +} func newAllowedGroupsStep() *sqlgraph.Step { return sqlgraph.NewStep( sqlgraph.From(Table, FieldID), diff --git a/backend/ent/user/where.go b/backend/ent/user/where.go index 6a460f10..1de61037 100644 --- a/backend/ent/user/where.go +++ b/backend/ent/user/where.go @@ -110,6 +110,21 @@ func Notes(v string) predicate.User { return predicate.User(sql.FieldEQ(FieldNotes, v)) } +// TotpSecretEncrypted applies equality check predicate on the "totp_secret_encrypted" field. It's identical to TotpSecretEncryptedEQ. +func TotpSecretEncrypted(v string) predicate.User { + return predicate.User(sql.FieldEQ(FieldTotpSecretEncrypted, v)) +} + +// TotpEnabled applies equality check predicate on the "totp_enabled" field. It's identical to TotpEnabledEQ. +func TotpEnabled(v bool) predicate.User { + return predicate.User(sql.FieldEQ(FieldTotpEnabled, v)) +} + +// TotpEnabledAt applies equality check predicate on the "totp_enabled_at" field. It's identical to TotpEnabledAtEQ. +func TotpEnabledAt(v time.Time) predicate.User { + return predicate.User(sql.FieldEQ(FieldTotpEnabledAt, v)) +} + // CreatedAtEQ applies the EQ predicate on the "created_at" field. func CreatedAtEQ(v time.Time) predicate.User { return predicate.User(sql.FieldEQ(FieldCreatedAt, v)) @@ -710,6 +725,141 @@ func NotesContainsFold(v string) predicate.User { return predicate.User(sql.FieldContainsFold(FieldNotes, v)) } +// TotpSecretEncryptedEQ applies the EQ predicate on the "totp_secret_encrypted" field. +func TotpSecretEncryptedEQ(v string) predicate.User { + return predicate.User(sql.FieldEQ(FieldTotpSecretEncrypted, v)) +} + +// TotpSecretEncryptedNEQ applies the NEQ predicate on the "totp_secret_encrypted" field. +func TotpSecretEncryptedNEQ(v string) predicate.User { + return predicate.User(sql.FieldNEQ(FieldTotpSecretEncrypted, v)) +} + +// TotpSecretEncryptedIn applies the In predicate on the "totp_secret_encrypted" field. +func TotpSecretEncryptedIn(vs ...string) predicate.User { + return predicate.User(sql.FieldIn(FieldTotpSecretEncrypted, vs...)) +} + +// TotpSecretEncryptedNotIn applies the NotIn predicate on the "totp_secret_encrypted" field. +func TotpSecretEncryptedNotIn(vs ...string) predicate.User { + return predicate.User(sql.FieldNotIn(FieldTotpSecretEncrypted, vs...)) +} + +// TotpSecretEncryptedGT applies the GT predicate on the "totp_secret_encrypted" field. +func TotpSecretEncryptedGT(v string) predicate.User { + return predicate.User(sql.FieldGT(FieldTotpSecretEncrypted, v)) +} + +// TotpSecretEncryptedGTE applies the GTE predicate on the "totp_secret_encrypted" field. +func TotpSecretEncryptedGTE(v string) predicate.User { + return predicate.User(sql.FieldGTE(FieldTotpSecretEncrypted, v)) +} + +// TotpSecretEncryptedLT applies the LT predicate on the "totp_secret_encrypted" field. +func TotpSecretEncryptedLT(v string) predicate.User { + return predicate.User(sql.FieldLT(FieldTotpSecretEncrypted, v)) +} + +// TotpSecretEncryptedLTE applies the LTE predicate on the "totp_secret_encrypted" field. +func TotpSecretEncryptedLTE(v string) predicate.User { + return predicate.User(sql.FieldLTE(FieldTotpSecretEncrypted, v)) +} + +// TotpSecretEncryptedContains applies the Contains predicate on the "totp_secret_encrypted" field. +func TotpSecretEncryptedContains(v string) predicate.User { + return predicate.User(sql.FieldContains(FieldTotpSecretEncrypted, v)) +} + +// TotpSecretEncryptedHasPrefix applies the HasPrefix predicate on the "totp_secret_encrypted" field. +func TotpSecretEncryptedHasPrefix(v string) predicate.User { + return predicate.User(sql.FieldHasPrefix(FieldTotpSecretEncrypted, v)) +} + +// TotpSecretEncryptedHasSuffix applies the HasSuffix predicate on the "totp_secret_encrypted" field. +func TotpSecretEncryptedHasSuffix(v string) predicate.User { + return predicate.User(sql.FieldHasSuffix(FieldTotpSecretEncrypted, v)) +} + +// TotpSecretEncryptedIsNil applies the IsNil predicate on the "totp_secret_encrypted" field. +func TotpSecretEncryptedIsNil() predicate.User { + return predicate.User(sql.FieldIsNull(FieldTotpSecretEncrypted)) +} + +// TotpSecretEncryptedNotNil applies the NotNil predicate on the "totp_secret_encrypted" field. +func TotpSecretEncryptedNotNil() predicate.User { + return predicate.User(sql.FieldNotNull(FieldTotpSecretEncrypted)) +} + +// TotpSecretEncryptedEqualFold applies the EqualFold predicate on the "totp_secret_encrypted" field. +func TotpSecretEncryptedEqualFold(v string) predicate.User { + return predicate.User(sql.FieldEqualFold(FieldTotpSecretEncrypted, v)) +} + +// TotpSecretEncryptedContainsFold applies the ContainsFold predicate on the "totp_secret_encrypted" field. +func TotpSecretEncryptedContainsFold(v string) predicate.User { + return predicate.User(sql.FieldContainsFold(FieldTotpSecretEncrypted, v)) +} + +// TotpEnabledEQ applies the EQ predicate on the "totp_enabled" field. +func TotpEnabledEQ(v bool) predicate.User { + return predicate.User(sql.FieldEQ(FieldTotpEnabled, v)) +} + +// TotpEnabledNEQ applies the NEQ predicate on the "totp_enabled" field. +func TotpEnabledNEQ(v bool) predicate.User { + return predicate.User(sql.FieldNEQ(FieldTotpEnabled, v)) +} + +// TotpEnabledAtEQ applies the EQ predicate on the "totp_enabled_at" field. +func TotpEnabledAtEQ(v time.Time) predicate.User { + return predicate.User(sql.FieldEQ(FieldTotpEnabledAt, v)) +} + +// TotpEnabledAtNEQ applies the NEQ predicate on the "totp_enabled_at" field. +func TotpEnabledAtNEQ(v time.Time) predicate.User { + return predicate.User(sql.FieldNEQ(FieldTotpEnabledAt, v)) +} + +// TotpEnabledAtIn applies the In predicate on the "totp_enabled_at" field. +func TotpEnabledAtIn(vs ...time.Time) predicate.User { + return predicate.User(sql.FieldIn(FieldTotpEnabledAt, vs...)) +} + +// TotpEnabledAtNotIn applies the NotIn predicate on the "totp_enabled_at" field. +func TotpEnabledAtNotIn(vs ...time.Time) predicate.User { + return predicate.User(sql.FieldNotIn(FieldTotpEnabledAt, vs...)) +} + +// TotpEnabledAtGT applies the GT predicate on the "totp_enabled_at" field. +func TotpEnabledAtGT(v time.Time) predicate.User { + return predicate.User(sql.FieldGT(FieldTotpEnabledAt, v)) +} + +// TotpEnabledAtGTE applies the GTE predicate on the "totp_enabled_at" field. +func TotpEnabledAtGTE(v time.Time) predicate.User { + return predicate.User(sql.FieldGTE(FieldTotpEnabledAt, v)) +} + +// TotpEnabledAtLT applies the LT predicate on the "totp_enabled_at" field. +func TotpEnabledAtLT(v time.Time) predicate.User { + return predicate.User(sql.FieldLT(FieldTotpEnabledAt, v)) +} + +// TotpEnabledAtLTE applies the LTE predicate on the "totp_enabled_at" field. +func TotpEnabledAtLTE(v time.Time) predicate.User { + return predicate.User(sql.FieldLTE(FieldTotpEnabledAt, v)) +} + +// TotpEnabledAtIsNil applies the IsNil predicate on the "totp_enabled_at" field. +func TotpEnabledAtIsNil() predicate.User { + return predicate.User(sql.FieldIsNull(FieldTotpEnabledAt)) +} + +// TotpEnabledAtNotNil applies the NotNil predicate on the "totp_enabled_at" field. +func TotpEnabledAtNotNil() predicate.User { + return predicate.User(sql.FieldNotNull(FieldTotpEnabledAt)) +} + // HasAPIKeys applies the HasEdge predicate on the "api_keys" edge. func HasAPIKeys() predicate.User { return predicate.User(func(s *sql.Selector) { @@ -802,6 +952,29 @@ func HasAssignedSubscriptionsWith(preds ...predicate.UserSubscription) predicate }) } +// HasAnnouncementReads applies the HasEdge predicate on the "announcement_reads" edge. +func HasAnnouncementReads() predicate.User { + return predicate.User(func(s *sql.Selector) { + step := sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, AnnouncementReadsTable, AnnouncementReadsColumn), + ) + sqlgraph.HasNeighbors(s, step) + }) +} + +// HasAnnouncementReadsWith applies the HasEdge predicate on the "announcement_reads" edge with a given conditions (other predicates). +func HasAnnouncementReadsWith(preds ...predicate.AnnouncementRead) predicate.User { + return predicate.User(func(s *sql.Selector) { + step := newAnnouncementReadsStep() + sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { + for _, p := range preds { + p(s) + } + }) + }) +} + // HasAllowedGroups applies the HasEdge predicate on the "allowed_groups" edge. func HasAllowedGroups() predicate.User { return predicate.User(func(s *sql.Selector) { diff --git a/backend/ent/user_create.go b/backend/ent/user_create.go index e12e476c..f862a580 100644 --- a/backend/ent/user_create.go +++ b/backend/ent/user_create.go @@ -11,6 +11,7 @@ import ( "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" + "github.com/Wei-Shaw/sub2api/ent/announcementread" "github.com/Wei-Shaw/sub2api/ent/apikey" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/promocodeusage" @@ -167,6 +168,48 @@ func (_c *UserCreate) SetNillableNotes(v *string) *UserCreate { return _c } +// SetTotpSecretEncrypted sets the "totp_secret_encrypted" field. +func (_c *UserCreate) SetTotpSecretEncrypted(v string) *UserCreate { + _c.mutation.SetTotpSecretEncrypted(v) + return _c +} + +// SetNillableTotpSecretEncrypted sets the "totp_secret_encrypted" field if the given value is not nil. +func (_c *UserCreate) SetNillableTotpSecretEncrypted(v *string) *UserCreate { + if v != nil { + _c.SetTotpSecretEncrypted(*v) + } + return _c +} + +// SetTotpEnabled sets the "totp_enabled" field. +func (_c *UserCreate) SetTotpEnabled(v bool) *UserCreate { + _c.mutation.SetTotpEnabled(v) + return _c +} + +// SetNillableTotpEnabled sets the "totp_enabled" field if the given value is not nil. +func (_c *UserCreate) SetNillableTotpEnabled(v *bool) *UserCreate { + if v != nil { + _c.SetTotpEnabled(*v) + } + return _c +} + +// SetTotpEnabledAt sets the "totp_enabled_at" field. +func (_c *UserCreate) SetTotpEnabledAt(v time.Time) *UserCreate { + _c.mutation.SetTotpEnabledAt(v) + return _c +} + +// SetNillableTotpEnabledAt sets the "totp_enabled_at" field if the given value is not nil. +func (_c *UserCreate) SetNillableTotpEnabledAt(v *time.Time) *UserCreate { + if v != nil { + _c.SetTotpEnabledAt(*v) + } + return _c +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_c *UserCreate) AddAPIKeyIDs(ids ...int64) *UserCreate { _c.mutation.AddAPIKeyIDs(ids...) @@ -227,6 +270,21 @@ func (_c *UserCreate) AddAssignedSubscriptions(v ...*UserSubscription) *UserCrea return _c.AddAssignedSubscriptionIDs(ids...) } +// AddAnnouncementReadIDs adds the "announcement_reads" edge to the AnnouncementRead entity by IDs. +func (_c *UserCreate) AddAnnouncementReadIDs(ids ...int64) *UserCreate { + _c.mutation.AddAnnouncementReadIDs(ids...) + return _c +} + +// AddAnnouncementReads adds the "announcement_reads" edges to the AnnouncementRead entity. +func (_c *UserCreate) AddAnnouncementReads(v ...*AnnouncementRead) *UserCreate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _c.AddAnnouncementReadIDs(ids...) +} + // AddAllowedGroupIDs adds the "allowed_groups" edge to the Group entity by IDs. func (_c *UserCreate) AddAllowedGroupIDs(ids ...int64) *UserCreate { _c.mutation.AddAllowedGroupIDs(ids...) @@ -362,6 +420,10 @@ func (_c *UserCreate) defaults() error { v := user.DefaultNotes _c.mutation.SetNotes(v) } + if _, ok := _c.mutation.TotpEnabled(); !ok { + v := user.DefaultTotpEnabled + _c.mutation.SetTotpEnabled(v) + } return nil } @@ -422,6 +484,9 @@ func (_c *UserCreate) check() error { if _, ok := _c.mutation.Notes(); !ok { return &ValidationError{Name: "notes", err: errors.New(`ent: missing required field "User.notes"`)} } + if _, ok := _c.mutation.TotpEnabled(); !ok { + return &ValidationError{Name: "totp_enabled", err: errors.New(`ent: missing required field "User.totp_enabled"`)} + } return nil } @@ -493,6 +558,18 @@ func (_c *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) { _spec.SetField(user.FieldNotes, field.TypeString, value) _node.Notes = value } + if value, ok := _c.mutation.TotpSecretEncrypted(); ok { + _spec.SetField(user.FieldTotpSecretEncrypted, field.TypeString, value) + _node.TotpSecretEncrypted = &value + } + if value, ok := _c.mutation.TotpEnabled(); ok { + _spec.SetField(user.FieldTotpEnabled, field.TypeBool, value) + _node.TotpEnabled = value + } + if value, ok := _c.mutation.TotpEnabledAt(); ok { + _spec.SetField(user.FieldTotpEnabledAt, field.TypeTime, value) + _node.TotpEnabledAt = &value + } if nodes := _c.mutation.APIKeysIDs(); len(nodes) > 0 { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -557,6 +634,22 @@ func (_c *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) { } _spec.Edges = append(_spec.Edges, edge) } + if nodes := _c.mutation.AnnouncementReadsIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.AnnouncementReadsTable, + Columns: []string{user.AnnouncementReadsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(announcementread.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges = append(_spec.Edges, edge) + } if nodes := _c.mutation.AllowedGroupsIDs(); len(nodes) > 0 { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.M2M, @@ -815,6 +908,54 @@ func (u *UserUpsert) UpdateNotes() *UserUpsert { return u } +// SetTotpSecretEncrypted sets the "totp_secret_encrypted" field. +func (u *UserUpsert) SetTotpSecretEncrypted(v string) *UserUpsert { + u.Set(user.FieldTotpSecretEncrypted, v) + return u +} + +// UpdateTotpSecretEncrypted sets the "totp_secret_encrypted" field to the value that was provided on create. +func (u *UserUpsert) UpdateTotpSecretEncrypted() *UserUpsert { + u.SetExcluded(user.FieldTotpSecretEncrypted) + return u +} + +// ClearTotpSecretEncrypted clears the value of the "totp_secret_encrypted" field. +func (u *UserUpsert) ClearTotpSecretEncrypted() *UserUpsert { + u.SetNull(user.FieldTotpSecretEncrypted) + return u +} + +// SetTotpEnabled sets the "totp_enabled" field. +func (u *UserUpsert) SetTotpEnabled(v bool) *UserUpsert { + u.Set(user.FieldTotpEnabled, v) + return u +} + +// UpdateTotpEnabled sets the "totp_enabled" field to the value that was provided on create. +func (u *UserUpsert) UpdateTotpEnabled() *UserUpsert { + u.SetExcluded(user.FieldTotpEnabled) + return u +} + +// SetTotpEnabledAt sets the "totp_enabled_at" field. +func (u *UserUpsert) SetTotpEnabledAt(v time.Time) *UserUpsert { + u.Set(user.FieldTotpEnabledAt, v) + return u +} + +// UpdateTotpEnabledAt sets the "totp_enabled_at" field to the value that was provided on create. +func (u *UserUpsert) UpdateTotpEnabledAt() *UserUpsert { + u.SetExcluded(user.FieldTotpEnabledAt) + return u +} + +// ClearTotpEnabledAt clears the value of the "totp_enabled_at" field. +func (u *UserUpsert) ClearTotpEnabledAt() *UserUpsert { + u.SetNull(user.FieldTotpEnabledAt) + return u +} + // UpdateNewValues updates the mutable fields using the new values that were set on create. // Using this option is equivalent to using: // @@ -1021,6 +1162,62 @@ func (u *UserUpsertOne) UpdateNotes() *UserUpsertOne { }) } +// SetTotpSecretEncrypted sets the "totp_secret_encrypted" field. +func (u *UserUpsertOne) SetTotpSecretEncrypted(v string) *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.SetTotpSecretEncrypted(v) + }) +} + +// UpdateTotpSecretEncrypted sets the "totp_secret_encrypted" field to the value that was provided on create. +func (u *UserUpsertOne) UpdateTotpSecretEncrypted() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.UpdateTotpSecretEncrypted() + }) +} + +// ClearTotpSecretEncrypted clears the value of the "totp_secret_encrypted" field. +func (u *UserUpsertOne) ClearTotpSecretEncrypted() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.ClearTotpSecretEncrypted() + }) +} + +// SetTotpEnabled sets the "totp_enabled" field. +func (u *UserUpsertOne) SetTotpEnabled(v bool) *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.SetTotpEnabled(v) + }) +} + +// UpdateTotpEnabled sets the "totp_enabled" field to the value that was provided on create. +func (u *UserUpsertOne) UpdateTotpEnabled() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.UpdateTotpEnabled() + }) +} + +// SetTotpEnabledAt sets the "totp_enabled_at" field. +func (u *UserUpsertOne) SetTotpEnabledAt(v time.Time) *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.SetTotpEnabledAt(v) + }) +} + +// UpdateTotpEnabledAt sets the "totp_enabled_at" field to the value that was provided on create. +func (u *UserUpsertOne) UpdateTotpEnabledAt() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.UpdateTotpEnabledAt() + }) +} + +// ClearTotpEnabledAt clears the value of the "totp_enabled_at" field. +func (u *UserUpsertOne) ClearTotpEnabledAt() *UserUpsertOne { + return u.Update(func(s *UserUpsert) { + s.ClearTotpEnabledAt() + }) +} + // Exec executes the query. func (u *UserUpsertOne) Exec(ctx context.Context) error { if len(u.create.conflict) == 0 { @@ -1393,6 +1590,62 @@ func (u *UserUpsertBulk) UpdateNotes() *UserUpsertBulk { }) } +// SetTotpSecretEncrypted sets the "totp_secret_encrypted" field. +func (u *UserUpsertBulk) SetTotpSecretEncrypted(v string) *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.SetTotpSecretEncrypted(v) + }) +} + +// UpdateTotpSecretEncrypted sets the "totp_secret_encrypted" field to the value that was provided on create. +func (u *UserUpsertBulk) UpdateTotpSecretEncrypted() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.UpdateTotpSecretEncrypted() + }) +} + +// ClearTotpSecretEncrypted clears the value of the "totp_secret_encrypted" field. +func (u *UserUpsertBulk) ClearTotpSecretEncrypted() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.ClearTotpSecretEncrypted() + }) +} + +// SetTotpEnabled sets the "totp_enabled" field. +func (u *UserUpsertBulk) SetTotpEnabled(v bool) *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.SetTotpEnabled(v) + }) +} + +// UpdateTotpEnabled sets the "totp_enabled" field to the value that was provided on create. +func (u *UserUpsertBulk) UpdateTotpEnabled() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.UpdateTotpEnabled() + }) +} + +// SetTotpEnabledAt sets the "totp_enabled_at" field. +func (u *UserUpsertBulk) SetTotpEnabledAt(v time.Time) *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.SetTotpEnabledAt(v) + }) +} + +// UpdateTotpEnabledAt sets the "totp_enabled_at" field to the value that was provided on create. +func (u *UserUpsertBulk) UpdateTotpEnabledAt() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.UpdateTotpEnabledAt() + }) +} + +// ClearTotpEnabledAt clears the value of the "totp_enabled_at" field. +func (u *UserUpsertBulk) ClearTotpEnabledAt() *UserUpsertBulk { + return u.Update(func(s *UserUpsert) { + s.ClearTotpEnabledAt() + }) +} + // Exec executes the query. func (u *UserUpsertBulk) Exec(ctx context.Context) error { if u.create.err != nil { diff --git a/backend/ent/user_query.go b/backend/ent/user_query.go index e66e2dc8..4b56e16f 100644 --- a/backend/ent/user_query.go +++ b/backend/ent/user_query.go @@ -13,6 +13,7 @@ import ( "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" + "github.com/Wei-Shaw/sub2api/ent/announcementread" "github.com/Wei-Shaw/sub2api/ent/apikey" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/predicate" @@ -36,6 +37,7 @@ type UserQuery struct { withRedeemCodes *RedeemCodeQuery withSubscriptions *UserSubscriptionQuery withAssignedSubscriptions *UserSubscriptionQuery + withAnnouncementReads *AnnouncementReadQuery withAllowedGroups *GroupQuery withUsageLogs *UsageLogQuery withAttributeValues *UserAttributeValueQuery @@ -166,6 +168,28 @@ func (_q *UserQuery) QueryAssignedSubscriptions() *UserSubscriptionQuery { return query } +// QueryAnnouncementReads chains the current query on the "announcement_reads" edge. +func (_q *UserQuery) QueryAnnouncementReads() *AnnouncementReadQuery { + query := (&AnnouncementReadClient{config: _q.config}).Query() + query.path = func(ctx context.Context) (fromU *sql.Selector, err error) { + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + selector := _q.sqlQuery(ctx) + if err := selector.Err(); err != nil { + return nil, err + } + step := sqlgraph.NewStep( + sqlgraph.From(user.Table, user.FieldID, selector), + sqlgraph.To(announcementread.Table, announcementread.FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, user.AnnouncementReadsTable, user.AnnouncementReadsColumn), + ) + fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step) + return fromU, nil + } + return query +} + // QueryAllowedGroups chains the current query on the "allowed_groups" edge. func (_q *UserQuery) QueryAllowedGroups() *GroupQuery { query := (&GroupClient{config: _q.config}).Query() @@ -472,6 +496,7 @@ func (_q *UserQuery) Clone() *UserQuery { withRedeemCodes: _q.withRedeemCodes.Clone(), withSubscriptions: _q.withSubscriptions.Clone(), withAssignedSubscriptions: _q.withAssignedSubscriptions.Clone(), + withAnnouncementReads: _q.withAnnouncementReads.Clone(), withAllowedGroups: _q.withAllowedGroups.Clone(), withUsageLogs: _q.withUsageLogs.Clone(), withAttributeValues: _q.withAttributeValues.Clone(), @@ -527,6 +552,17 @@ func (_q *UserQuery) WithAssignedSubscriptions(opts ...func(*UserSubscriptionQue return _q } +// WithAnnouncementReads tells the query-builder to eager-load the nodes that are connected to +// the "announcement_reads" edge. The optional arguments are used to configure the query builder of the edge. +func (_q *UserQuery) WithAnnouncementReads(opts ...func(*AnnouncementReadQuery)) *UserQuery { + query := (&AnnouncementReadClient{config: _q.config}).Query() + for _, opt := range opts { + opt(query) + } + _q.withAnnouncementReads = query + return _q +} + // WithAllowedGroups tells the query-builder to eager-load the nodes that are connected to // the "allowed_groups" edge. The optional arguments are used to configure the query builder of the edge. func (_q *UserQuery) WithAllowedGroups(opts ...func(*GroupQuery)) *UserQuery { @@ -660,11 +696,12 @@ func (_q *UserQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*User, e var ( nodes = []*User{} _spec = _q.querySpec() - loadedTypes = [9]bool{ + loadedTypes = [10]bool{ _q.withAPIKeys != nil, _q.withRedeemCodes != nil, _q.withSubscriptions != nil, _q.withAssignedSubscriptions != nil, + _q.withAnnouncementReads != nil, _q.withAllowedGroups != nil, _q.withUsageLogs != nil, _q.withAttributeValues != nil, @@ -723,6 +760,13 @@ func (_q *UserQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*User, e return nil, err } } + if query := _q.withAnnouncementReads; query != nil { + if err := _q.loadAnnouncementReads(ctx, query, nodes, + func(n *User) { n.Edges.AnnouncementReads = []*AnnouncementRead{} }, + func(n *User, e *AnnouncementRead) { n.Edges.AnnouncementReads = append(n.Edges.AnnouncementReads, e) }); err != nil { + return nil, err + } + } if query := _q.withAllowedGroups; query != nil { if err := _q.loadAllowedGroups(ctx, query, nodes, func(n *User) { n.Edges.AllowedGroups = []*Group{} }, @@ -887,6 +931,36 @@ func (_q *UserQuery) loadAssignedSubscriptions(ctx context.Context, query *UserS } return nil } +func (_q *UserQuery) loadAnnouncementReads(ctx context.Context, query *AnnouncementReadQuery, nodes []*User, init func(*User), assign func(*User, *AnnouncementRead)) error { + fks := make([]driver.Value, 0, len(nodes)) + nodeids := make(map[int64]*User) + for i := range nodes { + fks = append(fks, nodes[i].ID) + nodeids[nodes[i].ID] = nodes[i] + if init != nil { + init(nodes[i]) + } + } + if len(query.ctx.Fields) > 0 { + query.ctx.AppendFieldOnce(announcementread.FieldUserID) + } + query.Where(predicate.AnnouncementRead(func(s *sql.Selector) { + s.Where(sql.InValues(s.C(user.AnnouncementReadsColumn), fks...)) + })) + neighbors, err := query.All(ctx) + if err != nil { + return err + } + for _, n := range neighbors { + fk := n.UserID + node, ok := nodeids[fk] + if !ok { + return fmt.Errorf(`unexpected referenced foreign-key "user_id" returned %v for node %v`, fk, n.ID) + } + assign(node, n) + } + return nil +} func (_q *UserQuery) loadAllowedGroups(ctx context.Context, query *GroupQuery, nodes []*User, init func(*User), assign func(*User, *Group)) error { edgeIDs := make([]driver.Value, len(nodes)) byID := make(map[int64]*User) diff --git a/backend/ent/user_update.go b/backend/ent/user_update.go index cf189fea..80222c92 100644 --- a/backend/ent/user_update.go +++ b/backend/ent/user_update.go @@ -11,6 +11,7 @@ import ( "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" + "github.com/Wei-Shaw/sub2api/ent/announcementread" "github.com/Wei-Shaw/sub2api/ent/apikey" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/predicate" @@ -187,6 +188,60 @@ func (_u *UserUpdate) SetNillableNotes(v *string) *UserUpdate { return _u } +// SetTotpSecretEncrypted sets the "totp_secret_encrypted" field. +func (_u *UserUpdate) SetTotpSecretEncrypted(v string) *UserUpdate { + _u.mutation.SetTotpSecretEncrypted(v) + return _u +} + +// SetNillableTotpSecretEncrypted sets the "totp_secret_encrypted" field if the given value is not nil. +func (_u *UserUpdate) SetNillableTotpSecretEncrypted(v *string) *UserUpdate { + if v != nil { + _u.SetTotpSecretEncrypted(*v) + } + return _u +} + +// ClearTotpSecretEncrypted clears the value of the "totp_secret_encrypted" field. +func (_u *UserUpdate) ClearTotpSecretEncrypted() *UserUpdate { + _u.mutation.ClearTotpSecretEncrypted() + return _u +} + +// SetTotpEnabled sets the "totp_enabled" field. +func (_u *UserUpdate) SetTotpEnabled(v bool) *UserUpdate { + _u.mutation.SetTotpEnabled(v) + return _u +} + +// SetNillableTotpEnabled sets the "totp_enabled" field if the given value is not nil. +func (_u *UserUpdate) SetNillableTotpEnabled(v *bool) *UserUpdate { + if v != nil { + _u.SetTotpEnabled(*v) + } + return _u +} + +// SetTotpEnabledAt sets the "totp_enabled_at" field. +func (_u *UserUpdate) SetTotpEnabledAt(v time.Time) *UserUpdate { + _u.mutation.SetTotpEnabledAt(v) + return _u +} + +// SetNillableTotpEnabledAt sets the "totp_enabled_at" field if the given value is not nil. +func (_u *UserUpdate) SetNillableTotpEnabledAt(v *time.Time) *UserUpdate { + if v != nil { + _u.SetTotpEnabledAt(*v) + } + return _u +} + +// ClearTotpEnabledAt clears the value of the "totp_enabled_at" field. +func (_u *UserUpdate) ClearTotpEnabledAt() *UserUpdate { + _u.mutation.ClearTotpEnabledAt() + return _u +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_u *UserUpdate) AddAPIKeyIDs(ids ...int64) *UserUpdate { _u.mutation.AddAPIKeyIDs(ids...) @@ -247,6 +302,21 @@ func (_u *UserUpdate) AddAssignedSubscriptions(v ...*UserSubscription) *UserUpda return _u.AddAssignedSubscriptionIDs(ids...) } +// AddAnnouncementReadIDs adds the "announcement_reads" edge to the AnnouncementRead entity by IDs. +func (_u *UserUpdate) AddAnnouncementReadIDs(ids ...int64) *UserUpdate { + _u.mutation.AddAnnouncementReadIDs(ids...) + return _u +} + +// AddAnnouncementReads adds the "announcement_reads" edges to the AnnouncementRead entity. +func (_u *UserUpdate) AddAnnouncementReads(v ...*AnnouncementRead) *UserUpdate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.AddAnnouncementReadIDs(ids...) +} + // AddAllowedGroupIDs adds the "allowed_groups" edge to the Group entity by IDs. func (_u *UserUpdate) AddAllowedGroupIDs(ids ...int64) *UserUpdate { _u.mutation.AddAllowedGroupIDs(ids...) @@ -396,6 +466,27 @@ func (_u *UserUpdate) RemoveAssignedSubscriptions(v ...*UserSubscription) *UserU return _u.RemoveAssignedSubscriptionIDs(ids...) } +// ClearAnnouncementReads clears all "announcement_reads" edges to the AnnouncementRead entity. +func (_u *UserUpdate) ClearAnnouncementReads() *UserUpdate { + _u.mutation.ClearAnnouncementReads() + return _u +} + +// RemoveAnnouncementReadIDs removes the "announcement_reads" edge to AnnouncementRead entities by IDs. +func (_u *UserUpdate) RemoveAnnouncementReadIDs(ids ...int64) *UserUpdate { + _u.mutation.RemoveAnnouncementReadIDs(ids...) + return _u +} + +// RemoveAnnouncementReads removes "announcement_reads" edges to AnnouncementRead entities. +func (_u *UserUpdate) RemoveAnnouncementReads(v ...*AnnouncementRead) *UserUpdate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.RemoveAnnouncementReadIDs(ids...) +} + // ClearAllowedGroups clears all "allowed_groups" edges to the Group entity. func (_u *UserUpdate) ClearAllowedGroups() *UserUpdate { _u.mutation.ClearAllowedGroups() @@ -603,6 +694,21 @@ func (_u *UserUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.Notes(); ok { _spec.SetField(user.FieldNotes, field.TypeString, value) } + if value, ok := _u.mutation.TotpSecretEncrypted(); ok { + _spec.SetField(user.FieldTotpSecretEncrypted, field.TypeString, value) + } + if _u.mutation.TotpSecretEncryptedCleared() { + _spec.ClearField(user.FieldTotpSecretEncrypted, field.TypeString) + } + if value, ok := _u.mutation.TotpEnabled(); ok { + _spec.SetField(user.FieldTotpEnabled, field.TypeBool, value) + } + if value, ok := _u.mutation.TotpEnabledAt(); ok { + _spec.SetField(user.FieldTotpEnabledAt, field.TypeTime, value) + } + if _u.mutation.TotpEnabledAtCleared() { + _spec.ClearField(user.FieldTotpEnabledAt, field.TypeTime) + } if _u.mutation.APIKeysCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -783,6 +889,51 @@ func (_u *UserUpdate) sqlSave(ctx context.Context) (_node int, err error) { } _spec.Edges.Add = append(_spec.Edges.Add, edge) } + if _u.mutation.AnnouncementReadsCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.AnnouncementReadsTable, + Columns: []string{user.AnnouncementReadsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(announcementread.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.RemovedAnnouncementReadsIDs(); len(nodes) > 0 && !_u.mutation.AnnouncementReadsCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.AnnouncementReadsTable, + Columns: []string{user.AnnouncementReadsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(announcementread.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.AnnouncementReadsIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.AnnouncementReadsTable, + Columns: []string{user.AnnouncementReadsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(announcementread.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } if _u.mutation.AllowedGroupsCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.M2M, @@ -1147,6 +1298,60 @@ func (_u *UserUpdateOne) SetNillableNotes(v *string) *UserUpdateOne { return _u } +// SetTotpSecretEncrypted sets the "totp_secret_encrypted" field. +func (_u *UserUpdateOne) SetTotpSecretEncrypted(v string) *UserUpdateOne { + _u.mutation.SetTotpSecretEncrypted(v) + return _u +} + +// SetNillableTotpSecretEncrypted sets the "totp_secret_encrypted" field if the given value is not nil. +func (_u *UserUpdateOne) SetNillableTotpSecretEncrypted(v *string) *UserUpdateOne { + if v != nil { + _u.SetTotpSecretEncrypted(*v) + } + return _u +} + +// ClearTotpSecretEncrypted clears the value of the "totp_secret_encrypted" field. +func (_u *UserUpdateOne) ClearTotpSecretEncrypted() *UserUpdateOne { + _u.mutation.ClearTotpSecretEncrypted() + return _u +} + +// SetTotpEnabled sets the "totp_enabled" field. +func (_u *UserUpdateOne) SetTotpEnabled(v bool) *UserUpdateOne { + _u.mutation.SetTotpEnabled(v) + return _u +} + +// SetNillableTotpEnabled sets the "totp_enabled" field if the given value is not nil. +func (_u *UserUpdateOne) SetNillableTotpEnabled(v *bool) *UserUpdateOne { + if v != nil { + _u.SetTotpEnabled(*v) + } + return _u +} + +// SetTotpEnabledAt sets the "totp_enabled_at" field. +func (_u *UserUpdateOne) SetTotpEnabledAt(v time.Time) *UserUpdateOne { + _u.mutation.SetTotpEnabledAt(v) + return _u +} + +// SetNillableTotpEnabledAt sets the "totp_enabled_at" field if the given value is not nil. +func (_u *UserUpdateOne) SetNillableTotpEnabledAt(v *time.Time) *UserUpdateOne { + if v != nil { + _u.SetTotpEnabledAt(*v) + } + return _u +} + +// ClearTotpEnabledAt clears the value of the "totp_enabled_at" field. +func (_u *UserUpdateOne) ClearTotpEnabledAt() *UserUpdateOne { + _u.mutation.ClearTotpEnabledAt() + return _u +} + // AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs. func (_u *UserUpdateOne) AddAPIKeyIDs(ids ...int64) *UserUpdateOne { _u.mutation.AddAPIKeyIDs(ids...) @@ -1207,6 +1412,21 @@ func (_u *UserUpdateOne) AddAssignedSubscriptions(v ...*UserSubscription) *UserU return _u.AddAssignedSubscriptionIDs(ids...) } +// AddAnnouncementReadIDs adds the "announcement_reads" edge to the AnnouncementRead entity by IDs. +func (_u *UserUpdateOne) AddAnnouncementReadIDs(ids ...int64) *UserUpdateOne { + _u.mutation.AddAnnouncementReadIDs(ids...) + return _u +} + +// AddAnnouncementReads adds the "announcement_reads" edges to the AnnouncementRead entity. +func (_u *UserUpdateOne) AddAnnouncementReads(v ...*AnnouncementRead) *UserUpdateOne { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.AddAnnouncementReadIDs(ids...) +} + // AddAllowedGroupIDs adds the "allowed_groups" edge to the Group entity by IDs. func (_u *UserUpdateOne) AddAllowedGroupIDs(ids ...int64) *UserUpdateOne { _u.mutation.AddAllowedGroupIDs(ids...) @@ -1356,6 +1576,27 @@ func (_u *UserUpdateOne) RemoveAssignedSubscriptions(v ...*UserSubscription) *Us return _u.RemoveAssignedSubscriptionIDs(ids...) } +// ClearAnnouncementReads clears all "announcement_reads" edges to the AnnouncementRead entity. +func (_u *UserUpdateOne) ClearAnnouncementReads() *UserUpdateOne { + _u.mutation.ClearAnnouncementReads() + return _u +} + +// RemoveAnnouncementReadIDs removes the "announcement_reads" edge to AnnouncementRead entities by IDs. +func (_u *UserUpdateOne) RemoveAnnouncementReadIDs(ids ...int64) *UserUpdateOne { + _u.mutation.RemoveAnnouncementReadIDs(ids...) + return _u +} + +// RemoveAnnouncementReads removes "announcement_reads" edges to AnnouncementRead entities. +func (_u *UserUpdateOne) RemoveAnnouncementReads(v ...*AnnouncementRead) *UserUpdateOne { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.RemoveAnnouncementReadIDs(ids...) +} + // ClearAllowedGroups clears all "allowed_groups" edges to the Group entity. func (_u *UserUpdateOne) ClearAllowedGroups() *UserUpdateOne { _u.mutation.ClearAllowedGroups() @@ -1593,6 +1834,21 @@ func (_u *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) { if value, ok := _u.mutation.Notes(); ok { _spec.SetField(user.FieldNotes, field.TypeString, value) } + if value, ok := _u.mutation.TotpSecretEncrypted(); ok { + _spec.SetField(user.FieldTotpSecretEncrypted, field.TypeString, value) + } + if _u.mutation.TotpSecretEncryptedCleared() { + _spec.ClearField(user.FieldTotpSecretEncrypted, field.TypeString) + } + if value, ok := _u.mutation.TotpEnabled(); ok { + _spec.SetField(user.FieldTotpEnabled, field.TypeBool, value) + } + if value, ok := _u.mutation.TotpEnabledAt(); ok { + _spec.SetField(user.FieldTotpEnabledAt, field.TypeTime, value) + } + if _u.mutation.TotpEnabledAtCleared() { + _spec.ClearField(user.FieldTotpEnabledAt, field.TypeTime) + } if _u.mutation.APIKeysCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -1773,6 +2029,51 @@ func (_u *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) { } _spec.Edges.Add = append(_spec.Edges.Add, edge) } + if _u.mutation.AnnouncementReadsCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.AnnouncementReadsTable, + Columns: []string{user.AnnouncementReadsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(announcementread.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.RemovedAnnouncementReadsIDs(); len(nodes) > 0 && !_u.mutation.AnnouncementReadsCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.AnnouncementReadsTable, + Columns: []string{user.AnnouncementReadsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(announcementread.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.AnnouncementReadsIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.AnnouncementReadsTable, + Columns: []string{user.AnnouncementReadsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(announcementread.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } if _u.mutation.AllowedGroupsCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.M2M, diff --git a/backend/go.mod b/backend/go.mod index fd429b07..4c3e6246 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module github.com/Wei-Shaw/sub2api -go 1.25.5 +go 1.25.6 require ( entgo.io/ent v0.14.5 @@ -37,6 +37,7 @@ require ( github.com/andybalholm/brotli v1.2.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/bmatcuk/doublestar v1.3.4 // indirect + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/bytedance/sonic v1.9.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -106,6 +107,7 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/pquerna/otp v1.5.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.57.1 // indirect github.com/refraction-networking/utls v1.8.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index aa10718c..0addb5bb 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -20,6 +20,8 @@ github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -217,6 +219,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 7d1b10e8..1cab3111 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -47,6 +47,7 @@ type Config struct { Redis RedisConfig `mapstructure:"redis"` Ops OpsConfig `mapstructure:"ops"` JWT JWTConfig `mapstructure:"jwt"` + Totp TotpConfig `mapstructure:"totp"` LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"` Default DefaultConfig `mapstructure:"default"` RateLimit RateLimitConfig `mapstructure:"rate_limit"` @@ -479,6 +480,8 @@ type RedisConfig struct { PoolSize int `mapstructure:"pool_size"` // MinIdleConns: 最小空闲连接数,保持热连接减少冷启动延迟 MinIdleConns int `mapstructure:"min_idle_conns"` + // EnableTLS: 是否启用 TLS/SSL 连接 + EnableTLS bool `mapstructure:"enable_tls"` } func (r *RedisConfig) Address() string { @@ -531,6 +534,16 @@ type JWTConfig struct { ExpireHour int `mapstructure:"expire_hour"` } +// TotpConfig TOTP 双因素认证配置 +type TotpConfig struct { + // EncryptionKey 用于加密 TOTP 密钥的 AES-256 密钥(32 字节 hex 编码) + // 如果为空,将自动生成一个随机密钥(仅适用于开发环境) + EncryptionKey string `mapstructure:"encryption_key"` + // EncryptionKeyConfigured 标记加密密钥是否为手动配置(非自动生成) + // 只有手动配置了密钥才允许在管理后台启用 TOTP 功能 + EncryptionKeyConfigured bool `mapstructure:"-"` +} + type TurnstileConfig struct { Required bool `mapstructure:"required"` } @@ -691,6 +704,20 @@ func Load() (*Config, error) { log.Println("Warning: JWT secret auto-generated. Consider setting a fixed secret for production.") } + // Auto-generate TOTP encryption key if not set (32 bytes = 64 hex chars for AES-256) + cfg.Totp.EncryptionKey = strings.TrimSpace(cfg.Totp.EncryptionKey) + if cfg.Totp.EncryptionKey == "" { + key, err := generateJWTSecret(32) // Reuse the same random generation function + if err != nil { + return nil, fmt.Errorf("generate totp encryption key error: %w", err) + } + cfg.Totp.EncryptionKey = key + cfg.Totp.EncryptionKeyConfigured = false + log.Println("Warning: TOTP encryption key auto-generated. Consider setting a fixed key for production.") + } else { + cfg.Totp.EncryptionKeyConfigured = true + } + if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("validate config error: %w", err) } @@ -802,6 +829,7 @@ func setDefaults() { viper.SetDefault("redis.write_timeout_seconds", 3) viper.SetDefault("redis.pool_size", 128) viper.SetDefault("redis.min_idle_conns", 10) + viper.SetDefault("redis.enable_tls", false) // Ops (vNext) viper.SetDefault("ops.enabled", true) @@ -821,6 +849,9 @@ func setDefaults() { viper.SetDefault("jwt.secret", "") viper.SetDefault("jwt.expire_hour", 24) + // TOTP + viper.SetDefault("totp.encryption_key", "") + // Default // Admin credentials are created via the setup flow (web wizard / CLI / AUTO_SETUP). // Do not ship fixed defaults here to avoid insecure "known credentials" in production. diff --git a/backend/internal/domain/announcement.go b/backend/internal/domain/announcement.go new file mode 100644 index 00000000..7dc9a9cc --- /dev/null +++ b/backend/internal/domain/announcement.go @@ -0,0 +1,226 @@ +package domain + +import ( + "strings" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +const ( + AnnouncementStatusDraft = "draft" + AnnouncementStatusActive = "active" + AnnouncementStatusArchived = "archived" +) + +const ( + AnnouncementConditionTypeSubscription = "subscription" + AnnouncementConditionTypeBalance = "balance" +) + +const ( + AnnouncementOperatorIn = "in" + AnnouncementOperatorGT = "gt" + AnnouncementOperatorGTE = "gte" + AnnouncementOperatorLT = "lt" + AnnouncementOperatorLTE = "lte" + AnnouncementOperatorEQ = "eq" +) + +var ( + ErrAnnouncementNotFound = infraerrors.NotFound("ANNOUNCEMENT_NOT_FOUND", "announcement not found") + ErrAnnouncementInvalidTarget = infraerrors.BadRequest("ANNOUNCEMENT_INVALID_TARGET", "invalid announcement targeting rules") +) + +type AnnouncementTargeting struct { + // AnyOf 表示 OR:任意一个条件组满足即可展示。 + AnyOf []AnnouncementConditionGroup `json:"any_of,omitempty"` +} + +type AnnouncementConditionGroup struct { + // AllOf 表示 AND:组内所有条件都满足才算命中该组。 + AllOf []AnnouncementCondition `json:"all_of,omitempty"` +} + +type AnnouncementCondition struct { + // Type: subscription | balance + Type string `json:"type"` + + // Operator: + // - subscription: in + // - balance: gt/gte/lt/lte/eq + Operator string `json:"operator"` + + // subscription 条件:匹配的订阅套餐(group_id) + GroupIDs []int64 `json:"group_ids,omitempty"` + + // balance 条件:比较阈值 + Value float64 `json:"value,omitempty"` +} + +func (t AnnouncementTargeting) Matches(balance float64, activeSubscriptionGroupIDs map[int64]struct{}) bool { + // 空规则:展示给所有用户 + if len(t.AnyOf) == 0 { + return true + } + + for _, group := range t.AnyOf { + if len(group.AllOf) == 0 { + // 空条件组不命中(避免 OR 中出现无条件 “全命中”) + continue + } + allMatched := true + for _, cond := range group.AllOf { + if !cond.Matches(balance, activeSubscriptionGroupIDs) { + allMatched = false + break + } + } + if allMatched { + return true + } + } + + return false +} + +func (c AnnouncementCondition) Matches(balance float64, activeSubscriptionGroupIDs map[int64]struct{}) bool { + switch c.Type { + case AnnouncementConditionTypeSubscription: + if c.Operator != AnnouncementOperatorIn { + return false + } + if len(c.GroupIDs) == 0 { + return false + } + if len(activeSubscriptionGroupIDs) == 0 { + return false + } + for _, gid := range c.GroupIDs { + if _, ok := activeSubscriptionGroupIDs[gid]; ok { + return true + } + } + return false + + case AnnouncementConditionTypeBalance: + switch c.Operator { + case AnnouncementOperatorGT: + return balance > c.Value + case AnnouncementOperatorGTE: + return balance >= c.Value + case AnnouncementOperatorLT: + return balance < c.Value + case AnnouncementOperatorLTE: + return balance <= c.Value + case AnnouncementOperatorEQ: + return balance == c.Value + default: + return false + } + + default: + return false + } +} + +func (t AnnouncementTargeting) NormalizeAndValidate() (AnnouncementTargeting, error) { + normalized := AnnouncementTargeting{AnyOf: make([]AnnouncementConditionGroup, 0, len(t.AnyOf))} + + // 允许空 targeting(展示给所有用户) + if len(t.AnyOf) == 0 { + return normalized, nil + } + + if len(t.AnyOf) > 50 { + return AnnouncementTargeting{}, ErrAnnouncementInvalidTarget + } + + for _, g := range t.AnyOf { + if len(g.AllOf) == 0 { + return AnnouncementTargeting{}, ErrAnnouncementInvalidTarget + } + if len(g.AllOf) > 50 { + return AnnouncementTargeting{}, ErrAnnouncementInvalidTarget + } + + group := AnnouncementConditionGroup{AllOf: make([]AnnouncementCondition, 0, len(g.AllOf))} + for _, c := range g.AllOf { + cond := AnnouncementCondition{ + Type: strings.TrimSpace(c.Type), + Operator: strings.TrimSpace(c.Operator), + Value: c.Value, + } + for _, gid := range c.GroupIDs { + if gid <= 0 { + return AnnouncementTargeting{}, ErrAnnouncementInvalidTarget + } + cond.GroupIDs = append(cond.GroupIDs, gid) + } + + if err := cond.validate(); err != nil { + return AnnouncementTargeting{}, err + } + group.AllOf = append(group.AllOf, cond) + } + + normalized.AnyOf = append(normalized.AnyOf, group) + } + + return normalized, nil +} + +func (c AnnouncementCondition) validate() error { + switch c.Type { + case AnnouncementConditionTypeSubscription: + if c.Operator != AnnouncementOperatorIn { + return ErrAnnouncementInvalidTarget + } + if len(c.GroupIDs) == 0 { + return ErrAnnouncementInvalidTarget + } + return nil + + case AnnouncementConditionTypeBalance: + switch c.Operator { + case AnnouncementOperatorGT, AnnouncementOperatorGTE, AnnouncementOperatorLT, AnnouncementOperatorLTE, AnnouncementOperatorEQ: + return nil + default: + return ErrAnnouncementInvalidTarget + } + + default: + return ErrAnnouncementInvalidTarget + } +} + +type Announcement struct { + ID int64 + Title string + Content string + Status string + Targeting AnnouncementTargeting + StartsAt *time.Time + EndsAt *time.Time + CreatedBy *int64 + UpdatedBy *int64 + CreatedAt time.Time + UpdatedAt time.Time +} + +func (a *Announcement) IsActiveAt(now time.Time) bool { + if a == nil { + return false + } + if a.Status != AnnouncementStatusActive { + return false + } + if a.StartsAt != nil && now.Before(*a.StartsAt) { + return false + } + if a.EndsAt != nil && !now.Before(*a.EndsAt) { + // ends_at 语义:到点即下线 + return false + } + return true +} diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go new file mode 100644 index 00000000..1aee7777 --- /dev/null +++ b/backend/internal/domain/constants.go @@ -0,0 +1,66 @@ +package domain + +// Status constants +const ( + StatusActive = "active" + StatusDisabled = "disabled" + StatusError = "error" + StatusUnused = "unused" + StatusUsed = "used" + StatusExpired = "expired" +) + +// Role constants +const ( + RoleAdmin = "admin" + RoleUser = "user" +) + +// Platform constants +const ( + PlatformAnthropic = "anthropic" + PlatformOpenAI = "openai" + PlatformGemini = "gemini" + PlatformAntigravity = "antigravity" + PlatformSora = "sora" +) + +// Account type constants +const ( + AccountTypeOAuth = "oauth" // OAuth类型账号(full scope: profile + inference) + AccountTypeSetupToken = "setup-token" // Setup Token类型账号(inference only scope) + AccountTypeAPIKey = "apikey" // API Key类型账号 +) + +// Redeem type constants +const ( + RedeemTypeBalance = "balance" + RedeemTypeConcurrency = "concurrency" + RedeemTypeSubscription = "subscription" + RedeemTypeInvitation = "invitation" +) + +// PromoCode status constants +const ( + PromoCodeStatusActive = "active" + PromoCodeStatusDisabled = "disabled" +) + +// Admin adjustment type constants +const ( + AdjustmentTypeAdminBalance = "admin_balance" // 管理员调整余额 + AdjustmentTypeAdminConcurrency = "admin_concurrency" // 管理员调整并发数 +) + +// Group subscription type constants +const ( + SubscriptionTypeStandard = "standard" // 标准计费模式(按余额扣费) + SubscriptionTypeSubscription = "subscription" // 订阅模式(按限额控制) +) + +// Subscription status constants +const ( + SubscriptionStatusActive = "active" + SubscriptionStatusExpired = "expired" + SubscriptionStatusSuspended = "suspended" +) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 188aa0ec..bbf5d026 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -547,9 +547,18 @@ func (h *AccountHandler) Refresh(c *gin.Context) { } } - // 如果 project_id 获取失败,先更新凭证,再标记账户为 error + // 特殊处理 project_id:如果新值为空但旧值非空,保留旧值 + // 这确保了即使 LoadCodeAssist 失败,project_id 也不会丢失 + if newProjectID, _ := newCredentials["project_id"].(string); newProjectID == "" { + if oldProjectID := strings.TrimSpace(account.GetCredential("project_id")); oldProjectID != "" { + newCredentials["project_id"] = oldProjectID + } + } + + // 如果 project_id 获取失败,更新凭证但不标记为 error + // LoadCodeAssist 失败可能是临时网络问题,给它机会在下次自动刷新时重试 if tokenInfo.ProjectIDMissing { - // 先更新凭证 + // 先更新凭证(token 本身刷新成功了) _, updateErr := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{ Credentials: newCredentials, }) @@ -557,14 +566,10 @@ func (h *AccountHandler) Refresh(c *gin.Context) { response.InternalError(c, "Failed to update credentials: "+updateErr.Error()) return } - // 标记账户为 error - if setErr := h.adminService.SetAccountError(c.Request.Context(), accountID, "missing_project_id: 账户缺少project id,可能无法使用Antigravity"); setErr != nil { - response.InternalError(c, "Failed to set account error: "+setErr.Error()) - return - } + // 不标记为 error,只返回警告信息 response.Success(c, gin.H{ - "message": "Token refreshed but project_id is missing, account marked as error", - "warning": "missing_project_id", + "message": "Token refreshed successfully, but project_id could not be retrieved (will retry automatically)", + "warning": "missing_project_id_temporary", }) return } diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index b820a3fb..ea2ea963 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -290,5 +290,9 @@ func (s *stubAdminService) ExpireRedeemCode(ctx context.Context, id int64) (*ser return &code, nil } +func (s *stubAdminService) GetUserBalanceHistory(ctx context.Context, userID int64, page, pageSize int, codeType string) ([]service.RedeemCode, int64, float64, error) { + return s.redeems, int64(len(s.redeems)), 100.0, nil +} + // Ensure stub implements interface. var _ service.AdminService = (*stubAdminService)(nil) diff --git a/backend/internal/handler/admin/announcement_handler.go b/backend/internal/handler/admin/announcement_handler.go new file mode 100644 index 00000000..0b5d0fbc --- /dev/null +++ b/backend/internal/handler/admin/announcement_handler.go @@ -0,0 +1,246 @@ +package admin + +import ( + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/handler/dto" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" +) + +// AnnouncementHandler handles admin announcement management +type AnnouncementHandler struct { + announcementService *service.AnnouncementService +} + +// NewAnnouncementHandler creates a new admin announcement handler +func NewAnnouncementHandler(announcementService *service.AnnouncementService) *AnnouncementHandler { + return &AnnouncementHandler{ + announcementService: announcementService, + } +} + +type CreateAnnouncementRequest struct { + Title string `json:"title" binding:"required"` + Content string `json:"content" binding:"required"` + Status string `json:"status" binding:"omitempty,oneof=draft active archived"` + Targeting service.AnnouncementTargeting `json:"targeting"` + StartsAt *int64 `json:"starts_at"` // Unix seconds, 0/empty = immediate + EndsAt *int64 `json:"ends_at"` // Unix seconds, 0/empty = never +} + +type UpdateAnnouncementRequest struct { + Title *string `json:"title"` + Content *string `json:"content"` + Status *string `json:"status" binding:"omitempty,oneof=draft active archived"` + Targeting *service.AnnouncementTargeting `json:"targeting"` + StartsAt *int64 `json:"starts_at"` // Unix seconds, 0 = clear + EndsAt *int64 `json:"ends_at"` // Unix seconds, 0 = clear +} + +// List handles listing announcements with filters +// GET /api/v1/admin/announcements +func (h *AnnouncementHandler) List(c *gin.Context) { + page, pageSize := response.ParsePagination(c) + status := strings.TrimSpace(c.Query("status")) + search := strings.TrimSpace(c.Query("search")) + if len(search) > 200 { + search = search[:200] + } + + params := pagination.PaginationParams{ + Page: page, + PageSize: pageSize, + } + + items, paginationResult, err := h.announcementService.List( + c.Request.Context(), + params, + service.AnnouncementListFilters{Status: status, Search: search}, + ) + if err != nil { + response.ErrorFrom(c, err) + return + } + + out := make([]dto.Announcement, 0, len(items)) + for i := range items { + out = append(out, *dto.AnnouncementFromService(&items[i])) + } + response.Paginated(c, out, paginationResult.Total, page, pageSize) +} + +// GetByID handles getting an announcement by ID +// GET /api/v1/admin/announcements/:id +func (h *AnnouncementHandler) GetByID(c *gin.Context) { + announcementID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || announcementID <= 0 { + response.BadRequest(c, "Invalid announcement ID") + return + } + + item, err := h.announcementService.GetByID(c.Request.Context(), announcementID) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.AnnouncementFromService(item)) +} + +// Create handles creating a new announcement +// POST /api/v1/admin/announcements +func (h *AnnouncementHandler) Create(c *gin.Context) { + var req CreateAnnouncementRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not found in context") + return + } + + input := &service.CreateAnnouncementInput{ + Title: req.Title, + Content: req.Content, + Status: req.Status, + Targeting: req.Targeting, + ActorID: &subject.UserID, + } + + if req.StartsAt != nil && *req.StartsAt > 0 { + t := time.Unix(*req.StartsAt, 0) + input.StartsAt = &t + } + if req.EndsAt != nil && *req.EndsAt > 0 { + t := time.Unix(*req.EndsAt, 0) + input.EndsAt = &t + } + + created, err := h.announcementService.Create(c.Request.Context(), input) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.AnnouncementFromService(created)) +} + +// Update handles updating an announcement +// PUT /api/v1/admin/announcements/:id +func (h *AnnouncementHandler) Update(c *gin.Context) { + announcementID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || announcementID <= 0 { + response.BadRequest(c, "Invalid announcement ID") + return + } + + var req UpdateAnnouncementRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not found in context") + return + } + + input := &service.UpdateAnnouncementInput{ + Title: req.Title, + Content: req.Content, + Status: req.Status, + Targeting: req.Targeting, + ActorID: &subject.UserID, + } + + if req.StartsAt != nil { + if *req.StartsAt == 0 { + var cleared *time.Time = nil + input.StartsAt = &cleared + } else { + t := time.Unix(*req.StartsAt, 0) + ptr := &t + input.StartsAt = &ptr + } + } + + if req.EndsAt != nil { + if *req.EndsAt == 0 { + var cleared *time.Time = nil + input.EndsAt = &cleared + } else { + t := time.Unix(*req.EndsAt, 0) + ptr := &t + input.EndsAt = &ptr + } + } + + updated, err := h.announcementService.Update(c.Request.Context(), announcementID, input) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.AnnouncementFromService(updated)) +} + +// Delete handles deleting an announcement +// DELETE /api/v1/admin/announcements/:id +func (h *AnnouncementHandler) Delete(c *gin.Context) { + announcementID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || announcementID <= 0 { + response.BadRequest(c, "Invalid announcement ID") + return + } + + if err := h.announcementService.Delete(c.Request.Context(), announcementID); err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{"message": "Announcement deleted successfully"}) +} + +// ListReadStatus handles listing users read status for an announcement +// GET /api/v1/admin/announcements/:id/read-status +func (h *AnnouncementHandler) ListReadStatus(c *gin.Context) { + announcementID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || announcementID <= 0 { + response.BadRequest(c, "Invalid announcement ID") + return + } + + page, pageSize := response.ParsePagination(c) + params := pagination.PaginationParams{ + Page: page, + PageSize: pageSize, + } + search := strings.TrimSpace(c.Query("search")) + if len(search) > 200 { + search = search[:200] + } + + items, paginationResult, err := h.announcementService.ListUserReadStatus( + c.Request.Context(), + announcementID, + params, + search, + ) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Paginated(c, items, paginationResult.Total, page, pageSize) +} diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 328c8fce..f7f6c893 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -47,6 +47,8 @@ type CreateGroupRequest struct { // 模型路由配置(仅 anthropic 平台使用) ModelRouting map[string][]int64 `json:"model_routing"` ModelRoutingEnabled bool `json:"model_routing_enabled"` + // 从指定分组复制账号(创建后自动绑定) + CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"` } // UpdateGroupRequest represents update group request @@ -74,6 +76,8 @@ type UpdateGroupRequest struct { // 模型路由配置(仅 anthropic 平台使用) ModelRouting map[string][]int64 `json:"model_routing"` ModelRoutingEnabled *bool `json:"model_routing_enabled"` + // 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号) + CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"` } // List handles listing all groups with pagination @@ -183,6 +187,7 @@ func (h *GroupHandler) Create(c *gin.Context) { FallbackGroupID: req.FallbackGroupID, ModelRouting: req.ModelRouting, ModelRoutingEnabled: req.ModelRoutingEnabled, + CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs, }) if err != nil { response.ErrorFrom(c, err) @@ -229,6 +234,7 @@ func (h *GroupHandler) Update(c *gin.Context) { FallbackGroupID: req.FallbackGroupID, ModelRouting: req.ModelRouting, ModelRoutingEnabled: req.ModelRoutingEnabled, + CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs, }) if err != nil { response.ErrorFrom(c, err) diff --git a/backend/internal/handler/admin/redeem_handler.go b/backend/internal/handler/admin/redeem_handler.go index f1b68334..e229385f 100644 --- a/backend/internal/handler/admin/redeem_handler.go +++ b/backend/internal/handler/admin/redeem_handler.go @@ -29,7 +29,7 @@ func NewRedeemHandler(adminService service.AdminService) *RedeemHandler { // GenerateRedeemCodesRequest represents generate redeem codes request type GenerateRedeemCodesRequest struct { Count int `json:"count" binding:"required,min=1,max=100"` - Type string `json:"type" binding:"required,oneof=balance concurrency subscription"` + Type string `json:"type" binding:"required,oneof=balance concurrency subscription invitation"` Value float64 `json:"value" binding:"min=0"` GroupID *int64 `json:"group_id"` // 订阅类型必填 ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // 订阅类型使用,默认30天,最大100年 diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 0e3e0a2f..1e723ee5 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -48,6 +48,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { RegistrationEnabled: settings.RegistrationEnabled, EmailVerifyEnabled: settings.EmailVerifyEnabled, PromoCodeEnabled: settings.PromoCodeEnabled, + PasswordResetEnabled: settings.PasswordResetEnabled, + InvitationCodeEnabled: settings.InvitationCodeEnabled, + TotpEnabled: settings.TotpEnabled, + TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), SMTPHost: settings.SMTPHost, SMTPPort: settings.SMTPPort, SMTPUsername: settings.SMTPUsername, @@ -70,6 +74,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { DocURL: settings.DocURL, HomeContent: settings.HomeContent, HideCcsImportButton: settings.HideCcsImportButton, + PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, + PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, DefaultConcurrency: settings.DefaultConcurrency, DefaultBalance: settings.DefaultBalance, EnableModelFallback: settings.EnableModelFallback, @@ -89,9 +95,12 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { // UpdateSettingsRequest 更新设置请求 type UpdateSettingsRequest struct { // 注册设置 - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - PromoCodeEnabled bool `json:"promo_code_enabled"` + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` + InvitationCodeEnabled bool `json:"invitation_code_enabled"` + TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 // 邮件服务设置 SMTPHost string `json:"smtp_host"` @@ -114,14 +123,16 @@ type UpdateSettingsRequest struct { LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"` // OEM设置 - SiteName string `json:"site_name"` - SiteLogo string `json:"site_logo"` - SiteSubtitle string `json:"site_subtitle"` - APIBaseURL string `json:"api_base_url"` - ContactInfo string `json:"contact_info"` - DocURL string `json:"doc_url"` - HomeContent string `json:"home_content"` - HideCcsImportButton bool `json:"hide_ccs_import_button"` + SiteName string `json:"site_name"` + SiteLogo string `json:"site_logo"` + SiteSubtitle string `json:"site_subtitle"` + APIBaseURL string `json:"api_base_url"` + ContactInfo string `json:"contact_info"` + DocURL string `json:"doc_url"` + HomeContent string `json:"home_content"` + HideCcsImportButton bool `json:"hide_ccs_import_button"` + PurchaseSubscriptionEnabled *bool `json:"purchase_subscription_enabled"` + PurchaseSubscriptionURL *string `json:"purchase_subscription_url"` // 默认配置 DefaultConcurrency int `json:"default_concurrency"` @@ -198,6 +209,16 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } } + // TOTP 双因素认证参数验证 + // 只有手动配置了加密密钥才允许启用 TOTP 功能 + if req.TotpEnabled && !previousSettings.TotpEnabled { + // 尝试启用 TOTP,检查加密密钥是否已手动配置 + if !h.settingService.IsTotpEncryptionKeyConfigured() { + response.BadRequest(c, "Cannot enable TOTP: TOTP_ENCRYPTION_KEY environment variable must be configured first. Generate a key with 'openssl rand -hex 32' and set it in your environment.") + return + } + } + // LinuxDo Connect 参数验证 if req.LinuxDoConnectEnabled { req.LinuxDoConnectClientID = strings.TrimSpace(req.LinuxDoConnectClientID) @@ -227,6 +248,34 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } } + // “购买订阅”页面配置验证 + purchaseEnabled := previousSettings.PurchaseSubscriptionEnabled + if req.PurchaseSubscriptionEnabled != nil { + purchaseEnabled = *req.PurchaseSubscriptionEnabled + } + purchaseURL := previousSettings.PurchaseSubscriptionURL + if req.PurchaseSubscriptionURL != nil { + purchaseURL = strings.TrimSpace(*req.PurchaseSubscriptionURL) + } + + // - 启用时要求 URL 合法且非空 + // - 禁用时允许为空;若提供了 URL 也做基本校验,避免误配置 + if purchaseEnabled { + if purchaseURL == "" { + response.BadRequest(c, "Purchase Subscription URL is required when enabled") + return + } + if err := config.ValidateAbsoluteHTTPURL(purchaseURL); err != nil { + response.BadRequest(c, "Purchase Subscription URL must be an absolute http(s) URL") + return + } + } else if purchaseURL != "" { + if err := config.ValidateAbsoluteHTTPURL(purchaseURL); err != nil { + response.BadRequest(c, "Purchase Subscription URL must be an absolute http(s) URL") + return + } + } + // Ops metrics collector interval validation (seconds). if req.OpsMetricsIntervalSeconds != nil { v := *req.OpsMetricsIntervalSeconds @@ -240,40 +289,45 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } settings := &service.SystemSettings{ - RegistrationEnabled: req.RegistrationEnabled, - EmailVerifyEnabled: req.EmailVerifyEnabled, - PromoCodeEnabled: req.PromoCodeEnabled, - SMTPHost: req.SMTPHost, - SMTPPort: req.SMTPPort, - SMTPUsername: req.SMTPUsername, - SMTPPassword: req.SMTPPassword, - SMTPFrom: req.SMTPFrom, - SMTPFromName: req.SMTPFromName, - SMTPUseTLS: req.SMTPUseTLS, - TurnstileEnabled: req.TurnstileEnabled, - TurnstileSiteKey: req.TurnstileSiteKey, - TurnstileSecretKey: req.TurnstileSecretKey, - LinuxDoConnectEnabled: req.LinuxDoConnectEnabled, - LinuxDoConnectClientID: req.LinuxDoConnectClientID, - LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret, - LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL, - SiteName: req.SiteName, - SiteLogo: req.SiteLogo, - SiteSubtitle: req.SiteSubtitle, - APIBaseURL: req.APIBaseURL, - ContactInfo: req.ContactInfo, - DocURL: req.DocURL, - HomeContent: req.HomeContent, - HideCcsImportButton: req.HideCcsImportButton, - DefaultConcurrency: req.DefaultConcurrency, - DefaultBalance: req.DefaultBalance, - EnableModelFallback: req.EnableModelFallback, - FallbackModelAnthropic: req.FallbackModelAnthropic, - FallbackModelOpenAI: req.FallbackModelOpenAI, - FallbackModelGemini: req.FallbackModelGemini, - FallbackModelAntigravity: req.FallbackModelAntigravity, - EnableIdentityPatch: req.EnableIdentityPatch, - IdentityPatchPrompt: req.IdentityPatchPrompt, + RegistrationEnabled: req.RegistrationEnabled, + EmailVerifyEnabled: req.EmailVerifyEnabled, + PromoCodeEnabled: req.PromoCodeEnabled, + PasswordResetEnabled: req.PasswordResetEnabled, + InvitationCodeEnabled: req.InvitationCodeEnabled, + TotpEnabled: req.TotpEnabled, + SMTPHost: req.SMTPHost, + SMTPPort: req.SMTPPort, + SMTPUsername: req.SMTPUsername, + SMTPPassword: req.SMTPPassword, + SMTPFrom: req.SMTPFrom, + SMTPFromName: req.SMTPFromName, + SMTPUseTLS: req.SMTPUseTLS, + TurnstileEnabled: req.TurnstileEnabled, + TurnstileSiteKey: req.TurnstileSiteKey, + TurnstileSecretKey: req.TurnstileSecretKey, + LinuxDoConnectEnabled: req.LinuxDoConnectEnabled, + LinuxDoConnectClientID: req.LinuxDoConnectClientID, + LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret, + LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL, + SiteName: req.SiteName, + SiteLogo: req.SiteLogo, + SiteSubtitle: req.SiteSubtitle, + APIBaseURL: req.APIBaseURL, + ContactInfo: req.ContactInfo, + DocURL: req.DocURL, + HomeContent: req.HomeContent, + HideCcsImportButton: req.HideCcsImportButton, + PurchaseSubscriptionEnabled: purchaseEnabled, + PurchaseSubscriptionURL: purchaseURL, + DefaultConcurrency: req.DefaultConcurrency, + DefaultBalance: req.DefaultBalance, + EnableModelFallback: req.EnableModelFallback, + FallbackModelAnthropic: req.FallbackModelAnthropic, + FallbackModelOpenAI: req.FallbackModelOpenAI, + FallbackModelGemini: req.FallbackModelGemini, + FallbackModelAntigravity: req.FallbackModelAntigravity, + EnableIdentityPatch: req.EnableIdentityPatch, + IdentityPatchPrompt: req.IdentityPatchPrompt, OpsMonitoringEnabled: func() bool { if req.OpsMonitoringEnabled != nil { return *req.OpsMonitoringEnabled @@ -318,6 +372,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { RegistrationEnabled: updatedSettings.RegistrationEnabled, EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled, PromoCodeEnabled: updatedSettings.PromoCodeEnabled, + PasswordResetEnabled: updatedSettings.PasswordResetEnabled, + InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled, + TotpEnabled: updatedSettings.TotpEnabled, + TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), SMTPHost: updatedSettings.SMTPHost, SMTPPort: updatedSettings.SMTPPort, SMTPUsername: updatedSettings.SMTPUsername, @@ -340,6 +398,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { DocURL: updatedSettings.DocURL, HomeContent: updatedSettings.HomeContent, HideCcsImportButton: updatedSettings.HideCcsImportButton, + PurchaseSubscriptionEnabled: updatedSettings.PurchaseSubscriptionEnabled, + PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL, DefaultConcurrency: updatedSettings.DefaultConcurrency, DefaultBalance: updatedSettings.DefaultBalance, EnableModelFallback: updatedSettings.EnableModelFallback, @@ -384,6 +444,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.EmailVerifyEnabled != after.EmailVerifyEnabled { changed = append(changed, "email_verify_enabled") } + if before.PasswordResetEnabled != after.PasswordResetEnabled { + changed = append(changed, "password_reset_enabled") + } + if before.TotpEnabled != after.TotpEnabled { + changed = append(changed, "totp_enabled") + } if before.SMTPHost != after.SMTPHost { changed = append(changed, "smtp_host") } diff --git a/backend/internal/handler/admin/subscription_handler.go b/backend/internal/handler/admin/subscription_handler.go index a0d1456f..51995ab1 100644 --- a/backend/internal/handler/admin/subscription_handler.go +++ b/backend/internal/handler/admin/subscription_handler.go @@ -77,7 +77,11 @@ func (h *SubscriptionHandler) List(c *gin.Context) { } status := c.Query("status") - subscriptions, pagination, err := h.subscriptionService.List(c.Request.Context(), page, pageSize, userID, groupID, status) + // Parse sorting parameters + sortBy := c.DefaultQuery("sort_by", "created_at") + sortOrder := c.DefaultQuery("sort_order", "desc") + + subscriptions, pagination, err := h.subscriptionService.List(c.Request.Context(), page, pageSize, userID, groupID, status, sortBy, sortOrder) if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index 9a5a691f..ac76689d 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -277,3 +277,44 @@ func (h *UserHandler) GetUserUsage(c *gin.Context) { response.Success(c, stats) } + +// GetBalanceHistory handles getting user's balance/concurrency change history +// GET /api/v1/admin/users/:id/balance-history +// Query params: +// - type: filter by record type (balance, admin_balance, concurrency, admin_concurrency, subscription) +func (h *UserHandler) GetBalanceHistory(c *gin.Context) { + userID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid user ID") + return + } + + page, pageSize := response.ParsePagination(c) + codeType := c.Query("type") + + codes, total, totalRecharged, err := h.adminService.GetUserBalanceHistory(c.Request.Context(), userID, page, pageSize, codeType) + if err != nil { + response.ErrorFrom(c, err) + return + } + + // Convert to admin DTO (includes notes field for admin visibility) + out := make([]dto.AdminRedeemCode, 0, len(codes)) + for i := range codes { + out = append(out, *dto.RedeemCodeFromServiceAdmin(&codes[i])) + } + + // Custom response with total_recharged alongside pagination + pages := int((total + int64(pageSize) - 1) / int64(pageSize)) + if pages < 1 { + pages = 1 + } + response.Success(c, gin.H{ + "items": out, + "total": total, + "page": page, + "page_size": pageSize, + "pages": pages, + "total_recharged": totalRecharged, + }) +} diff --git a/backend/internal/handler/announcement_handler.go b/backend/internal/handler/announcement_handler.go new file mode 100644 index 00000000..72823eaf --- /dev/null +++ b/backend/internal/handler/announcement_handler.go @@ -0,0 +1,81 @@ +package handler + +import ( + "strconv" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/handler/dto" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" +) + +// AnnouncementHandler handles user announcement operations +type AnnouncementHandler struct { + announcementService *service.AnnouncementService +} + +// NewAnnouncementHandler creates a new user announcement handler +func NewAnnouncementHandler(announcementService *service.AnnouncementService) *AnnouncementHandler { + return &AnnouncementHandler{ + announcementService: announcementService, + } +} + +// List handles listing announcements visible to current user +// GET /api/v1/announcements +func (h *AnnouncementHandler) List(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not found in context") + return + } + + unreadOnly := parseBoolQuery(c.Query("unread_only")) + + items, err := h.announcementService.ListForUser(c.Request.Context(), subject.UserID, unreadOnly) + if err != nil { + response.ErrorFrom(c, err) + return + } + + out := make([]dto.UserAnnouncement, 0, len(items)) + for i := range items { + out = append(out, *dto.UserAnnouncementFromService(&items[i])) + } + response.Success(c, out) +} + +// MarkRead marks an announcement as read for current user +// POST /api/v1/announcements/:id/read +func (h *AnnouncementHandler) MarkRead(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not found in context") + return + } + + announcementID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || announcementID <= 0 { + response.BadRequest(c, "Invalid announcement ID") + return + } + + if err := h.announcementService.MarkRead(c.Request.Context(), subject.UserID, announcementID); err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{"message": "ok"}) +} + +func parseBoolQuery(v string) bool { + switch strings.TrimSpace(strings.ToLower(v)) { + case "1", "true", "yes", "y", "on": + return true + default: + return false + } +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 89f34aae..75ea9f08 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -1,6 +1,8 @@ package handler import ( + "log/slog" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/ip" @@ -13,21 +15,25 @@ import ( // AuthHandler handles authentication-related requests type AuthHandler struct { - cfg *config.Config - authService *service.AuthService - userService *service.UserService - settingSvc *service.SettingService - promoService *service.PromoService + cfg *config.Config + authService *service.AuthService + userService *service.UserService + settingSvc *service.SettingService + promoService *service.PromoService + redeemService *service.RedeemService + totpService *service.TotpService } // NewAuthHandler creates a new AuthHandler -func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userService *service.UserService, settingService *service.SettingService, promoService *service.PromoService) *AuthHandler { +func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userService *service.UserService, settingService *service.SettingService, promoService *service.PromoService, redeemService *service.RedeemService, totpService *service.TotpService) *AuthHandler { return &AuthHandler{ - cfg: cfg, - authService: authService, - userService: userService, - settingSvc: settingService, - promoService: promoService, + cfg: cfg, + authService: authService, + userService: userService, + settingSvc: settingService, + promoService: promoService, + redeemService: redeemService, + totpService: totpService, } } @@ -37,7 +43,8 @@ type RegisterRequest struct { Password string `json:"password" binding:"required,min=6"` VerifyCode string `json:"verify_code"` TurnstileToken string `json:"turnstile_token"` - PromoCode string `json:"promo_code"` // 注册优惠码 + PromoCode string `json:"promo_code"` // 注册优惠码 + InvitationCode string `json:"invitation_code"` // 邀请码 } // SendVerifyCodeRequest 发送验证码请求 @@ -83,7 +90,7 @@ func (h *AuthHandler) Register(c *gin.Context) { } } - token, user, err := h.authService.RegisterWithVerification(c.Request.Context(), req.Email, req.Password, req.VerifyCode, req.PromoCode) + token, user, err := h.authService.RegisterWithVerification(c.Request.Context(), req.Email, req.Password, req.VerifyCode, req.PromoCode, req.InvitationCode) if err != nil { response.ErrorFrom(c, err) return @@ -144,6 +151,100 @@ func (h *AuthHandler) Login(c *gin.Context) { return } + // Check if TOTP 2FA is enabled for this user + if h.totpService != nil && h.settingSvc.IsTotpEnabled(c.Request.Context()) && user.TotpEnabled { + // Create a temporary login session for 2FA + tempToken, err := h.totpService.CreateLoginSession(c.Request.Context(), user.ID, user.Email) + if err != nil { + response.InternalError(c, "Failed to create 2FA session") + return + } + + response.Success(c, TotpLoginResponse{ + Requires2FA: true, + TempToken: tempToken, + UserEmailMasked: service.MaskEmail(user.Email), + }) + return + } + + response.Success(c, AuthResponse{ + AccessToken: token, + TokenType: "Bearer", + User: dto.UserFromService(user), + }) +} + +// TotpLoginResponse represents the response when 2FA is required +type TotpLoginResponse struct { + Requires2FA bool `json:"requires_2fa"` + TempToken string `json:"temp_token,omitempty"` + UserEmailMasked string `json:"user_email_masked,omitempty"` +} + +// Login2FARequest represents the 2FA login request +type Login2FARequest struct { + TempToken string `json:"temp_token" binding:"required"` + TotpCode string `json:"totp_code" binding:"required,len=6"` +} + +// Login2FA completes the login with 2FA verification +// POST /api/v1/auth/login/2fa +func (h *AuthHandler) Login2FA(c *gin.Context) { + var req Login2FARequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + slog.Debug("login_2fa_request", + "temp_token_len", len(req.TempToken), + "totp_code_len", len(req.TotpCode)) + + // Get the login session + session, err := h.totpService.GetLoginSession(c.Request.Context(), req.TempToken) + if err != nil || session == nil { + tokenPrefix := "" + if len(req.TempToken) >= 8 { + tokenPrefix = req.TempToken[:8] + } + slog.Debug("login_2fa_session_invalid", + "temp_token_prefix", tokenPrefix, + "error", err) + response.BadRequest(c, "Invalid or expired 2FA session") + return + } + + slog.Debug("login_2fa_session_found", + "user_id", session.UserID, + "email", session.Email) + + // Verify the TOTP code + if err := h.totpService.VerifyCode(c.Request.Context(), session.UserID, req.TotpCode); err != nil { + slog.Debug("login_2fa_verify_failed", + "user_id", session.UserID, + "error", err) + response.ErrorFrom(c, err) + return + } + + // Delete the login session + _ = h.totpService.DeleteLoginSession(c.Request.Context(), req.TempToken) + + // Get the user + user, err := h.userService.GetByID(c.Request.Context(), session.UserID) + if err != nil { + response.ErrorFrom(c, err) + return + } + + // Generate the JWT token + token, err := h.authService.GenerateToken(user) + if err != nil { + response.InternalError(c, "Failed to generate token") + return + } + response.Success(c, AuthResponse{ AccessToken: token, TokenType: "Bearer", @@ -247,3 +348,146 @@ func (h *AuthHandler) ValidatePromoCode(c *gin.Context) { BonusAmount: promoCode.BonusAmount, }) } + +// ValidateInvitationCodeRequest 验证邀请码请求 +type ValidateInvitationCodeRequest struct { + Code string `json:"code" binding:"required"` +} + +// ValidateInvitationCodeResponse 验证邀请码响应 +type ValidateInvitationCodeResponse struct { + Valid bool `json:"valid"` + ErrorCode string `json:"error_code,omitempty"` +} + +// ValidateInvitationCode 验证邀请码(公开接口,注册前调用) +// POST /api/v1/auth/validate-invitation-code +func (h *AuthHandler) ValidateInvitationCode(c *gin.Context) { + // 检查邀请码功能是否启用 + if h.settingSvc == nil || !h.settingSvc.IsInvitationCodeEnabled(c.Request.Context()) { + response.Success(c, ValidateInvitationCodeResponse{ + Valid: false, + ErrorCode: "INVITATION_CODE_DISABLED", + }) + return + } + + var req ValidateInvitationCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + // 验证邀请码 + redeemCode, err := h.redeemService.GetByCode(c.Request.Context(), req.Code) + if err != nil { + response.Success(c, ValidateInvitationCodeResponse{ + Valid: false, + ErrorCode: "INVITATION_CODE_NOT_FOUND", + }) + return + } + + // 检查类型和状态 + if redeemCode.Type != service.RedeemTypeInvitation { + response.Success(c, ValidateInvitationCodeResponse{ + Valid: false, + ErrorCode: "INVITATION_CODE_INVALID", + }) + return + } + + if redeemCode.Status != service.StatusUnused { + response.Success(c, ValidateInvitationCodeResponse{ + Valid: false, + ErrorCode: "INVITATION_CODE_USED", + }) + return + } + + response.Success(c, ValidateInvitationCodeResponse{ + Valid: true, + }) +} + +// ForgotPasswordRequest 忘记密码请求 +type ForgotPasswordRequest struct { + Email string `json:"email" binding:"required,email"` + TurnstileToken string `json:"turnstile_token"` +} + +// ForgotPasswordResponse 忘记密码响应 +type ForgotPasswordResponse struct { + Message string `json:"message"` +} + +// ForgotPassword 请求密码重置 +// POST /api/v1/auth/forgot-password +func (h *AuthHandler) ForgotPassword(c *gin.Context) { + var req ForgotPasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + // Turnstile 验证 + if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil { + response.ErrorFrom(c, err) + return + } + + // Build frontend base URL from request + scheme := "https" + if c.Request.TLS == nil { + // Check X-Forwarded-Proto header (common in reverse proxy setups) + if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" { + scheme = proto + } else { + scheme = "http" + } + } + frontendBaseURL := scheme + "://" + c.Request.Host + + // Request password reset (async) + // Note: This returns success even if email doesn't exist (to prevent enumeration) + if err := h.authService.RequestPasswordResetAsync(c.Request.Context(), req.Email, frontendBaseURL); err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, ForgotPasswordResponse{ + Message: "If your email is registered, you will receive a password reset link shortly.", + }) +} + +// ResetPasswordRequest 重置密码请求 +type ResetPasswordRequest struct { + Email string `json:"email" binding:"required,email"` + Token string `json:"token" binding:"required"` + NewPassword string `json:"new_password" binding:"required,min=6"` +} + +// ResetPasswordResponse 重置密码响应 +type ResetPasswordResponse struct { + Message string `json:"message"` +} + +// ResetPassword 重置密码 +// POST /api/v1/auth/reset-password +func (h *AuthHandler) ResetPassword(c *gin.Context) { + var req ResetPasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + // Reset password + if err := h.authService.ResetPassword(c.Request.Context(), req.Email, req.Token, req.NewPassword); err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, ResetPasswordResponse{ + Message: "Your password has been reset successfully. You can now log in with your new password.", + }) +} diff --git a/backend/internal/handler/dto/announcement.go b/backend/internal/handler/dto/announcement.go new file mode 100644 index 00000000..bc0db1b2 --- /dev/null +++ b/backend/internal/handler/dto/announcement.go @@ -0,0 +1,74 @@ +package dto + +import ( + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +type Announcement struct { + ID int64 `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + Status string `json:"status"` + + Targeting service.AnnouncementTargeting `json:"targeting"` + + StartsAt *time.Time `json:"starts_at,omitempty"` + EndsAt *time.Time `json:"ends_at,omitempty"` + + CreatedBy *int64 `json:"created_by,omitempty"` + UpdatedBy *int64 `json:"updated_by,omitempty"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UserAnnouncement struct { + ID int64 `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + + StartsAt *time.Time `json:"starts_at,omitempty"` + EndsAt *time.Time `json:"ends_at,omitempty"` + + ReadAt *time.Time `json:"read_at,omitempty"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func AnnouncementFromService(a *service.Announcement) *Announcement { + if a == nil { + return nil + } + return &Announcement{ + ID: a.ID, + Title: a.Title, + Content: a.Content, + Status: a.Status, + Targeting: a.Targeting, + StartsAt: a.StartsAt, + EndsAt: a.EndsAt, + CreatedBy: a.CreatedBy, + UpdatedBy: a.UpdatedBy, + CreatedAt: a.CreatedAt, + UpdatedAt: a.UpdatedAt, + } +} + +func UserAnnouncementFromService(a *service.UserAnnouncement) *UserAnnouncement { + if a == nil { + return nil + } + return &UserAnnouncement{ + ID: a.Announcement.ID, + Title: a.Announcement.Title, + Content: a.Announcement.Content, + StartsAt: a.Announcement.StartsAt, + EndsAt: a.Announcement.EndsAt, + ReadAt: a.ReadAt, + CreatedAt: a.Announcement.CreatedAt, + UpdatedAt: a.Announcement.UpdatedAt, + } +} diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 58a4ad86..04d1385d 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -208,6 +208,17 @@ func AccountFromServiceShallow(a *service.Account) *Account { } } + if scopeLimits := a.GetAntigravityScopeRateLimits(); len(scopeLimits) > 0 { + out.ScopeRateLimits = make(map[string]ScopeRateLimitInfo, len(scopeLimits)) + now := time.Now() + for scope, remainingSec := range scopeLimits { + out.ScopeRateLimits[scope] = ScopeRateLimitInfo{ + ResetAt: now.Add(time.Duration(remainingSec) * time.Second), + RemainingSec: remainingSec, + } + } + } + return out } @@ -325,7 +336,7 @@ func RedeemCodeFromServiceAdmin(rc *service.RedeemCode) *AdminRedeemCode { } func redeemCodeFromServiceBase(rc *service.RedeemCode) RedeemCode { - return RedeemCode{ + out := RedeemCode{ ID: rc.ID, Code: rc.Code, Type: rc.Type, @@ -339,6 +350,14 @@ func redeemCodeFromServiceBase(rc *service.RedeemCode) RedeemCode { User: UserFromServiceShallow(rc.User), Group: GroupFromServiceShallow(rc.Group), } + + // For admin_balance/admin_concurrency types, include notes so users can see + // why they were charged or credited by admin + if (rc.Type == "admin_balance" || rc.Type == "admin_concurrency") && rc.Notes != "" { + out.Notes = &rc.Notes + } + + return out } // AccountSummaryFromService returns a minimal AccountSummary for usage log display. @@ -362,6 +381,7 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog { AccountID: l.AccountID, RequestID: l.RequestID, Model: l.Model, + ReasoningEffort: l.ReasoningEffort, GroupID: l.GroupID, SubscriptionID: l.SubscriptionID, InputTokens: l.InputTokens, diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 01f39478..be94bc16 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -2,9 +2,13 @@ package dto // SystemSettings represents the admin settings API response payload. type SystemSettings struct { - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - PromoCodeEnabled bool `json:"promo_code_enabled"` + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` + InvitationCodeEnabled bool `json:"invitation_code_enabled"` + TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 + TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置 SMTPHost string `json:"smtp_host"` SMTPPort int `json:"smtp_port"` @@ -23,14 +27,16 @@ type SystemSettings struct { LinuxDoConnectClientSecretConfigured bool `json:"linuxdo_connect_client_secret_configured"` LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"` - SiteName string `json:"site_name"` - SiteLogo string `json:"site_logo"` - SiteSubtitle string `json:"site_subtitle"` - APIBaseURL string `json:"api_base_url"` - ContactInfo string `json:"contact_info"` - DocURL string `json:"doc_url"` - HomeContent string `json:"home_content"` - HideCcsImportButton bool `json:"hide_ccs_import_button"` + SiteName string `json:"site_name"` + SiteLogo string `json:"site_logo"` + SiteSubtitle string `json:"site_subtitle"` + APIBaseURL string `json:"api_base_url"` + ContactInfo string `json:"contact_info"` + DocURL string `json:"doc_url"` + HomeContent string `json:"home_content"` + HideCcsImportButton bool `json:"hide_ccs_import_button"` + PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` + PurchaseSubscriptionURL string `json:"purchase_subscription_url"` DefaultConcurrency int `json:"default_concurrency"` DefaultBalance float64 `json:"default_balance"` @@ -54,21 +60,26 @@ type SystemSettings struct { } type PublicSettings struct { - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - PromoCodeEnabled bool `json:"promo_code_enabled"` - TurnstileEnabled bool `json:"turnstile_enabled"` - TurnstileSiteKey string `json:"turnstile_site_key"` - SiteName string `json:"site_name"` - SiteLogo string `json:"site_logo"` - SiteSubtitle string `json:"site_subtitle"` - APIBaseURL string `json:"api_base_url"` - ContactInfo string `json:"contact_info"` - DocURL string `json:"doc_url"` - HomeContent string `json:"home_content"` - HideCcsImportButton bool `json:"hide_ccs_import_button"` - LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` - Version string `json:"version"` + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` + InvitationCodeEnabled bool `json:"invitation_code_enabled"` + TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 + TurnstileEnabled bool `json:"turnstile_enabled"` + TurnstileSiteKey string `json:"turnstile_site_key"` + SiteName string `json:"site_name"` + SiteLogo string `json:"site_logo"` + SiteSubtitle string `json:"site_subtitle"` + APIBaseURL string `json:"api_base_url"` + ContactInfo string `json:"contact_info"` + DocURL string `json:"doc_url"` + HomeContent string `json:"home_content"` + HideCcsImportButton bool `json:"hide_ccs_import_button"` + PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` + PurchaseSubscriptionURL string `json:"purchase_subscription_url"` + LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` + Version string `json:"version"` } // StreamTimeoutSettings 流超时处理配置 DTO diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 505f9dd4..f2c7f5f1 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -2,6 +2,11 @@ package dto import "time" +type ScopeRateLimitInfo struct { + ResetAt time.Time `json:"reset_at"` + RemainingSec int64 `json:"remaining_sec"` +} + type User struct { ID int64 `json:"id"` Email string `json:"email"` @@ -114,6 +119,9 @@ type Account struct { RateLimitResetAt *time.Time `json:"rate_limit_reset_at"` OverloadUntil *time.Time `json:"overload_until"` + // Antigravity scope 级限流状态(从 extra 提取) + ScopeRateLimits map[string]ScopeRateLimitInfo `json:"scope_rate_limits,omitempty"` + TempUnschedulableUntil *time.Time `json:"temp_unschedulable_until"` TempUnschedulableReason string `json:"temp_unschedulable_reason"` @@ -204,6 +212,10 @@ type RedeemCode struct { GroupID *int64 `json:"group_id"` ValidityDays int `json:"validity_days"` + // Notes is only populated for admin_balance/admin_concurrency types + // so users can see why they were charged or credited + Notes *string `json:"notes,omitempty"` + User *User `json:"user,omitempty"` Group *Group `json:"group,omitempty"` } @@ -224,6 +236,9 @@ type UsageLog struct { AccountID int64 `json:"account_id"` RequestID string `json:"request_id"` Model string `json:"model"` + // ReasoningEffort is the request's reasoning effort level (OpenAI Responses API). + // nil means not provided / not applicable. + ReasoningEffort *string `json:"reasoning_effort,omitempty"` GroupID *int64 `json:"group_id"` SubscriptionID *int64 `json:"subscription_id"` diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index a7b98940..673f6369 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -30,6 +30,7 @@ type GatewayHandler struct { antigravityGatewayService *service.AntigravityGatewayService userService *service.UserService billingCacheService *service.BillingCacheService + usageService *service.UsageService concurrencyHelper *ConcurrencyHelper maxAccountSwitches int maxAccountSwitchesGemini int @@ -44,6 +45,7 @@ func NewGatewayHandler( userService *service.UserService, concurrencyService *service.ConcurrencyService, billingCacheService *service.BillingCacheService, + usageService *service.UsageService, cfg *config.Config, ) *GatewayHandler { pingInterval := time.Duration(0) @@ -64,6 +66,7 @@ func NewGatewayHandler( antigravityGatewayService: antigravityGatewayService, userService: userService, billingCacheService: billingCacheService, + usageService: usageService, concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatClaude, pingInterval), maxAccountSwitches: maxAccountSwitches, maxAccountSwitchesGemini: maxAccountSwitchesGemini, @@ -537,7 +540,7 @@ func (h *GatewayHandler) AntigravityModels(c *gin.Context) { }) } -// Usage handles getting account balance for CC Switch integration +// Usage handles getting account balance and usage statistics for CC Switch integration // GET /v1/usage func (h *GatewayHandler) Usage(c *gin.Context) { apiKey, ok := middleware2.GetAPIKeyFromContext(c) @@ -552,7 +555,40 @@ func (h *GatewayHandler) Usage(c *gin.Context) { return } - // 订阅模式:返回订阅限额信息 + // Best-effort: 获取用量统计,失败不影响基础响应 + var usageData gin.H + if h.usageService != nil { + dashStats, err := h.usageService.GetUserDashboardStats(c.Request.Context(), subject.UserID) + if err == nil && dashStats != nil { + usageData = gin.H{ + "today": gin.H{ + "requests": dashStats.TodayRequests, + "input_tokens": dashStats.TodayInputTokens, + "output_tokens": dashStats.TodayOutputTokens, + "cache_creation_tokens": dashStats.TodayCacheCreationTokens, + "cache_read_tokens": dashStats.TodayCacheReadTokens, + "total_tokens": dashStats.TodayTokens, + "cost": dashStats.TodayCost, + "actual_cost": dashStats.TodayActualCost, + }, + "total": gin.H{ + "requests": dashStats.TotalRequests, + "input_tokens": dashStats.TotalInputTokens, + "output_tokens": dashStats.TotalOutputTokens, + "cache_creation_tokens": dashStats.TotalCacheCreationTokens, + "cache_read_tokens": dashStats.TotalCacheReadTokens, + "total_tokens": dashStats.TotalTokens, + "cost": dashStats.TotalCost, + "actual_cost": dashStats.TotalActualCost, + }, + "average_duration_ms": dashStats.AverageDurationMs, + "rpm": dashStats.Rpm, + "tpm": dashStats.Tpm, + } + } + } + + // 订阅模式:返回订阅限额信息 + 用量统计 if apiKey.Group != nil && apiKey.Group.IsSubscriptionType() { subscription, ok := middleware2.GetSubscriptionFromContext(c) if !ok { @@ -561,28 +597,46 @@ func (h *GatewayHandler) Usage(c *gin.Context) { } remaining := h.calculateSubscriptionRemaining(apiKey.Group, subscription) - c.JSON(http.StatusOK, gin.H{ + resp := gin.H{ "isValid": true, "planName": apiKey.Group.Name, "remaining": remaining, "unit": "USD", - }) + "subscription": gin.H{ + "daily_usage_usd": subscription.DailyUsageUSD, + "weekly_usage_usd": subscription.WeeklyUsageUSD, + "monthly_usage_usd": subscription.MonthlyUsageUSD, + "daily_limit_usd": apiKey.Group.DailyLimitUSD, + "weekly_limit_usd": apiKey.Group.WeeklyLimitUSD, + "monthly_limit_usd": apiKey.Group.MonthlyLimitUSD, + "expires_at": subscription.ExpiresAt, + }, + } + if usageData != nil { + resp["usage"] = usageData + } + c.JSON(http.StatusOK, resp) return } - // 余额模式:返回钱包余额 + // 余额模式:返回钱包余额 + 用量统计 latestUser, err := h.userService.GetByID(c.Request.Context(), subject.UserID) if err != nil { h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to get user info") return } - c.JSON(http.StatusOK, gin.H{ + resp := gin.H{ "isValid": true, "planName": "钱包余额", "remaining": latestUser.Balance, "unit": "USD", - }) + "balance": latestUser.Balance, + } + if usageData != nil { + resp["usage"] = usageData + } + c.JSON(http.StatusOK, resp) } // calculateSubscriptionRemaining 计算订阅剩余可用额度 @@ -738,6 +792,9 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) { return } + // 检查是否为 Claude Code 客户端,设置到 context 中 + SetClaudeCodeClientContext(c, body) + setOpsRequestContext(c, "", false, body) parsedReq, err := service.ParseGatewayRequest(body) diff --git a/backend/internal/handler/gemini_cli_session_test.go b/backend/internal/handler/gemini_cli_session_test.go new file mode 100644 index 00000000..0b37f5f2 --- /dev/null +++ b/backend/internal/handler/gemini_cli_session_test.go @@ -0,0 +1,122 @@ +//go:build unit + +package handler + +import ( + "crypto/sha256" + "encoding/hex" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestExtractGeminiCLISessionHash(t *testing.T) { + tests := []struct { + name string + body string + privilegedUserID string + wantEmpty bool + wantHash string + }{ + { + name: "with privileged-user-id and tmp dir", + body: `{"contents":[{"parts":[{"text":"The project's temporary directory is: /Users/ianshaw/.gemini/tmp/f7851b009ed314d1baee62e83115f486160283f4a55a582d89fdac8b9fe3b740"}]}]}`, + privilegedUserID: "90785f52-8bbe-4b17-b111-a1ddea1636c3", + wantEmpty: false, + wantHash: func() string { + combined := "90785f52-8bbe-4b17-b111-a1ddea1636c3:f7851b009ed314d1baee62e83115f486160283f4a55a582d89fdac8b9fe3b740" + hash := sha256.Sum256([]byte(combined)) + return hex.EncodeToString(hash[:]) + }(), + }, + { + name: "without privileged-user-id but with tmp dir", + body: `{"contents":[{"parts":[{"text":"The project's temporary directory is: /Users/ianshaw/.gemini/tmp/f7851b009ed314d1baee62e83115f486160283f4a55a582d89fdac8b9fe3b740"}]}]}`, + privilegedUserID: "", + wantEmpty: false, + wantHash: "f7851b009ed314d1baee62e83115f486160283f4a55a582d89fdac8b9fe3b740", + }, + { + name: "without tmp dir", + body: `{"contents":[{"parts":[{"text":"Hello world"}]}]}`, + privilegedUserID: "90785f52-8bbe-4b17-b111-a1ddea1636c3", + wantEmpty: true, + }, + { + name: "empty body", + body: "", + privilegedUserID: "90785f52-8bbe-4b17-b111-a1ddea1636c3", + wantEmpty: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // 创建测试上下文 + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/test", nil) + if tt.privilegedUserID != "" { + c.Request.Header.Set("x-gemini-api-privileged-user-id", tt.privilegedUserID) + } + + // 调用函数 + result := extractGeminiCLISessionHash(c, []byte(tt.body)) + + // 验证结果 + if tt.wantEmpty { + require.Empty(t, result, "expected empty session hash") + } else { + require.NotEmpty(t, result, "expected non-empty session hash") + require.Equal(t, tt.wantHash, result, "session hash mismatch") + } + }) + } +} + +func TestGeminiCLITmpDirRegex(t *testing.T) { + tests := []struct { + name string + input string + wantMatch bool + wantHash string + }{ + { + name: "valid tmp dir path", + input: "/Users/ianshaw/.gemini/tmp/f7851b009ed314d1baee62e83115f486160283f4a55a582d89fdac8b9fe3b740", + wantMatch: true, + wantHash: "f7851b009ed314d1baee62e83115f486160283f4a55a582d89fdac8b9fe3b740", + }, + { + name: "valid tmp dir path in text", + input: "The project's temporary directory is: /Users/ianshaw/.gemini/tmp/f7851b009ed314d1baee62e83115f486160283f4a55a582d89fdac8b9fe3b740\nOther text", + wantMatch: true, + wantHash: "f7851b009ed314d1baee62e83115f486160283f4a55a582d89fdac8b9fe3b740", + }, + { + name: "invalid hash length", + input: "/Users/ianshaw/.gemini/tmp/abc123", + wantMatch: false, + }, + { + name: "no tmp dir", + input: "Hello world", + wantMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + match := geminiCLITmpDirRegex.FindStringSubmatch(tt.input) + if tt.wantMatch { + require.NotNil(t, match, "expected regex to match") + require.Len(t, match, 2, "expected 2 capture groups") + require.Equal(t, tt.wantHash, match[1], "hash mismatch") + } else { + require.Nil(t, match, "expected regex not to match") + } + }) + } +} diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index c7646b38..d1b19ede 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -1,11 +1,15 @@ package handler import ( + "bytes" "context" + "crypto/sha256" + "encoding/hex" "errors" "io" "log" "net/http" + "regexp" "strings" "time" @@ -19,6 +23,17 @@ import ( "github.com/gin-gonic/gin" ) +// geminiCLITmpDirRegex 用于从 Gemini CLI 请求体中提取 tmp 目录的哈希值 +// 匹配格式: /Users/xxx/.gemini/tmp/[64位十六进制哈希] +var geminiCLITmpDirRegex = regexp.MustCompile(`/\.gemini/tmp/([A-Fa-f0-9]{64})`) + +func isGeminiCLIRequest(c *gin.Context, body []byte) bool { + if strings.TrimSpace(c.GetHeader("x-gemini-api-privileged-user-id")) != "" { + return true + } + return geminiCLITmpDirRegex.Match(body) +} + // GeminiV1BetaListModels proxies: // GET /v1beta/models func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) { @@ -214,12 +229,26 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { } // 3) select account (sticky session based on request body) - parsedReq, _ := service.ParseGatewayRequest(body) - sessionHash := h.gatewayService.GenerateSessionHash(parsedReq) + // 优先使用 Gemini CLI 的会话标识(privileged-user-id + tmp 目录哈希) + sessionHash := extractGeminiCLISessionHash(c, body) + if sessionHash == "" { + // Fallback: 使用通用的会话哈希生成逻辑(适用于其他客户端) + parsedReq, _ := service.ParseGatewayRequest(body) + sessionHash = h.gatewayService.GenerateSessionHash(parsedReq) + } sessionKey := sessionHash if sessionHash != "" { sessionKey = "gemini:" + sessionHash } + + // 查询粘性会话绑定的账号 ID(用于检测账号切换) + var sessionBoundAccountID int64 + if sessionKey != "" { + sessionBoundAccountID, _ = h.gatewayService.GetCachedSessionAccountID(c.Request.Context(), apiKey.GroupID, sessionKey) + } + isCLI := isGeminiCLIRequest(c, body) + cleanedForUnknownBinding := false + maxAccountSwitches := h.maxAccountSwitchesGemini switchCount := 0 failedAccountIDs := make(map[int64]struct{}) @@ -238,6 +267,24 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { account := selection.Account setOpsSelectedAccount(c, account.ID) + // 检测账号切换:如果粘性会话绑定的账号与当前选择的账号不同,清除 thoughtSignature + // 注意:Gemini 原生 API 的 thoughtSignature 与具体上游账号强相关;跨账号透传会导致 400。 + if sessionBoundAccountID > 0 && sessionBoundAccountID != account.ID { + log.Printf("[Gemini] Sticky session account switched: %d -> %d, cleaning thoughtSignature", sessionBoundAccountID, account.ID) + body = service.CleanGeminiNativeThoughtSignatures(body) + sessionBoundAccountID = account.ID + } else if sessionKey != "" && sessionBoundAccountID == 0 && isCLI && !cleanedForUnknownBinding && bytes.Contains(body, []byte(`"thoughtSignature"`)) { + // 无缓存绑定但请求里已有 thoughtSignature:常见于缓存丢失/TTL 过期后,CLI 继续携带旧签名。 + // 为避免第一次转发就 400,这里做一次确定性清理,让新账号重新生成签名链路。 + log.Printf("[Gemini] Sticky session binding missing for CLI request, cleaning thoughtSignature proactively") + body = service.CleanGeminiNativeThoughtSignatures(body) + cleanedForUnknownBinding = true + sessionBoundAccountID = account.ID + } else if sessionBoundAccountID == 0 { + // 记录本次请求中首次选择到的账号,便于同一请求内 failover 时检测切换。 + sessionBoundAccountID = account.ID + } + // 4) account concurrency slot accountReleaseFunc := selection.ReleaseFunc if !selection.Acquired { @@ -319,18 +366,21 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { userAgent := c.GetHeader("User-Agent") clientIP := ip.GetClientIP(c) - // 6) record usage async + // 6) record usage async (Gemini 使用长上下文双倍计费) go func(result *service.ForwardResult, usedAccount *service.Account, ua, ip string) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ - Result: result, - APIKey: apiKey, - User: apiKey.User, - Account: usedAccount, - Subscription: subscription, - UserAgent: ua, - IPAddress: ip, + + if err := h.gatewayService.RecordUsageWithLongContext(ctx, &service.RecordUsageLongContextInput{ + Result: result, + APIKey: apiKey, + User: apiKey.User, + Account: usedAccount, + Subscription: subscription, + UserAgent: ua, + IPAddress: ip, + LongContextThreshold: 200000, // Gemini 200K 阈值 + LongContextMultiplier: 2.0, // 超出部分双倍计费 }); err != nil { log.Printf("Record usage failed: %v", err) } @@ -433,3 +483,38 @@ func shouldFallbackGeminiModels(res *service.UpstreamHTTPResult) bool { } return false } + +// extractGeminiCLISessionHash 从 Gemini CLI 请求中提取会话标识。 +// 组合 x-gemini-api-privileged-user-id header 和请求体中的 tmp 目录哈希。 +// +// 会话标识生成策略: +// 1. 从请求体中提取 tmp 目录哈希(64位十六进制) +// 2. 从 header 中提取 privileged-user-id(UUID) +// 3. 组合两者生成 SHA256 哈希作为最终的会话标识 +// +// 如果找不到 tmp 目录哈希,返回空字符串(不使用粘性会话)。 +// +// extractGeminiCLISessionHash extracts session identifier from Gemini CLI requests. +// Combines x-gemini-api-privileged-user-id header with tmp directory hash from request body. +func extractGeminiCLISessionHash(c *gin.Context, body []byte) string { + // 1. 从请求体中提取 tmp 目录哈希 + match := geminiCLITmpDirRegex.FindSubmatch(body) + if len(match) < 2 { + return "" // 没有找到 tmp 目录,不使用粘性会话 + } + tmpDirHash := string(match[1]) + + // 2. 提取 privileged-user-id + privilegedUserID := strings.TrimSpace(c.GetHeader("x-gemini-api-privileged-user-id")) + + // 3. 组合生成最终的 session hash + if privilegedUserID != "" { + // 组合两个标识符:privileged-user-id + tmp 目录哈希 + combined := privilegedUserID + ":" + tmpDirHash + hash := sha256.Sum256([]byte(combined)) + return hex.EncodeToString(hash[:]) + } + + // 如果没有 privileged-user-id,直接使用 tmp 目录哈希 + return tmpDirHash +} diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index d7014a22..ec0fb99d 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -10,6 +10,7 @@ type AdminHandlers struct { User *admin.UserHandler Group *admin.GroupHandler Account *admin.AccountHandler + Announcement *admin.AnnouncementHandler OAuth *admin.OAuthHandler OpenAIOAuth *admin.OpenAIOAuthHandler GeminiOAuth *admin.GeminiOAuthHandler @@ -33,11 +34,13 @@ type Handlers struct { Usage *UsageHandler Redeem *RedeemHandler Subscription *SubscriptionHandler + Announcement *AnnouncementHandler Admin *AdminHandlers Gateway *GatewayHandler OpenAIGateway *OpenAIGatewayHandler SoraGateway *SoraGatewayHandler Setting *SettingHandler + Totp *TotpHandler } // BuildInfo contains build-time information diff --git a/backend/internal/handler/ops_error_logger.go b/backend/internal/handler/ops_error_logger.go index f62e6b3e..36ffde63 100644 --- a/backend/internal/handler/ops_error_logger.go +++ b/backend/internal/handler/ops_error_logger.go @@ -905,7 +905,7 @@ func classifyOpsIsRetryable(errType string, statusCode int) bool { func classifyOpsIsBusinessLimited(errType, phase, code string, status int, message string) bool { switch strings.TrimSpace(code) { - case "INSUFFICIENT_BALANCE", "USAGE_LIMIT_EXCEEDED", "SUBSCRIPTION_NOT_FOUND", "SUBSCRIPTION_INVALID": + case "INSUFFICIENT_BALANCE", "USAGE_LIMIT_EXCEEDED", "SUBSCRIPTION_NOT_FOUND", "SUBSCRIPTION_INVALID", "USER_INACTIVE": return true } if phase == "billing" || phase == "concurrency" { @@ -1011,5 +1011,12 @@ func shouldSkipOpsErrorLog(ctx context.Context, ops *service.OpsService, message } } + // Check if invalid/missing API key errors should be ignored (user misconfiguration) + if settings.IgnoreInvalidApiKeyErrors { + if strings.Contains(bodyLower, "invalid_api_key") || strings.Contains(bodyLower, "api_key_required") { + return true + } + } + return false } diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 8723c746..2029f116 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -32,20 +32,25 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { } response.Success(c, dto.PublicSettings{ - RegistrationEnabled: settings.RegistrationEnabled, - EmailVerifyEnabled: settings.EmailVerifyEnabled, - PromoCodeEnabled: settings.PromoCodeEnabled, - TurnstileEnabled: settings.TurnstileEnabled, - TurnstileSiteKey: settings.TurnstileSiteKey, - SiteName: settings.SiteName, - SiteLogo: settings.SiteLogo, - SiteSubtitle: settings.SiteSubtitle, - APIBaseURL: settings.APIBaseURL, - ContactInfo: settings.ContactInfo, - DocURL: settings.DocURL, - HomeContent: settings.HomeContent, - HideCcsImportButton: settings.HideCcsImportButton, - LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, - Version: h.version, + RegistrationEnabled: settings.RegistrationEnabled, + EmailVerifyEnabled: settings.EmailVerifyEnabled, + PromoCodeEnabled: settings.PromoCodeEnabled, + PasswordResetEnabled: settings.PasswordResetEnabled, + InvitationCodeEnabled: settings.InvitationCodeEnabled, + TotpEnabled: settings.TotpEnabled, + TurnstileEnabled: settings.TurnstileEnabled, + TurnstileSiteKey: settings.TurnstileSiteKey, + SiteName: settings.SiteName, + SiteLogo: settings.SiteLogo, + SiteSubtitle: settings.SiteSubtitle, + APIBaseURL: settings.APIBaseURL, + ContactInfo: settings.ContactInfo, + DocURL: settings.DocURL, + HomeContent: settings.HomeContent, + HideCcsImportButton: settings.HideCcsImportButton, + PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, + PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, + LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, + Version: h.version, }) } diff --git a/backend/internal/handler/totp_handler.go b/backend/internal/handler/totp_handler.go new file mode 100644 index 00000000..5c5eb567 --- /dev/null +++ b/backend/internal/handler/totp_handler.go @@ -0,0 +1,181 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +// TotpHandler handles TOTP-related requests +type TotpHandler struct { + totpService *service.TotpService +} + +// NewTotpHandler creates a new TotpHandler +func NewTotpHandler(totpService *service.TotpService) *TotpHandler { + return &TotpHandler{ + totpService: totpService, + } +} + +// TotpStatusResponse represents the TOTP status response +type TotpStatusResponse struct { + Enabled bool `json:"enabled"` + EnabledAt *int64 `json:"enabled_at,omitempty"` // Unix timestamp + FeatureEnabled bool `json:"feature_enabled"` +} + +// GetStatus returns the TOTP status for the current user +// GET /api/v1/user/totp/status +func (h *TotpHandler) GetStatus(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + + status, err := h.totpService.GetStatus(c.Request.Context(), subject.UserID) + if err != nil { + response.ErrorFrom(c, err) + return + } + + resp := TotpStatusResponse{ + Enabled: status.Enabled, + FeatureEnabled: status.FeatureEnabled, + } + + if status.EnabledAt != nil { + ts := status.EnabledAt.Unix() + resp.EnabledAt = &ts + } + + response.Success(c, resp) +} + +// TotpSetupRequest represents the request to initiate TOTP setup +type TotpSetupRequest struct { + EmailCode string `json:"email_code"` + Password string `json:"password"` +} + +// TotpSetupResponse represents the TOTP setup response +type TotpSetupResponse struct { + Secret string `json:"secret"` + QRCodeURL string `json:"qr_code_url"` + SetupToken string `json:"setup_token"` + Countdown int `json:"countdown"` +} + +// InitiateSetup starts the TOTP setup process +// POST /api/v1/user/totp/setup +func (h *TotpHandler) InitiateSetup(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + + var req TotpSetupRequest + if err := c.ShouldBindJSON(&req); err != nil { + // Allow empty body (optional params) + req = TotpSetupRequest{} + } + + result, err := h.totpService.InitiateSetup(c.Request.Context(), subject.UserID, req.EmailCode, req.Password) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, TotpSetupResponse{ + Secret: result.Secret, + QRCodeURL: result.QRCodeURL, + SetupToken: result.SetupToken, + Countdown: result.Countdown, + }) +} + +// TotpEnableRequest represents the request to enable TOTP +type TotpEnableRequest struct { + TotpCode string `json:"totp_code" binding:"required,len=6"` + SetupToken string `json:"setup_token" binding:"required"` +} + +// Enable completes the TOTP setup +// POST /api/v1/user/totp/enable +func (h *TotpHandler) Enable(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + + var req TotpEnableRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + if err := h.totpService.CompleteSetup(c.Request.Context(), subject.UserID, req.TotpCode, req.SetupToken); err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{"success": true}) +} + +// TotpDisableRequest represents the request to disable TOTP +type TotpDisableRequest struct { + EmailCode string `json:"email_code"` + Password string `json:"password"` +} + +// Disable disables TOTP for the current user +// POST /api/v1/user/totp/disable +func (h *TotpHandler) Disable(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + + var req TotpDisableRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + if err := h.totpService.Disable(c.Request.Context(), subject.UserID, req.EmailCode, req.Password); err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{"success": true}) +} + +// GetVerificationMethod returns the verification method for TOTP operations +// GET /api/v1/user/totp/verification-method +func (h *TotpHandler) GetVerificationMethod(c *gin.Context) { + method := h.totpService.GetVerificationMethod(c.Request.Context()) + response.Success(c, method) +} + +// SendVerifyCode sends an email verification code for TOTP operations +// POST /api/v1/user/totp/send-code +func (h *TotpHandler) SendVerifyCode(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + + if err := h.totpService.SendVerifyCode(c.Request.Context(), subject.UserID); err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{"success": true}) +} diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index c20b7fbc..3d268c93 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -13,6 +13,7 @@ func ProvideAdminHandlers( userHandler *admin.UserHandler, groupHandler *admin.GroupHandler, accountHandler *admin.AccountHandler, + announcementHandler *admin.AnnouncementHandler, oauthHandler *admin.OAuthHandler, openaiOAuthHandler *admin.OpenAIOAuthHandler, geminiOAuthHandler *admin.GeminiOAuthHandler, @@ -32,6 +33,7 @@ func ProvideAdminHandlers( User: userHandler, Group: groupHandler, Account: accountHandler, + Announcement: announcementHandler, OAuth: oauthHandler, OpenAIOAuth: openaiOAuthHandler, GeminiOAuth: geminiOAuthHandler, @@ -66,11 +68,13 @@ func ProvideHandlers( usageHandler *UsageHandler, redeemHandler *RedeemHandler, subscriptionHandler *SubscriptionHandler, + announcementHandler *AnnouncementHandler, adminHandlers *AdminHandlers, gatewayHandler *GatewayHandler, openaiGatewayHandler *OpenAIGatewayHandler, soraGatewayHandler *SoraGatewayHandler, settingHandler *SettingHandler, + totpHandler *TotpHandler, ) *Handlers { return &Handlers{ Auth: authHandler, @@ -79,11 +83,13 @@ func ProvideHandlers( Usage: usageHandler, Redeem: redeemHandler, Subscription: subscriptionHandler, + Announcement: announcementHandler, Admin: adminHandlers, Gateway: gatewayHandler, OpenAIGateway: openaiGatewayHandler, SoraGateway: soraGatewayHandler, Setting: settingHandler, + Totp: totpHandler, } } @@ -96,9 +102,11 @@ var ProviderSet = wire.NewSet( NewUsageHandler, NewRedeemHandler, NewSubscriptionHandler, + NewAnnouncementHandler, NewGatewayHandler, NewOpenAIGatewayHandler, NewSoraGatewayHandler, + NewTotpHandler, ProvideSettingHandler, // Admin handlers @@ -106,6 +114,7 @@ var ProviderSet = wire.NewSet( admin.NewUserHandler, admin.NewGroupHandler, admin.NewAccountHandler, + admin.NewAnnouncementHandler, admin.NewOAuthHandler, admin.NewOpenAIOAuthHandler, admin.NewGeminiOAuthHandler, diff --git a/backend/internal/pkg/antigravity/oauth.go b/backend/internal/pkg/antigravity/oauth.go index ee2a6c1a..c7d657b9 100644 --- a/backend/internal/pkg/antigravity/oauth.go +++ b/backend/internal/pkg/antigravity/oauth.go @@ -33,7 +33,7 @@ const ( "https://www.googleapis.com/auth/experimentsandconfigs" // User-Agent(与 Antigravity-Manager 保持一致) - UserAgent = "antigravity/1.11.9 windows/amd64" + UserAgent = "antigravity/1.15.8 windows/amd64" // Session 过期时间 SessionTTL = 30 * time.Minute diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go index 1b21bd58..63f6ee7c 100644 --- a/backend/internal/pkg/antigravity/request_transformer.go +++ b/backend/internal/pkg/antigravity/request_transformer.go @@ -367,8 +367,10 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu Text: block.Thinking, Thought: true, } - // 保留原有 signature(Claude 模型需要有效的 signature) - if block.Signature != "" { + // signature 处理: + // - Claude 模型(allowDummyThought=false):必须是上游返回的真实 signature(dummy 视为缺失) + // - Gemini 模型(allowDummyThought=true):优先透传真实 signature,缺失时使用 dummy signature + if block.Signature != "" && (allowDummyThought || block.Signature != dummyThoughtSignature) { part.ThoughtSignature = block.Signature } else if !allowDummyThought { // Claude 模型需要有效 signature;在缺失时降级为普通文本,并在上层禁用 thinking mode。 @@ -407,12 +409,12 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu }, } // tool_use 的 signature 处理: - // - Gemini 模型:使用 dummy signature(跳过 thought_signature 校验) - // - Claude 模型:透传上游返回的真实 signature(Vertex/Google 需要完整签名链路) - if allowDummyThought { - part.ThoughtSignature = dummyThoughtSignature - } else if block.Signature != "" && block.Signature != dummyThoughtSignature { + // - Claude 模型(allowDummyThought=false):必须是上游返回的真实 signature(dummy 视为缺失) + // - Gemini 模型(allowDummyThought=true):优先透传真实 signature,缺失时使用 dummy signature + if block.Signature != "" && (allowDummyThought || block.Signature != dummyThoughtSignature) { part.ThoughtSignature = block.Signature + } else if allowDummyThought { + part.ThoughtSignature = dummyThoughtSignature } parts = append(parts, part) diff --git a/backend/internal/pkg/antigravity/request_transformer_test.go b/backend/internal/pkg/antigravity/request_transformer_test.go index 60ee6f63..9d62a4a1 100644 --- a/backend/internal/pkg/antigravity/request_transformer_test.go +++ b/backend/internal/pkg/antigravity/request_transformer_test.go @@ -100,7 +100,7 @@ func TestBuildParts_ToolUseSignatureHandling(t *testing.T) { {"type": "tool_use", "id": "t1", "name": "Bash", "input": {"command": "ls"}, "signature": "sig_tool_abc"} ]` - t.Run("Gemini uses dummy tool_use signature", func(t *testing.T) { + t.Run("Gemini preserves provided tool_use signature", func(t *testing.T) { toolIDToName := make(map[string]string) parts, _, err := buildParts(json.RawMessage(content), toolIDToName, true) if err != nil { @@ -109,6 +109,23 @@ func TestBuildParts_ToolUseSignatureHandling(t *testing.T) { if len(parts) != 1 || parts[0].FunctionCall == nil { t.Fatalf("expected 1 functionCall part, got %+v", parts) } + if parts[0].ThoughtSignature != "sig_tool_abc" { + t.Fatalf("expected preserved tool signature %q, got %q", "sig_tool_abc", parts[0].ThoughtSignature) + } + }) + + t.Run("Gemini falls back to dummy tool_use signature when missing", func(t *testing.T) { + contentNoSig := `[ + {"type": "tool_use", "id": "t1", "name": "Bash", "input": {"command": "ls"}} + ]` + toolIDToName := make(map[string]string) + parts, _, err := buildParts(json.RawMessage(contentNoSig), toolIDToName, true) + if err != nil { + t.Fatalf("buildParts() error = %v", err) + } + if len(parts) != 1 || parts[0].FunctionCall == nil { + t.Fatalf("expected 1 functionCall part, got %+v", parts) + } if parts[0].ThoughtSignature != dummyThoughtSignature { t.Fatalf("expected dummy tool signature %q, got %q", dummyThoughtSignature, parts[0].ThoughtSignature) } diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index d1a56a84..8b3441dc 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -9,11 +9,26 @@ const ( BetaClaudeCode = "claude-code-20250219" BetaInterleavedThinking = "interleaved-thinking-2025-05-14" BetaFineGrainedToolStreaming = "fine-grained-tool-streaming-2025-05-14" + BetaTokenCounting = "token-counting-2024-11-01" ) // DefaultBetaHeader Claude Code 客户端默认的 anthropic-beta header const DefaultBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaFineGrainedToolStreaming +// MessageBetaHeaderNoTools /v1/messages 在无工具时的 beta header +// +// NOTE: Claude Code OAuth credentials are scoped to Claude Code. When we "mimic" +// Claude Code for non-Claude-Code clients, we must include the claude-code beta +// even if the request doesn't use tools, otherwise upstream may reject the +// request as a non-Claude-Code API request. +const MessageBetaHeaderNoTools = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + +// MessageBetaHeaderWithTools /v1/messages 在有工具时的 beta header +const MessageBetaHeaderWithTools = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + +// CountTokensBetaHeader count_tokens 请求使用的 anthropic-beta header +const CountTokensBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaTokenCounting + // HaikuBetaHeader Haiku 模型使用的 anthropic-beta header(不需要 claude-code beta) const HaikuBetaHeader = BetaOAuth + "," + BetaInterleavedThinking @@ -25,15 +40,17 @@ const APIKeyHaikuBetaHeader = BetaInterleavedThinking // DefaultHeaders 是 Claude Code 客户端默认请求头。 var DefaultHeaders = map[string]string{ - "User-Agent": "claude-cli/2.0.62 (external, cli)", + // Keep these in sync with recent Claude CLI traffic to reduce the chance + // that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage. + "User-Agent": "claude-cli/2.1.22 (external, cli)", "X-Stainless-Lang": "js", - "X-Stainless-Package-Version": "0.52.0", + "X-Stainless-Package-Version": "0.70.0", "X-Stainless-OS": "Linux", - "X-Stainless-Arch": "x64", + "X-Stainless-Arch": "arm64", "X-Stainless-Runtime": "node", - "X-Stainless-Runtime-Version": "v22.14.0", + "X-Stainless-Runtime-Version": "v24.13.0", "X-Stainless-Retry-Count": "0", - "X-Stainless-Timeout": "60", + "X-Stainless-Timeout": "600", "X-App": "cli", "Anthropic-Dangerous-Direct-Browser-Access": "true", } @@ -79,3 +96,39 @@ func DefaultModelIDs() []string { // DefaultTestModel 测试时使用的默认模型 const DefaultTestModel = "claude-sonnet-4-5-20250929" + +// ModelIDOverrides Claude OAuth 请求需要的模型 ID 映射 +var ModelIDOverrides = map[string]string{ + "claude-sonnet-4-5": "claude-sonnet-4-5-20250929", + "claude-opus-4-5": "claude-opus-4-5-20251101", + "claude-haiku-4-5": "claude-haiku-4-5-20251001", +} + +// ModelIDReverseOverrides 用于将上游模型 ID 还原为短名 +var ModelIDReverseOverrides = map[string]string{ + "claude-sonnet-4-5-20250929": "claude-sonnet-4-5", + "claude-opus-4-5-20251101": "claude-opus-4-5", + "claude-haiku-4-5-20251001": "claude-haiku-4-5", +} + +// NormalizeModelID 根据 Claude OAuth 规则映射模型 +func NormalizeModelID(id string) string { + if id == "" { + return id + } + if mapped, ok := ModelIDOverrides[id]; ok { + return mapped + } + return id +} + +// DenormalizeModelID 将上游模型 ID 转换为短名 +func DenormalizeModelID(id string) string { + if id == "" { + return id + } + if mapped, ok := ModelIDReverseOverrides[id]; ok { + return mapped + } + return id +} diff --git a/backend/internal/pkg/response/response.go b/backend/internal/pkg/response/response.go index 43fe12d4..c5b41d6e 100644 --- a/backend/internal/pkg/response/response.go +++ b/backend/internal/pkg/response/response.go @@ -2,6 +2,7 @@ package response import ( + "log" "math" "net/http" @@ -74,6 +75,12 @@ func ErrorFrom(c *gin.Context, err error) bool { } statusCode, status := infraerrors.ToHTTP(err) + + // Log internal errors with full details for debugging + if statusCode >= 500 && c.Request != nil { + log.Printf("[ERROR] %s %s\n Error: %s", c.Request.Method, c.Request.URL.Path, err.Error()) + } + ErrorWithDetails(c, statusCode, status.Message, status.Reason, status.Metadata) return true } diff --git a/backend/internal/pkg/tlsfingerprint/dialer_integration_test.go b/backend/internal/pkg/tlsfingerprint/dialer_integration_test.go new file mode 100644 index 00000000..eea74fcc --- /dev/null +++ b/backend/internal/pkg/tlsfingerprint/dialer_integration_test.go @@ -0,0 +1,278 @@ +//go:build integration + +// Package tlsfingerprint provides TLS fingerprint simulation for HTTP clients. +// +// Integration tests for verifying TLS fingerprint correctness. +// These tests make actual network requests to external services and should be run manually. +// +// Run with: go test -v -tags=integration ./internal/pkg/tlsfingerprint/... +package tlsfingerprint + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" +) + +// skipIfExternalServiceUnavailable checks if the external service is available. +// If not, it skips the test instead of failing. +func skipIfExternalServiceUnavailable(t *testing.T, err error) { + t.Helper() + if err != nil { + // Check for common network/TLS errors that indicate external service issues + errStr := err.Error() + if strings.Contains(errStr, "certificate has expired") || + strings.Contains(errStr, "certificate is not yet valid") || + strings.Contains(errStr, "connection refused") || + strings.Contains(errStr, "no such host") || + strings.Contains(errStr, "network is unreachable") || + strings.Contains(errStr, "timeout") { + t.Skipf("skipping test: external service unavailable: %v", err) + } + t.Fatalf("failed to get fingerprint: %v", err) + } +} + +// TestJA3Fingerprint verifies the JA3/JA4 fingerprint matches expected value. +// This test uses tls.peet.ws to verify the fingerprint. +// Expected JA3 hash: 1a28e69016765d92e3b381168d68922c (Claude CLI / Node.js 20.x) +// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4 (d=domain) or t13i5911h1_... (i=IP) +func TestJA3Fingerprint(t *testing.T) { + // Skip if network is unavailable or if running in short mode + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + profile := &Profile{ + Name: "Claude CLI Test", + EnableGREASE: false, + } + dialer := NewDialer(profile, nil) + + client := &http.Client{ + Transport: &http.Transport{ + DialTLSContext: dialer.DialTLSContext, + }, + Timeout: 30 * time.Second, + } + + // Use tls.peet.ws fingerprint detection API + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", "https://tls.peet.ws/api/all", nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/20.0.0") + + resp, err := client.Do(req) + skipIfExternalServiceUnavailable(t, err) + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read response: %v", err) + } + + var fpResp FingerprintResponse + if err := json.Unmarshal(body, &fpResp); err != nil { + t.Logf("Response body: %s", string(body)) + t.Fatalf("failed to parse fingerprint response: %v", err) + } + + // Log all fingerprint information + t.Logf("JA3: %s", fpResp.TLS.JA3) + t.Logf("JA3 Hash: %s", fpResp.TLS.JA3Hash) + t.Logf("JA4: %s", fpResp.TLS.JA4) + t.Logf("PeetPrint: %s", fpResp.TLS.PeetPrint) + t.Logf("PeetPrint Hash: %s", fpResp.TLS.PeetPrintHash) + + // Verify JA3 hash matches expected value + expectedJA3Hash := "1a28e69016765d92e3b381168d68922c" + if fpResp.TLS.JA3Hash == expectedJA3Hash { + t.Logf("✓ JA3 hash matches expected value: %s", expectedJA3Hash) + } else { + t.Errorf("✗ JA3 hash mismatch: got %s, expected %s", fpResp.TLS.JA3Hash, expectedJA3Hash) + } + + // Verify JA4 fingerprint + // JA4 format: t[version][sni][cipher_count][ext_count][alpn]_[cipher_hash]_[ext_hash] + // Expected: t13d5910h1 (d=domain) or t13i5910h1 (i=IP) + // The suffix _a33745022dd6_1f22a2ca17c4 should match + expectedJA4Suffix := "_a33745022dd6_1f22a2ca17c4" + if strings.HasSuffix(fpResp.TLS.JA4, expectedJA4Suffix) { + t.Logf("✓ JA4 suffix matches expected value: %s", expectedJA4Suffix) + } else { + t.Errorf("✗ JA4 suffix mismatch: got %s, expected suffix %s", fpResp.TLS.JA4, expectedJA4Suffix) + } + + // Verify JA4 prefix (t13d5911h1 or t13i5911h1) + // d = domain (SNI present), i = IP (no SNI) + // Since we connect to tls.peet.ws (domain), we expect 'd' + expectedJA4Prefix := "t13d5911h1" + if strings.HasPrefix(fpResp.TLS.JA4, expectedJA4Prefix) { + t.Logf("✓ JA4 prefix matches: %s (t13=TLS1.3, d=domain, 59=ciphers, 11=extensions, h1=HTTP/1.1)", expectedJA4Prefix) + } else { + // Also accept 'i' variant for IP connections + altPrefix := "t13i5911h1" + if strings.HasPrefix(fpResp.TLS.JA4, altPrefix) { + t.Logf("✓ JA4 prefix matches (IP variant): %s", altPrefix) + } else { + t.Errorf("✗ JA4 prefix mismatch: got %s, expected %s or %s", fpResp.TLS.JA4, expectedJA4Prefix, altPrefix) + } + } + + // Verify JA3 contains expected cipher suites (TLS 1.3 ciphers at the beginning) + if strings.Contains(fpResp.TLS.JA3, "4866-4867-4865") { + t.Logf("✓ JA3 contains expected TLS 1.3 cipher suites") + } else { + t.Logf("Warning: JA3 does not contain expected TLS 1.3 cipher suites") + } + + // Verify extension list (should be 11 extensions including SNI) + // Expected: 0-11-10-35-16-22-23-13-43-45-51 + expectedExtensions := "0-11-10-35-16-22-23-13-43-45-51" + if strings.Contains(fpResp.TLS.JA3, expectedExtensions) { + t.Logf("✓ JA3 contains expected extension list: %s", expectedExtensions) + } else { + t.Logf("Warning: JA3 extension list may differ") + } +} + +// TestProfileExpectation defines expected fingerprint values for a profile. +type TestProfileExpectation struct { + Profile *Profile + ExpectedJA3 string // Expected JA3 hash (empty = don't check) + ExpectedJA4 string // Expected full JA4 (empty = don't check) + JA4CipherHash string // Expected JA4 cipher hash - the stable middle part (empty = don't check) +} + +// TestAllProfiles tests multiple TLS fingerprint profiles against tls.peet.ws. +// Run with: go test -v -tags=integration -run TestAllProfiles ./internal/pkg/tlsfingerprint/... +func TestAllProfiles(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + // Define all profiles to test with their expected fingerprints + // These profiles are from config.yaml gateway.tls_fingerprint.profiles + profiles := []TestProfileExpectation{ + { + // Linux x64 Node.js v22.17.1 + // Expected JA3 Hash: 1a28e69016765d92e3b381168d68922c + // Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4 + Profile: &Profile{ + Name: "linux_x64_node_v22171", + EnableGREASE: false, + CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255}, + Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260}, + PointFormats: []uint8{0, 1, 2}, + }, + JA4CipherHash: "a33745022dd6", // stable part + }, + { + // MacOS arm64 Node.js v22.18.0 + // Expected JA3 Hash: 70cb5ca646080902703ffda87036a5ea + // Expected JA4: t13d5912h1_a33745022dd6_dbd39dd1d406 + Profile: &Profile{ + Name: "macos_arm64_node_v22180", + EnableGREASE: false, + CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255}, + Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260}, + PointFormats: []uint8{0, 1, 2}, + }, + JA4CipherHash: "a33745022dd6", // stable part (same cipher suites) + }, + } + + for _, tc := range profiles { + tc := tc // capture range variable + t.Run(tc.Profile.Name, func(t *testing.T) { + fp := fetchFingerprint(t, tc.Profile) + if fp == nil { + return // fetchFingerprint already called t.Fatal + } + + t.Logf("Profile: %s", tc.Profile.Name) + t.Logf(" JA3: %s", fp.JA3) + t.Logf(" JA3 Hash: %s", fp.JA3Hash) + t.Logf(" JA4: %s", fp.JA4) + t.Logf(" PeetPrint: %s", fp.PeetPrint) + t.Logf(" PeetPrintHash: %s", fp.PeetPrintHash) + + // Verify expectations + if tc.ExpectedJA3 != "" { + if fp.JA3Hash == tc.ExpectedJA3 { + t.Logf(" ✓ JA3 hash matches: %s", tc.ExpectedJA3) + } else { + t.Errorf(" ✗ JA3 hash mismatch: got %s, expected %s", fp.JA3Hash, tc.ExpectedJA3) + } + } + + if tc.ExpectedJA4 != "" { + if fp.JA4 == tc.ExpectedJA4 { + t.Logf(" ✓ JA4 matches: %s", tc.ExpectedJA4) + } else { + t.Errorf(" ✗ JA4 mismatch: got %s, expected %s", fp.JA4, tc.ExpectedJA4) + } + } + + // Check JA4 cipher hash (stable middle part) + // JA4 format: prefix_cipherHash_extHash + if tc.JA4CipherHash != "" { + if strings.Contains(fp.JA4, "_"+tc.JA4CipherHash+"_") { + t.Logf(" ✓ JA4 cipher hash matches: %s", tc.JA4CipherHash) + } else { + t.Errorf(" ✗ JA4 cipher hash mismatch: got %s, expected cipher hash %s", fp.JA4, tc.JA4CipherHash) + } + } + }) + } +} + +// fetchFingerprint makes a request to tls.peet.ws and returns the TLS fingerprint info. +func fetchFingerprint(t *testing.T, profile *Profile) *TLSInfo { + t.Helper() + + dialer := NewDialer(profile, nil) + client := &http.Client{ + Transport: &http.Transport{ + DialTLSContext: dialer.DialTLSContext, + }, + Timeout: 30 * time.Second, + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", "https://tls.peet.ws/api/all", nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + return nil + } + req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/20.0.0") + + resp, err := client.Do(req) + skipIfExternalServiceUnavailable(t, err) + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read response: %v", err) + return nil + } + + var fpResp FingerprintResponse + if err := json.Unmarshal(body, &fpResp); err != nil { + t.Logf("Response body: %s", string(body)) + t.Fatalf("failed to parse fingerprint response: %v", err) + return nil + } + + return &fpResp.TLS +} diff --git a/backend/internal/pkg/tlsfingerprint/dialer_test.go b/backend/internal/pkg/tlsfingerprint/dialer_test.go index 31a59fc7..345067e5 100644 --- a/backend/internal/pkg/tlsfingerprint/dialer_test.go +++ b/backend/internal/pkg/tlsfingerprint/dialer_test.go @@ -1,10 +1,11 @@ // Package tlsfingerprint provides TLS fingerprint simulation for HTTP clients. // -// Integration tests for verifying TLS fingerprint correctness. -// These tests make actual network requests and should be run manually. +// Unit tests for TLS fingerprint dialer. +// Integration tests that require external network are in dialer_integration_test.go +// and require the 'integration' build tag. // -// Run with: go test -v ./internal/pkg/tlsfingerprint/... -// Run integration tests: go test -v -run TestJA3 ./internal/pkg/tlsfingerprint/... +// Run unit tests: go test -v ./internal/pkg/tlsfingerprint/... +// Run integration tests: go test -v -tags=integration ./internal/pkg/tlsfingerprint/... package tlsfingerprint import ( diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 170e5de9..f38f0cfa 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -809,12 +809,21 @@ func (r *accountRepository) SetAntigravityQuotaScopeLimit(ctx context.Context, i return err } - path := "{antigravity_quota_scopes," + string(scope) + "}" + scopeKey := string(scope) client := clientFromContext(ctx, r.client) result, err := client.ExecContext( ctx, - "UPDATE accounts SET extra = jsonb_set(COALESCE(extra, '{}'::jsonb), $1::text[], $2::jsonb, true), updated_at = NOW() WHERE id = $3 AND deleted_at IS NULL", - path, + `UPDATE accounts SET + extra = jsonb_set( + jsonb_set(COALESCE(extra, '{}'::jsonb), '{antigravity_quota_scopes}'::text[], COALESCE(extra->'antigravity_quota_scopes', '{}'::jsonb), true), + ARRAY['antigravity_quota_scopes', $1]::text[], + $2::jsonb, + true + ), + updated_at = NOW(), + last_used_at = NOW() + WHERE id = $3 AND deleted_at IS NULL`, + scopeKey, raw, id, ) @@ -829,6 +838,7 @@ func (r *accountRepository) SetAntigravityQuotaScopeLimit(ctx context.Context, i if affected == 0 { return service.ErrAccountNotFound } + if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventAccountChanged, &id, nil, nil); err != nil { log.Printf("[SchedulerOutbox] enqueue quota scope failed: account=%d err=%v", id, err) } @@ -849,12 +859,19 @@ func (r *accountRepository) SetModelRateLimit(ctx context.Context, id int64, sco return err } - path := "{model_rate_limits," + scope + "}" client := clientFromContext(ctx, r.client) result, err := client.ExecContext( ctx, - "UPDATE accounts SET extra = jsonb_set(COALESCE(extra, '{}'::jsonb), $1::text[], $2::jsonb, true), updated_at = NOW() WHERE id = $3 AND deleted_at IS NULL", - path, + `UPDATE accounts SET + extra = jsonb_set( + jsonb_set(COALESCE(extra, '{}'::jsonb), '{model_rate_limits}'::text[], COALESCE(extra->'model_rate_limits', '{}'::jsonb), true), + ARRAY['model_rate_limits', $1]::text[], + $2::jsonb, + true + ), + updated_at = NOW() + WHERE id = $3 AND deleted_at IS NULL`, + scope, raw, id, ) diff --git a/backend/internal/repository/aes_encryptor.go b/backend/internal/repository/aes_encryptor.go new file mode 100644 index 00000000..924e3698 --- /dev/null +++ b/backend/internal/repository/aes_encryptor.go @@ -0,0 +1,95 @@ +package repository + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "fmt" + "io" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +// AESEncryptor implements SecretEncryptor using AES-256-GCM +type AESEncryptor struct { + key []byte +} + +// NewAESEncryptor creates a new AES encryptor +func NewAESEncryptor(cfg *config.Config) (service.SecretEncryptor, error) { + key, err := hex.DecodeString(cfg.Totp.EncryptionKey) + if err != nil { + return nil, fmt.Errorf("invalid totp encryption key: %w", err) + } + + if len(key) != 32 { + return nil, fmt.Errorf("totp encryption key must be 32 bytes (64 hex chars), got %d bytes", len(key)) + } + + return &AESEncryptor{key: key}, nil +} + +// Encrypt encrypts plaintext using AES-256-GCM +// Output format: base64(nonce + ciphertext + tag) +func (e *AESEncryptor) Encrypt(plaintext string) (string, error) { + block, err := aes.NewCipher(e.key) + if err != nil { + return "", fmt.Errorf("create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("create gcm: %w", err) + } + + // Generate a random nonce + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("generate nonce: %w", err) + } + + // Encrypt the plaintext + // Seal appends the ciphertext and tag to the nonce + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + + // Encode as base64 + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// Decrypt decrypts ciphertext using AES-256-GCM +func (e *AESEncryptor) Decrypt(ciphertext string) (string, error) { + // Decode from base64 + data, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return "", fmt.Errorf("decode base64: %w", err) + } + + block, err := aes.NewCipher(e.key) + if err != nil { + return "", fmt.Errorf("create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("create gcm: %w", err) + } + + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return "", fmt.Errorf("ciphertext too short") + } + + // Extract nonce and ciphertext + nonce, ciphertextData := data[:nonceSize], data[nonceSize:] + + // Decrypt + plaintext, err := gcm.Open(nil, nonce, ciphertextData, nil) + if err != nil { + return "", fmt.Errorf("decrypt: %w", err) + } + + return string(plaintext), nil +} diff --git a/backend/internal/repository/announcement_read_repo.go b/backend/internal/repository/announcement_read_repo.go new file mode 100644 index 00000000..2dc346b1 --- /dev/null +++ b/backend/internal/repository/announcement_read_repo.go @@ -0,0 +1,83 @@ +package repository + +import ( + "context" + "time" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/announcementread" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +type announcementReadRepository struct { + client *dbent.Client +} + +func NewAnnouncementReadRepository(client *dbent.Client) service.AnnouncementReadRepository { + return &announcementReadRepository{client: client} +} + +func (r *announcementReadRepository) MarkRead(ctx context.Context, announcementID, userID int64, readAt time.Time) error { + client := clientFromContext(ctx, r.client) + return client.AnnouncementRead.Create(). + SetAnnouncementID(announcementID). + SetUserID(userID). + SetReadAt(readAt). + OnConflictColumns(announcementread.FieldAnnouncementID, announcementread.FieldUserID). + DoNothing(). + Exec(ctx) +} + +func (r *announcementReadRepository) GetReadMapByUser(ctx context.Context, userID int64, announcementIDs []int64) (map[int64]time.Time, error) { + if len(announcementIDs) == 0 { + return map[int64]time.Time{}, nil + } + + rows, err := r.client.AnnouncementRead.Query(). + Where( + announcementread.UserIDEQ(userID), + announcementread.AnnouncementIDIn(announcementIDs...), + ). + All(ctx) + if err != nil { + return nil, err + } + + out := make(map[int64]time.Time, len(rows)) + for i := range rows { + out[rows[i].AnnouncementID] = rows[i].ReadAt + } + return out, nil +} + +func (r *announcementReadRepository) GetReadMapByUsers(ctx context.Context, announcementID int64, userIDs []int64) (map[int64]time.Time, error) { + if len(userIDs) == 0 { + return map[int64]time.Time{}, nil + } + + rows, err := r.client.AnnouncementRead.Query(). + Where( + announcementread.AnnouncementIDEQ(announcementID), + announcementread.UserIDIn(userIDs...), + ). + All(ctx) + if err != nil { + return nil, err + } + + out := make(map[int64]time.Time, len(rows)) + for i := range rows { + out[rows[i].UserID] = rows[i].ReadAt + } + return out, nil +} + +func (r *announcementReadRepository) CountByAnnouncementID(ctx context.Context, announcementID int64) (int64, error) { + count, err := r.client.AnnouncementRead.Query(). + Where(announcementread.AnnouncementIDEQ(announcementID)). + Count(ctx) + if err != nil { + return 0, err + } + return int64(count), nil +} diff --git a/backend/internal/repository/announcement_repo.go b/backend/internal/repository/announcement_repo.go new file mode 100644 index 00000000..52029e4e --- /dev/null +++ b/backend/internal/repository/announcement_repo.go @@ -0,0 +1,194 @@ +package repository + +import ( + "context" + "time" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/announcement" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +type announcementRepository struct { + client *dbent.Client +} + +func NewAnnouncementRepository(client *dbent.Client) service.AnnouncementRepository { + return &announcementRepository{client: client} +} + +func (r *announcementRepository) Create(ctx context.Context, a *service.Announcement) error { + client := clientFromContext(ctx, r.client) + builder := client.Announcement.Create(). + SetTitle(a.Title). + SetContent(a.Content). + SetStatus(a.Status). + SetTargeting(a.Targeting) + + if a.StartsAt != nil { + builder.SetStartsAt(*a.StartsAt) + } + if a.EndsAt != nil { + builder.SetEndsAt(*a.EndsAt) + } + if a.CreatedBy != nil { + builder.SetCreatedBy(*a.CreatedBy) + } + if a.UpdatedBy != nil { + builder.SetUpdatedBy(*a.UpdatedBy) + } + + created, err := builder.Save(ctx) + if err != nil { + return err + } + + applyAnnouncementEntityToService(a, created) + return nil +} + +func (r *announcementRepository) GetByID(ctx context.Context, id int64) (*service.Announcement, error) { + m, err := r.client.Announcement.Query(). + Where(announcement.IDEQ(id)). + Only(ctx) + if err != nil { + return nil, translatePersistenceError(err, service.ErrAnnouncementNotFound, nil) + } + return announcementEntityToService(m), nil +} + +func (r *announcementRepository) Update(ctx context.Context, a *service.Announcement) error { + client := clientFromContext(ctx, r.client) + builder := client.Announcement.UpdateOneID(a.ID). + SetTitle(a.Title). + SetContent(a.Content). + SetStatus(a.Status). + SetTargeting(a.Targeting) + + if a.StartsAt != nil { + builder.SetStartsAt(*a.StartsAt) + } else { + builder.ClearStartsAt() + } + if a.EndsAt != nil { + builder.SetEndsAt(*a.EndsAt) + } else { + builder.ClearEndsAt() + } + if a.CreatedBy != nil { + builder.SetCreatedBy(*a.CreatedBy) + } else { + builder.ClearCreatedBy() + } + if a.UpdatedBy != nil { + builder.SetUpdatedBy(*a.UpdatedBy) + } else { + builder.ClearUpdatedBy() + } + + updated, err := builder.Save(ctx) + if err != nil { + return translatePersistenceError(err, service.ErrAnnouncementNotFound, nil) + } + + a.UpdatedAt = updated.UpdatedAt + return nil +} + +func (r *announcementRepository) Delete(ctx context.Context, id int64) error { + client := clientFromContext(ctx, r.client) + _, err := client.Announcement.Delete().Where(announcement.IDEQ(id)).Exec(ctx) + return err +} + +func (r *announcementRepository) List( + ctx context.Context, + params pagination.PaginationParams, + filters service.AnnouncementListFilters, +) ([]service.Announcement, *pagination.PaginationResult, error) { + q := r.client.Announcement.Query() + + if filters.Status != "" { + q = q.Where(announcement.StatusEQ(filters.Status)) + } + if filters.Search != "" { + q = q.Where( + announcement.Or( + announcement.TitleContainsFold(filters.Search), + announcement.ContentContainsFold(filters.Search), + ), + ) + } + + total, err := q.Count(ctx) + if err != nil { + return nil, nil, err + } + + items, err := q. + Offset(params.Offset()). + Limit(params.Limit()). + Order(dbent.Desc(announcement.FieldID)). + All(ctx) + if err != nil { + return nil, nil, err + } + + out := announcementEntitiesToService(items) + return out, paginationResultFromTotal(int64(total), params), nil +} + +func (r *announcementRepository) ListActive(ctx context.Context, now time.Time) ([]service.Announcement, error) { + q := r.client.Announcement.Query(). + Where( + announcement.StatusEQ(service.AnnouncementStatusActive), + announcement.Or(announcement.StartsAtIsNil(), announcement.StartsAtLTE(now)), + announcement.Or(announcement.EndsAtIsNil(), announcement.EndsAtGT(now)), + ). + Order(dbent.Desc(announcement.FieldID)) + + items, err := q.All(ctx) + if err != nil { + return nil, err + } + return announcementEntitiesToService(items), nil +} + +func applyAnnouncementEntityToService(dst *service.Announcement, src *dbent.Announcement) { + if dst == nil || src == nil { + return + } + dst.ID = src.ID + dst.CreatedAt = src.CreatedAt + dst.UpdatedAt = src.UpdatedAt +} + +func announcementEntityToService(m *dbent.Announcement) *service.Announcement { + if m == nil { + return nil + } + return &service.Announcement{ + ID: m.ID, + Title: m.Title, + Content: m.Content, + Status: m.Status, + Targeting: m.Targeting, + StartsAt: m.StartsAt, + EndsAt: m.EndsAt, + CreatedBy: m.CreatedBy, + UpdatedBy: m.UpdatedBy, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + } +} + +func announcementEntitiesToService(models []*dbent.Announcement) []service.Announcement { + out := make([]service.Announcement, 0, len(models)) + for i := range models { + if s := announcementEntityToService(models[i]); s != nil { + out = append(out, *s) + } + } + return out +} diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index a020ee2b..25fb88b8 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -391,17 +391,20 @@ func userEntityToService(u *dbent.User) *service.User { return nil } return &service.User{ - ID: u.ID, - Email: u.Email, - Username: u.Username, - Notes: u.Notes, - PasswordHash: u.PasswordHash, - Role: u.Role, - Balance: u.Balance, - Concurrency: u.Concurrency, - Status: u.Status, - CreatedAt: u.CreatedAt, - UpdatedAt: u.UpdatedAt, + ID: u.ID, + Email: u.Email, + Username: u.Username, + Notes: u.Notes, + PasswordHash: u.PasswordHash, + Role: u.Role, + Balance: u.Balance, + Concurrency: u.Concurrency, + Status: u.Status, + TotpSecretEncrypted: u.TotpSecretEncrypted, + TotpEnabled: u.TotpEnabled, + TotpEnabledAt: u.TotpEnabledAt, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, } } diff --git a/backend/internal/repository/email_cache.go b/backend/internal/repository/email_cache.go index e00e35dd..8f2b8eca 100644 --- a/backend/internal/repository/email_cache.go +++ b/backend/internal/repository/email_cache.go @@ -9,13 +9,27 @@ import ( "github.com/redis/go-redis/v9" ) -const verifyCodeKeyPrefix = "verify_code:" +const ( + verifyCodeKeyPrefix = "verify_code:" + passwordResetKeyPrefix = "password_reset:" + passwordResetSentAtKeyPrefix = "password_reset_sent:" +) // verifyCodeKey generates the Redis key for email verification code. func verifyCodeKey(email string) string { return verifyCodeKeyPrefix + email } +// passwordResetKey generates the Redis key for password reset token. +func passwordResetKey(email string) string { + return passwordResetKeyPrefix + email +} + +// passwordResetSentAtKey generates the Redis key for password reset email sent timestamp. +func passwordResetSentAtKey(email string) string { + return passwordResetSentAtKeyPrefix + email +} + type emailCache struct { rdb *redis.Client } @@ -50,3 +64,45 @@ func (c *emailCache) DeleteVerificationCode(ctx context.Context, email string) e key := verifyCodeKey(email) return c.rdb.Del(ctx, key).Err() } + +// Password reset token methods + +func (c *emailCache) GetPasswordResetToken(ctx context.Context, email string) (*service.PasswordResetTokenData, error) { + key := passwordResetKey(email) + val, err := c.rdb.Get(ctx, key).Result() + if err != nil { + return nil, err + } + var data service.PasswordResetTokenData + if err := json.Unmarshal([]byte(val), &data); err != nil { + return nil, err + } + return &data, nil +} + +func (c *emailCache) SetPasswordResetToken(ctx context.Context, email string, data *service.PasswordResetTokenData, ttl time.Duration) error { + key := passwordResetKey(email) + val, err := json.Marshal(data) + if err != nil { + return err + } + return c.rdb.Set(ctx, key, val, ttl).Err() +} + +func (c *emailCache) DeletePasswordResetToken(ctx context.Context, email string) error { + key := passwordResetKey(email) + return c.rdb.Del(ctx, key).Err() +} + +// Password reset email cooldown methods + +func (c *emailCache) IsPasswordResetEmailInCooldown(ctx context.Context, email string) bool { + key := passwordResetSentAtKey(email) + exists, err := c.rdb.Exists(ctx, key).Result() + return err == nil && exists > 0 +} + +func (c *emailCache) SetPasswordResetEmailCooldown(ctx context.Context, email string, ttl time.Duration) error { + key := passwordResetSentAtKey(email) + return c.rdb.Set(ctx, key, "1", ttl).Err() +} diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index 75684fc9..14e5cb86 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -433,3 +433,61 @@ func (r *groupRepository) loadAccountCounts(ctx context.Context, groupIDs []int6 return counts, nil } + +// GetAccountIDsByGroupIDs 获取多个分组的所有账号 ID(去重) +func (r *groupRepository) GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error) { + if len(groupIDs) == 0 { + return nil, nil + } + + rows, err := r.sql.QueryContext( + ctx, + "SELECT DISTINCT account_id FROM account_groups WHERE group_id = ANY($1) ORDER BY account_id", + pq.Array(groupIDs), + ) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var accountIDs []int64 + for rows.Next() { + var accountID int64 + if err := rows.Scan(&accountID); err != nil { + return nil, err + } + accountIDs = append(accountIDs, accountID) + } + if err := rows.Err(); err != nil { + return nil, err + } + + return accountIDs, nil +} + +// BindAccountsToGroup 将多个账号绑定到指定分组(批量插入,忽略已存在的绑定) +func (r *groupRepository) BindAccountsToGroup(ctx context.Context, groupID int64, accountIDs []int64) error { + if len(accountIDs) == 0 { + return nil + } + + // 使用 INSERT ... ON CONFLICT DO NOTHING 忽略已存在的绑定 + _, err := r.sql.ExecContext( + ctx, + `INSERT INTO account_groups (account_id, group_id, priority, created_at) + SELECT unnest($1::bigint[]), $2, 50, NOW() + ON CONFLICT (account_id, group_id) DO NOTHING`, + pq.Array(accountIDs), + groupID, + ) + if err != nil { + return err + } + + // 发送调度器事件 + if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventGroupChanged, nil, &groupID, nil); err != nil { + log.Printf("[SchedulerOutbox] enqueue bind accounts to group failed: group=%d err=%v", groupID, err) + } + + return nil +} diff --git a/backend/internal/repository/openai_oauth_service.go b/backend/internal/repository/openai_oauth_service.go index b7f3606f..394d3a1a 100644 --- a/backend/internal/repository/openai_oauth_service.go +++ b/backend/internal/repository/openai_oauth_service.go @@ -2,11 +2,11 @@ package repository import ( "context" - "fmt" + "net/http" "net/url" - "strings" "time" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/imroc/req/v3" @@ -22,7 +22,7 @@ type openaiOAuthService struct { } func (s *openaiOAuthService) ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI, proxyURL string) (*openai.TokenResponse, error) { - client := createOpenAIReqClient(s.tokenURL, proxyURL) + client := createOpenAIReqClient(proxyURL) if redirectURI == "" { redirectURI = openai.DefaultRedirectURI @@ -39,23 +39,24 @@ func (s *openaiOAuthService) ExchangeCode(ctx context.Context, code, codeVerifie resp, err := client.R(). SetContext(ctx). + SetHeader("User-Agent", "codex-cli/0.91.0"). SetFormDataFromValues(formData). SetSuccessResult(&tokenResp). Post(s.tokenURL) if err != nil { - return nil, fmt.Errorf("request failed: %w", err) + return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_OAUTH_REQUEST_FAILED", "request failed: %v", err) } if !resp.IsSuccessState() { - return nil, fmt.Errorf("token exchange failed: status %d, body: %s", resp.StatusCode, resp.String()) + return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_OAUTH_TOKEN_EXCHANGE_FAILED", "token exchange failed: status %d, body: %s", resp.StatusCode, resp.String()) } return &tokenResp, nil } func (s *openaiOAuthService) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*openai.TokenResponse, error) { - client := createOpenAIReqClient(s.tokenURL, proxyURL) + client := createOpenAIReqClient(proxyURL) formData := url.Values{} formData.Set("grant_type", "refresh_token") @@ -67,29 +68,25 @@ func (s *openaiOAuthService) RefreshToken(ctx context.Context, refreshToken, pro resp, err := client.R(). SetContext(ctx). + SetHeader("User-Agent", "codex-cli/0.91.0"). SetFormDataFromValues(formData). SetSuccessResult(&tokenResp). Post(s.tokenURL) if err != nil { - return nil, fmt.Errorf("request failed: %w", err) + return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_OAUTH_REQUEST_FAILED", "request failed: %v", err) } if !resp.IsSuccessState() { - return nil, fmt.Errorf("token refresh failed: status %d, body: %s", resp.StatusCode, resp.String()) + return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_OAUTH_TOKEN_REFRESH_FAILED", "token refresh failed: status %d, body: %s", resp.StatusCode, resp.String()) } return &tokenResp, nil } -func createOpenAIReqClient(tokenURL, proxyURL string) *req.Client { - forceHTTP2 := false - if parsedURL, err := url.Parse(tokenURL); err == nil { - forceHTTP2 = strings.EqualFold(parsedURL.Scheme, "https") - } +func createOpenAIReqClient(proxyURL string) *req.Client { return getSharedReqClient(reqClientOptions{ - ProxyURL: proxyURL, - Timeout: 120 * time.Second, - ForceHTTP2: forceHTTP2, + ProxyURL: proxyURL, + Timeout: 120 * time.Second, }) } diff --git a/backend/internal/repository/proxy_probe_service.go b/backend/internal/repository/proxy_probe_service.go index fb6f405e..513e929c 100644 --- a/backend/internal/repository/proxy_probe_service.go +++ b/backend/internal/repository/proxy_probe_service.go @@ -28,7 +28,6 @@ func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber { log.Printf("[ProxyProbe] Warning: insecure_skip_verify is not allowed and will cause probe failure.") } return &proxyProbeService{ - ipInfoURL: defaultIPInfoURL, insecureSkipVerify: insecure, allowPrivateHosts: allowPrivate, validateResolvedIP: validateResolvedIP, @@ -36,12 +35,20 @@ func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber { } const ( - defaultIPInfoURL = "http://ip-api.com/json/?lang=zh-CN" defaultProxyProbeTimeout = 30 * time.Second ) +// probeURLs 按优先级排列的探测 URL 列表 +// 某些 AI API 专用代理只允许访问特定域名,因此需要多个备选 +var probeURLs = []struct { + url string + parser string // "ip-api" or "httpbin" +}{ + {"http://ip-api.com/json/?lang=zh-CN", "ip-api"}, + {"http://httpbin.org/ip", "httpbin"}, +} + type proxyProbeService struct { - ipInfoURL string insecureSkipVerify bool allowPrivateHosts bool validateResolvedIP bool @@ -60,8 +67,21 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s return nil, 0, fmt.Errorf("failed to create proxy client: %w", err) } + var lastErr error + for _, probe := range probeURLs { + exitInfo, latencyMs, err := s.probeWithURL(ctx, client, probe.url, probe.parser) + if err == nil { + return exitInfo, latencyMs, nil + } + lastErr = err + } + + return nil, 0, fmt.Errorf("all probe URLs failed, last error: %w", lastErr) +} + +func (s *proxyProbeService) probeWithURL(ctx context.Context, client *http.Client, url string, parser string) (*service.ProxyExitInfo, int64, error) { startTime := time.Now() - req, err := http.NewRequestWithContext(ctx, "GET", s.ipInfoURL, nil) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, 0, fmt.Errorf("failed to create request: %w", err) } @@ -78,6 +98,22 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s return nil, latencyMs, fmt.Errorf("request failed with status: %d", resp.StatusCode) } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, latencyMs, fmt.Errorf("failed to read response: %w", err) + } + + switch parser { + case "ip-api": + return s.parseIPAPI(body, latencyMs) + case "httpbin": + return s.parseHTTPBin(body, latencyMs) + default: + return nil, latencyMs, fmt.Errorf("unknown parser: %s", parser) + } +} + +func (s *proxyProbeService) parseIPAPI(body []byte, latencyMs int64) (*service.ProxyExitInfo, int64, error) { var ipInfo struct { Status string `json:"status"` Message string `json:"message"` @@ -89,13 +125,12 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s CountryCode string `json:"countryCode"` } - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, latencyMs, fmt.Errorf("failed to read response: %w", err) - } - if err := json.Unmarshal(body, &ipInfo); err != nil { - return nil, latencyMs, fmt.Errorf("failed to parse response: %w", err) + preview := string(body) + if len(preview) > 200 { + preview = preview[:200] + "..." + } + return nil, latencyMs, fmt.Errorf("failed to parse response: %w (body: %s)", err, preview) } if strings.ToLower(ipInfo.Status) != "success" { if ipInfo.Message == "" { @@ -116,3 +151,19 @@ func (s *proxyProbeService) ProbeProxy(ctx context.Context, proxyURL string) (*s CountryCode: ipInfo.CountryCode, }, latencyMs, nil } + +func (s *proxyProbeService) parseHTTPBin(body []byte, latencyMs int64) (*service.ProxyExitInfo, int64, error) { + // httpbin.org/ip 返回格式: {"origin": "1.2.3.4"} + var result struct { + Origin string `json:"origin"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, latencyMs, fmt.Errorf("failed to parse httpbin response: %w", err) + } + if result.Origin == "" { + return nil, latencyMs, fmt.Errorf("httpbin: no IP found in response") + } + return &service.ProxyExitInfo{ + IP: result.Origin, + }, latencyMs, nil +} diff --git a/backend/internal/repository/proxy_probe_service_test.go b/backend/internal/repository/proxy_probe_service_test.go index f1cd5721..7450653b 100644 --- a/backend/internal/repository/proxy_probe_service_test.go +++ b/backend/internal/repository/proxy_probe_service_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/require" @@ -21,7 +22,6 @@ type ProxyProbeServiceSuite struct { func (s *ProxyProbeServiceSuite) SetupTest() { s.ctx = context.Background() s.prober = &proxyProbeService{ - ipInfoURL: "http://ip-api.test/json/?lang=zh-CN", allowPrivateHosts: true, } } @@ -49,12 +49,16 @@ func (s *ProxyProbeServiceSuite) TestProbeProxy_UnsupportedProxyScheme() { require.ErrorContains(s.T(), err, "failed to create proxy client") } -func (s *ProxyProbeServiceSuite) TestProbeProxy_Success() { - seen := make(chan string, 1) +func (s *ProxyProbeServiceSuite) TestProbeProxy_Success_IPAPI() { s.setupProxyServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - seen <- r.RequestURI - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, `{"status":"success","query":"1.2.3.4","city":"c","regionName":"r","country":"cc","countryCode":"CC"}`) + // 检查是否是 ip-api 请求 + if strings.Contains(r.RequestURI, "ip-api.com") { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"status":"success","query":"1.2.3.4","city":"c","regionName":"r","country":"cc","countryCode":"CC"}`) + return + } + // 其他请求返回错误 + w.WriteHeader(http.StatusServiceUnavailable) })) info, latencyMs, err := s.prober.ProbeProxy(s.ctx, s.proxySrv.URL) @@ -65,45 +69,59 @@ func (s *ProxyProbeServiceSuite) TestProbeProxy_Success() { require.Equal(s.T(), "r", info.Region) require.Equal(s.T(), "cc", info.Country) require.Equal(s.T(), "CC", info.CountryCode) - - // Verify proxy received the request - select { - case uri := <-seen: - require.Contains(s.T(), uri, "ip-api.test", "expected request to go through proxy") - default: - require.Fail(s.T(), "expected proxy to receive request") - } } -func (s *ProxyProbeServiceSuite) TestProbeProxy_NonOKStatus() { +func (s *ProxyProbeServiceSuite) TestProbeProxy_Success_HTTPBinFallback() { + s.setupProxyServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // ip-api 失败 + if strings.Contains(r.RequestURI, "ip-api.com") { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + // httpbin 成功 + if strings.Contains(r.RequestURI, "httpbin.org") { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"origin": "5.6.7.8"}`) + return + } + w.WriteHeader(http.StatusServiceUnavailable) + })) + + info, latencyMs, err := s.prober.ProbeProxy(s.ctx, s.proxySrv.URL) + require.NoError(s.T(), err, "ProbeProxy should fallback to httpbin") + require.GreaterOrEqual(s.T(), latencyMs, int64(0), "unexpected latency") + require.Equal(s.T(), "5.6.7.8", info.IP) +} + +func (s *ProxyProbeServiceSuite) TestProbeProxy_AllFailed() { s.setupProxyServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) })) _, _, err := s.prober.ProbeProxy(s.ctx, s.proxySrv.URL) require.Error(s.T(), err) - require.ErrorContains(s.T(), err, "status: 503") + require.ErrorContains(s.T(), err, "all probe URLs failed") } func (s *ProxyProbeServiceSuite) TestProbeProxy_InvalidJSON() { s.setupProxyServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, "not-json") + if strings.Contains(r.RequestURI, "ip-api.com") { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, "not-json") + return + } + // httpbin 也返回无效响应 + if strings.Contains(r.RequestURI, "httpbin.org") { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, "not-json") + return + } + w.WriteHeader(http.StatusServiceUnavailable) })) _, _, err := s.prober.ProbeProxy(s.ctx, s.proxySrv.URL) require.Error(s.T(), err) - require.ErrorContains(s.T(), err, "failed to parse response") -} - -func (s *ProxyProbeServiceSuite) TestProbeProxy_InvalidIPInfoURL() { - s.prober.ipInfoURL = "://invalid-url" - s.setupProxyServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - - _, _, err := s.prober.ProbeProxy(s.ctx, s.proxySrv.URL) - require.Error(s.T(), err, "expected error for invalid ipInfoURL") + require.ErrorContains(s.T(), err, "all probe URLs failed") } func (s *ProxyProbeServiceSuite) TestProbeProxy_ProxyServerClosed() { @@ -114,6 +132,40 @@ func (s *ProxyProbeServiceSuite) TestProbeProxy_ProxyServerClosed() { require.Error(s.T(), err, "expected error when proxy server is closed") } +func (s *ProxyProbeServiceSuite) TestParseIPAPI_Success() { + body := []byte(`{"status":"success","query":"1.2.3.4","city":"Beijing","regionName":"Beijing","country":"China","countryCode":"CN"}`) + info, latencyMs, err := s.prober.parseIPAPI(body, 100) + require.NoError(s.T(), err) + require.Equal(s.T(), int64(100), latencyMs) + require.Equal(s.T(), "1.2.3.4", info.IP) + require.Equal(s.T(), "Beijing", info.City) + require.Equal(s.T(), "Beijing", info.Region) + require.Equal(s.T(), "China", info.Country) + require.Equal(s.T(), "CN", info.CountryCode) +} + +func (s *ProxyProbeServiceSuite) TestParseIPAPI_Failure() { + body := []byte(`{"status":"fail","message":"rate limited"}`) + _, _, err := s.prober.parseIPAPI(body, 100) + require.Error(s.T(), err) + require.ErrorContains(s.T(), err, "rate limited") +} + +func (s *ProxyProbeServiceSuite) TestParseHTTPBin_Success() { + body := []byte(`{"origin": "9.8.7.6"}`) + info, latencyMs, err := s.prober.parseHTTPBin(body, 50) + require.NoError(s.T(), err) + require.Equal(s.T(), int64(50), latencyMs) + require.Equal(s.T(), "9.8.7.6", info.IP) +} + +func (s *ProxyProbeServiceSuite) TestParseHTTPBin_NoIP() { + body := []byte(`{"origin": ""}`) + _, _, err := s.prober.parseHTTPBin(body, 50) + require.Error(s.T(), err) + require.ErrorContains(s.T(), err, "no IP found") +} + func TestProxyProbeServiceSuite(t *testing.T) { suite.Run(t, new(ProxyProbeServiceSuite)) } diff --git a/backend/internal/repository/redeem_code_repo.go b/backend/internal/repository/redeem_code_repo.go index ee8a01b5..a3a048c3 100644 --- a/backend/internal/repository/redeem_code_repo.go +++ b/backend/internal/repository/redeem_code_repo.go @@ -202,6 +202,57 @@ func (r *redeemCodeRepository) ListByUser(ctx context.Context, userID int64, lim return redeemCodeEntitiesToService(codes), nil } +// ListByUserPaginated returns paginated balance/concurrency history for a user. +// Supports optional type filter (e.g. "balance", "admin_balance", "concurrency", "admin_concurrency", "subscription"). +func (r *redeemCodeRepository) ListByUserPaginated(ctx context.Context, userID int64, params pagination.PaginationParams, codeType string) ([]service.RedeemCode, *pagination.PaginationResult, error) { + q := r.client.RedeemCode.Query(). + Where(redeemcode.UsedByEQ(userID)) + + // Optional type filter + if codeType != "" { + q = q.Where(redeemcode.TypeEQ(codeType)) + } + + total, err := q.Count(ctx) + if err != nil { + return nil, nil, err + } + + codes, err := q. + WithGroup(). + Offset(params.Offset()). + Limit(params.Limit()). + Order(dbent.Desc(redeemcode.FieldUsedAt)). + All(ctx) + if err != nil { + return nil, nil, err + } + + return redeemCodeEntitiesToService(codes), paginationResultFromTotal(int64(total), params), nil +} + +// SumPositiveBalanceByUser returns total recharged amount (sum of value > 0 where type is balance/admin_balance). +func (r *redeemCodeRepository) SumPositiveBalanceByUser(ctx context.Context, userID int64) (float64, error) { + var result []struct { + Sum float64 `json:"sum"` + } + err := r.client.RedeemCode.Query(). + Where( + redeemcode.UsedByEQ(userID), + redeemcode.ValueGT(0), + redeemcode.TypeIn("balance", "admin_balance"), + ). + Aggregate(dbent.As(dbent.Sum(redeemcode.FieldValue), "sum")). + Scan(ctx, &result) + if err != nil { + return 0, err + } + if len(result) == 0 { + return 0, nil + } + return result[0].Sum, nil +} + func redeemCodeEntityToService(m *dbent.RedeemCode) *service.RedeemCode { if m == nil { return nil diff --git a/backend/internal/repository/redis.go b/backend/internal/repository/redis.go index f3606ad9..2b4ee4e6 100644 --- a/backend/internal/repository/redis.go +++ b/backend/internal/repository/redis.go @@ -1,6 +1,7 @@ package repository import ( + "crypto/tls" "time" "github.com/Wei-Shaw/sub2api/internal/config" @@ -26,7 +27,7 @@ func InitRedis(cfg *config.Config) *redis.Client { // buildRedisOptions 构建 Redis 连接选项 // 从配置文件读取连接池和超时参数,支持生产环境调优 func buildRedisOptions(cfg *config.Config) *redis.Options { - return &redis.Options{ + opts := &redis.Options{ Addr: cfg.Redis.Address(), Password: cfg.Redis.Password, DB: cfg.Redis.DB, @@ -36,4 +37,13 @@ func buildRedisOptions(cfg *config.Config) *redis.Options { PoolSize: cfg.Redis.PoolSize, // 连接池大小 MinIdleConns: cfg.Redis.MinIdleConns, // 最小空闲连接 } + + if cfg.Redis.EnableTLS { + opts.TLSConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + ServerName: cfg.Redis.Host, + } + } + + return opts } diff --git a/backend/internal/repository/redis_test.go b/backend/internal/repository/redis_test.go index 756a63dc..7cb31002 100644 --- a/backend/internal/repository/redis_test.go +++ b/backend/internal/repository/redis_test.go @@ -32,4 +32,16 @@ func TestBuildRedisOptions(t *testing.T) { require.Equal(t, 4*time.Second, opts.WriteTimeout) require.Equal(t, 100, opts.PoolSize) require.Equal(t, 10, opts.MinIdleConns) + require.Nil(t, opts.TLSConfig) + + // Test case with TLS enabled + cfgTLS := &config.Config{ + Redis: config.RedisConfig{ + Host: "localhost", + EnableTLS: true, + }, + } + optsTLS := buildRedisOptions(cfgTLS) + require.NotNil(t, optsTLS.TLSConfig) + require.Equal(t, "localhost", optsTLS.TLSConfig.ServerName) } diff --git a/backend/internal/repository/req_client_pool_test.go b/backend/internal/repository/req_client_pool_test.go index cf7e8bd0..904ed4d6 100644 --- a/backend/internal/repository/req_client_pool_test.go +++ b/backend/internal/repository/req_client_pool_test.go @@ -77,21 +77,9 @@ func TestGetSharedReqClient_ImpersonateAndProxy(t *testing.T) { require.Equal(t, "http://proxy.local:8080|4s|true|false", buildReqClientKey(opts)) } -func TestCreateOpenAIReqClient_ForceHTTP2Enabled(t *testing.T) { - sharedReqClients = sync.Map{} - client := createOpenAIReqClient("https://auth.openai.com/oauth/token", "http://proxy.local:8080") - require.Equal(t, "2", forceHTTPVersion(t, client)) -} - -func TestCreateOpenAIReqClient_ForceHTTP2DisabledForHTTP(t *testing.T) { - sharedReqClients = sync.Map{} - client := createOpenAIReqClient("http://localhost/oauth/token", "http://proxy.local:8080") - require.Equal(t, "", forceHTTPVersion(t, client)) -} - func TestCreateOpenAIReqClient_Timeout120Seconds(t *testing.T) { sharedReqClients = sync.Map{} - client := createOpenAIReqClient("https://auth.openai.com/oauth/token", "http://proxy.local:8080") + client := createOpenAIReqClient("http://proxy.local:8080") require.Equal(t, 120*time.Second, client.GetClient().Timeout) } diff --git a/backend/internal/repository/scheduler_cache.go b/backend/internal/repository/scheduler_cache.go index 13b22107..4f447e4f 100644 --- a/backend/internal/repository/scheduler_cache.go +++ b/backend/internal/repository/scheduler_cache.go @@ -58,7 +58,9 @@ func (c *schedulerCache) GetSnapshot(ctx context.Context, bucket service.Schedul return nil, false, err } if len(ids) == 0 { - return []*service.Account{}, true, nil + // 空快照视为缓存未命中,触发数据库回退查询 + // 这解决了新分组创建后立即绑定账号时的竞态条件问题 + return nil, false, nil } keys := make([]string, 0, len(ids)) diff --git a/backend/internal/repository/totp_cache.go b/backend/internal/repository/totp_cache.go new file mode 100644 index 00000000..2f4a8ab2 --- /dev/null +++ b/backend/internal/repository/totp_cache.go @@ -0,0 +1,149 @@ +package repository + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/redis/go-redis/v9" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +const ( + totpSetupKeyPrefix = "totp:setup:" + totpLoginKeyPrefix = "totp:login:" + totpAttemptsKeyPrefix = "totp:attempts:" + totpAttemptsTTL = 15 * time.Minute +) + +// TotpCache implements service.TotpCache using Redis +type TotpCache struct { + rdb *redis.Client +} + +// NewTotpCache creates a new TOTP cache +func NewTotpCache(rdb *redis.Client) service.TotpCache { + return &TotpCache{rdb: rdb} +} + +// GetSetupSession retrieves a TOTP setup session +func (c *TotpCache) GetSetupSession(ctx context.Context, userID int64) (*service.TotpSetupSession, error) { + key := fmt.Sprintf("%s%d", totpSetupKeyPrefix, userID) + data, err := c.rdb.Get(ctx, key).Bytes() + if err != nil { + if err == redis.Nil { + return nil, nil + } + return nil, fmt.Errorf("get setup session: %w", err) + } + + var session service.TotpSetupSession + if err := json.Unmarshal(data, &session); err != nil { + return nil, fmt.Errorf("unmarshal setup session: %w", err) + } + + return &session, nil +} + +// SetSetupSession stores a TOTP setup session +func (c *TotpCache) SetSetupSession(ctx context.Context, userID int64, session *service.TotpSetupSession, ttl time.Duration) error { + key := fmt.Sprintf("%s%d", totpSetupKeyPrefix, userID) + data, err := json.Marshal(session) + if err != nil { + return fmt.Errorf("marshal setup session: %w", err) + } + + if err := c.rdb.Set(ctx, key, data, ttl).Err(); err != nil { + return fmt.Errorf("set setup session: %w", err) + } + + return nil +} + +// DeleteSetupSession deletes a TOTP setup session +func (c *TotpCache) DeleteSetupSession(ctx context.Context, userID int64) error { + key := fmt.Sprintf("%s%d", totpSetupKeyPrefix, userID) + return c.rdb.Del(ctx, key).Err() +} + +// GetLoginSession retrieves a TOTP login session +func (c *TotpCache) GetLoginSession(ctx context.Context, tempToken string) (*service.TotpLoginSession, error) { + key := totpLoginKeyPrefix + tempToken + data, err := c.rdb.Get(ctx, key).Bytes() + if err != nil { + if err == redis.Nil { + return nil, nil + } + return nil, fmt.Errorf("get login session: %w", err) + } + + var session service.TotpLoginSession + if err := json.Unmarshal(data, &session); err != nil { + return nil, fmt.Errorf("unmarshal login session: %w", err) + } + + return &session, nil +} + +// SetLoginSession stores a TOTP login session +func (c *TotpCache) SetLoginSession(ctx context.Context, tempToken string, session *service.TotpLoginSession, ttl time.Duration) error { + key := totpLoginKeyPrefix + tempToken + data, err := json.Marshal(session) + if err != nil { + return fmt.Errorf("marshal login session: %w", err) + } + + if err := c.rdb.Set(ctx, key, data, ttl).Err(); err != nil { + return fmt.Errorf("set login session: %w", err) + } + + return nil +} + +// DeleteLoginSession deletes a TOTP login session +func (c *TotpCache) DeleteLoginSession(ctx context.Context, tempToken string) error { + key := totpLoginKeyPrefix + tempToken + return c.rdb.Del(ctx, key).Err() +} + +// IncrementVerifyAttempts increments the verify attempt counter +func (c *TotpCache) IncrementVerifyAttempts(ctx context.Context, userID int64) (int, error) { + key := fmt.Sprintf("%s%d", totpAttemptsKeyPrefix, userID) + + // Use pipeline for atomic increment and set TTL + pipe := c.rdb.Pipeline() + incrCmd := pipe.Incr(ctx, key) + pipe.Expire(ctx, key, totpAttemptsTTL) + + if _, err := pipe.Exec(ctx); err != nil { + return 0, fmt.Errorf("increment verify attempts: %w", err) + } + + count, err := incrCmd.Result() + if err != nil { + return 0, fmt.Errorf("get increment result: %w", err) + } + + return int(count), nil +} + +// GetVerifyAttempts gets the current verify attempt count +func (c *TotpCache) GetVerifyAttempts(ctx context.Context, userID int64) (int, error) { + key := fmt.Sprintf("%s%d", totpAttemptsKeyPrefix, userID) + count, err := c.rdb.Get(ctx, key).Int() + if err != nil { + if err == redis.Nil { + return 0, nil + } + return 0, fmt.Errorf("get verify attempts: %w", err) + } + return count, nil +} + +// ClearVerifyAttempts clears the verify attempt counter +func (c *TotpCache) ClearVerifyAttempts(ctx context.Context, userID int64) error { + key := fmt.Sprintf("%s%d", totpAttemptsKeyPrefix, userID) + return c.rdb.Del(ctx, key).Err() +} diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index 0696c958..c53b7bad 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -22,7 +22,7 @@ import ( "github.com/lib/pq" ) -const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, stream, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, media_type, created_at" +const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, stream, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, media_type, reasoning_effort, created_at" type usageLogRepository struct { client *dbent.Client @@ -115,6 +115,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog) image_count, image_size, media_type, + reasoning_effort, created_at ) VALUES ( $1, $2, $3, $4, $5, @@ -122,7 +123,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog) $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, - $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31 + $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32 ) ON CONFLICT (request_id, api_key_id) DO NOTHING RETURNING id, created_at @@ -136,6 +137,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog) ipAddress := nullString(log.IPAddress) imageSize := nullString(log.ImageSize) mediaType := nullString(log.MediaType) + reasoningEffort := nullString(log.ReasoningEffort) var requestIDArg any if requestID != "" { @@ -173,6 +175,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog) log.ImageCount, imageSize, mediaType, + reasoningEffort, createdAt, } if err := scanSingleRow(ctx, sqlq, query, args, &log.ID, &log.CreatedAt); err != nil { @@ -2094,6 +2097,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e imageCount int imageSize sql.NullString mediaType sql.NullString + reasoningEffort sql.NullString createdAt time.Time ) @@ -2129,6 +2133,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e &imageCount, &imageSize, &mediaType, + &reasoningEffort, &createdAt, ); err != nil { return nil, err @@ -2191,6 +2196,9 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e if mediaType.Valid { log.MediaType = &mediaType.String } + if reasoningEffort.Valid { + log.ReasoningEffort = &reasoningEffort.String + } return log, nil } diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index 006a5464..654bd16b 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -7,6 +7,7 @@ import ( "fmt" "sort" "strings" + "time" dbent "github.com/Wei-Shaw/sub2api/ent" dbuser "github.com/Wei-Shaw/sub2api/ent/user" @@ -189,6 +190,7 @@ func (r *userRepository) ListWithFilters(ctx context.Context, params pagination. dbuser.Or( dbuser.EmailContainsFold(filters.Search), dbuser.UsernameContainsFold(filters.Search), + dbuser.NotesContainsFold(filters.Search), ), ) } @@ -466,3 +468,46 @@ func applyUserEntityToService(dst *service.User, src *dbent.User) { dst.CreatedAt = src.CreatedAt dst.UpdatedAt = src.UpdatedAt } + +// UpdateTotpSecret 更新用户的 TOTP 加密密钥 +func (r *userRepository) UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error { + client := clientFromContext(ctx, r.client) + update := client.User.UpdateOneID(userID) + if encryptedSecret == nil { + update = update.ClearTotpSecretEncrypted() + } else { + update = update.SetTotpSecretEncrypted(*encryptedSecret) + } + _, err := update.Save(ctx) + if err != nil { + return translatePersistenceError(err, service.ErrUserNotFound, nil) + } + return nil +} + +// EnableTotp 启用用户的 TOTP 双因素认证 +func (r *userRepository) EnableTotp(ctx context.Context, userID int64) error { + client := clientFromContext(ctx, r.client) + _, err := client.User.UpdateOneID(userID). + SetTotpEnabled(true). + SetTotpEnabledAt(time.Now()). + Save(ctx) + if err != nil { + return translatePersistenceError(err, service.ErrUserNotFound, nil) + } + return nil +} + +// DisableTotp 禁用用户的 TOTP 双因素认证 +func (r *userRepository) DisableTotp(ctx context.Context, userID int64) error { + client := clientFromContext(ctx, r.client) + _, err := client.User.UpdateOneID(userID). + SetTotpEnabled(false). + ClearTotpEnabledAt(). + ClearTotpSecretEncrypted(). + Save(ctx) + if err != nil { + return translatePersistenceError(err, service.ErrUserNotFound, nil) + } + return nil +} diff --git a/backend/internal/repository/user_subscription_repo.go b/backend/internal/repository/user_subscription_repo.go index cd3b9db6..5a649846 100644 --- a/backend/internal/repository/user_subscription_repo.go +++ b/backend/internal/repository/user_subscription_repo.go @@ -190,7 +190,7 @@ func (r *userSubscriptionRepository) ListByGroupID(ctx context.Context, groupID return userSubscriptionEntitiesToService(subs), paginationResultFromTotal(int64(total), params), nil } -func (r *userSubscriptionRepository) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status string) ([]service.UserSubscription, *pagination.PaginationResult, error) { +func (r *userSubscriptionRepository) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) { client := clientFromContext(ctx, r.client) q := client.UserSubscription.Query() if userID != nil { @@ -199,7 +199,31 @@ func (r *userSubscriptionRepository) List(ctx context.Context, params pagination if groupID != nil { q = q.Where(usersubscription.GroupIDEQ(*groupID)) } - if status != "" { + + // Status filtering with real-time expiration check + now := time.Now() + switch status { + case service.SubscriptionStatusActive: + // Active: status is active AND not yet expired + q = q.Where( + usersubscription.StatusEQ(service.SubscriptionStatusActive), + usersubscription.ExpiresAtGT(now), + ) + case service.SubscriptionStatusExpired: + // Expired: status is expired OR (status is active but already expired) + q = q.Where( + usersubscription.Or( + usersubscription.StatusEQ(service.SubscriptionStatusExpired), + usersubscription.And( + usersubscription.StatusEQ(service.SubscriptionStatusActive), + usersubscription.ExpiresAtLTE(now), + ), + ), + ) + case "": + // No filter + default: + // Other status (e.g., revoked) q = q.Where(usersubscription.StatusEQ(status)) } @@ -208,11 +232,28 @@ func (r *userSubscriptionRepository) List(ctx context.Context, params pagination return nil, nil, err } + // Apply sorting + q = q.WithUser().WithGroup().WithAssignedByUser() + + // Determine sort field + var field string + switch sortBy { + case "expires_at": + field = usersubscription.FieldExpiresAt + case "status": + field = usersubscription.FieldStatus + default: + field = usersubscription.FieldCreatedAt + } + + // Determine sort order (default: desc) + if sortOrder == "asc" && sortBy != "" { + q = q.Order(dbent.Asc(field)) + } else { + q = q.Order(dbent.Desc(field)) + } + subs, err := q. - WithUser(). - WithGroup(). - WithAssignedByUser(). - Order(dbent.Desc(usersubscription.FieldCreatedAt)). Offset(params.Offset()). Limit(params.Limit()). All(ctx) diff --git a/backend/internal/repository/user_subscription_repo_integration_test.go b/backend/internal/repository/user_subscription_repo_integration_test.go index 2099e5d8..60a5a378 100644 --- a/backend/internal/repository/user_subscription_repo_integration_test.go +++ b/backend/internal/repository/user_subscription_repo_integration_test.go @@ -271,7 +271,7 @@ func (s *UserSubscriptionRepoSuite) TestList_NoFilters() { group := s.mustCreateGroup("g-list") s.mustCreateSubscription(user.ID, group.ID, nil) - subs, page, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, nil, nil, "") + subs, page, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, nil, nil, "", "", "") s.Require().NoError(err, "List") s.Require().Len(subs, 1) s.Require().Equal(int64(1), page.Total) @@ -285,7 +285,7 @@ func (s *UserSubscriptionRepoSuite) TestList_FilterByUserID() { s.mustCreateSubscription(user1.ID, group.ID, nil) s.mustCreateSubscription(user2.ID, group.ID, nil) - subs, _, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, &user1.ID, nil, "") + subs, _, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, &user1.ID, nil, "", "", "") s.Require().NoError(err) s.Require().Len(subs, 1) s.Require().Equal(user1.ID, subs[0].UserID) @@ -299,7 +299,7 @@ func (s *UserSubscriptionRepoSuite) TestList_FilterByGroupID() { s.mustCreateSubscription(user.ID, g1.ID, nil) s.mustCreateSubscription(user.ID, g2.ID, nil) - subs, _, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, nil, &g1.ID, "") + subs, _, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, nil, &g1.ID, "", "", "") s.Require().NoError(err) s.Require().Len(subs, 1) s.Require().Equal(g1.ID, subs[0].GroupID) @@ -320,7 +320,7 @@ func (s *UserSubscriptionRepoSuite) TestList_FilterByStatus() { c.SetExpiresAt(time.Now().Add(-24 * time.Hour)) }) - subs, _, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, nil, nil, service.SubscriptionStatusExpired) + subs, _, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, nil, nil, service.SubscriptionStatusExpired, "", "") s.Require().NoError(err) s.Require().Len(subs, 1) s.Require().Equal(service.SubscriptionStatusExpired, subs[0].Status) diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 929eb22b..8d76f014 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -57,6 +57,8 @@ var ProviderSet = wire.NewSet( NewProxyRepository, NewRedeemCodeRepository, NewPromoCodeRepository, + NewAnnouncementRepository, + NewAnnouncementReadRepository, NewUsageLogRepository, NewUsageCleanupRepository, NewDashboardAggregationRepository, @@ -83,6 +85,10 @@ var ProviderSet = wire.NewSet( NewSchedulerCache, NewSchedulerOutboxRepository, NewProxyLatencyCache, + NewTotpCache, + + // Encryptors + NewAESEncryptor, // HTTP service ports (DI Strategy A: return interface directly) NewTurnstileVerifier, diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 409a7625..73809ee1 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -201,7 +201,7 @@ func TestAPIContracts(t *testing.T) { UserID: 1, GroupID: 10, StartsAt: deps.now, - ExpiresAt: deps.now.Add(24 * time.Hour), + ExpiresAt: time.Date(2099, 1, 2, 3, 4, 5, 0, time.UTC), // 使用未来日期避免 normalizeSubscriptionStatus 标记为过期 Status: service.SubscriptionStatusActive, DailyUsageUSD: 1.23, WeeklyUsageUSD: 2.34, @@ -226,7 +226,7 @@ func TestAPIContracts(t *testing.T) { "user_id": 1, "group_id": 10, "starts_at": "2025-01-02T03:04:05Z", - "expires_at": "2025-01-03T03:04:05Z", + "expires_at": "2099-01-02T03:04:05Z", "status": "active", "daily_window_start": null, "weekly_window_start": null, @@ -457,6 +457,9 @@ func TestAPIContracts(t *testing.T) { "registration_enabled": true, "email_verify_enabled": false, "promo_code_enabled": true, + "password_reset_enabled": false, + "totp_enabled": false, + "totp_encryption_key_configured": false, "smtp_host": "smtp.example.com", "smtp_port": 587, "smtp_username": "user", @@ -490,8 +493,11 @@ func TestAPIContracts(t *testing.T) { "fallback_model_openai": "gpt-4o", "enable_identity_patch": true, "identity_patch_prompt": "", + "invitation_code_enabled": false, "home_content": "", - "hide_ccs_import_button": false + "hide_ccs_import_button": false, + "purchase_subscription_enabled": false, + "purchase_subscription_url": "" } }`, }, @@ -600,7 +606,7 @@ func newContractDeps(t *testing.T) *contractDeps { settingService := service.NewSettingService(settingRepo, cfg) adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, nil, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil) - authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil) + authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, redeemService, nil) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) usageHandler := handler.NewUsageHandler(usageService, apiKeyService) adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil, nil) @@ -759,6 +765,18 @@ func (r *stubUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID return 0, errors.New("not implemented") } +func (r *stubUserRepo) UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error { + return errors.New("not implemented") +} + +func (r *stubUserRepo) EnableTotp(ctx context.Context, userID int64) error { + return errors.New("not implemented") +} + +func (r *stubUserRepo) DisableTotp(ctx context.Context, userID int64) error { + return errors.New("not implemented") +} + type stubApiKeyCache struct{} func (stubApiKeyCache) GetCreateAttemptCount(ctx context.Context, userID int64) (int, error) { @@ -868,6 +886,14 @@ func (stubGroupRepo) DeleteAccountGroupsByGroupID(ctx context.Context, groupID i return 0, errors.New("not implemented") } +func (stubGroupRepo) BindAccountsToGroup(ctx context.Context, groupID int64, accountIDs []int64) error { + return errors.New("not implemented") +} + +func (stubGroupRepo) GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error) { + return nil, errors.New("not implemented") +} + type stubAccountRepo struct { bulkUpdateIDs []int64 } @@ -1133,6 +1159,14 @@ func (r *stubRedeemCodeRepo) ListByUser(ctx context.Context, userID int64, limit return append([]service.RedeemCode(nil), codes...), nil } +func (stubRedeemCodeRepo) ListByUserPaginated(ctx context.Context, userID int64, params pagination.PaginationParams, codeType string) ([]service.RedeemCode, *pagination.PaginationResult, error) { + return nil, nil, errors.New("not implemented") +} + +func (stubRedeemCodeRepo) SumPositiveBalanceByUser(ctx context.Context, userID int64) (float64, error) { + return 0, errors.New("not implemented") +} + type stubUserSubscriptionRepo struct { byUser map[int64][]service.UserSubscription activeByUser map[int64][]service.UserSubscription @@ -1185,7 +1219,7 @@ func (r *stubUserSubscriptionRepo) ListActiveByUserID(ctx context.Context, userI func (stubUserSubscriptionRepo) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.UserSubscription, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } -func (stubUserSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status string) ([]service.UserSubscription, *pagination.PaginationResult, error) { +func (stubUserSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } func (stubUserSubscriptionRepo) ExistsByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) { diff --git a/backend/internal/server/middleware/api_key_auth_test.go b/backend/internal/server/middleware/api_key_auth_test.go index 84398093..920ff93f 100644 --- a/backend/internal/server/middleware/api_key_auth_test.go +++ b/backend/internal/server/middleware/api_key_auth_test.go @@ -367,7 +367,7 @@ func (r *stubUserSubscriptionRepo) ListByGroupID(ctx context.Context, groupID in return nil, nil, errors.New("not implemented") } -func (r *stubUserSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status string) ([]service.UserSubscription, *pagination.PaginationResult, error) { +func (r *stubUserSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 050e724d..ca9d627e 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -29,6 +29,9 @@ func RegisterAdminRoutes( // 账号管理 registerAccountRoutes(admin, h) + // 公告管理 + registerAnnouncementRoutes(admin, h) + // OpenAI OAuth registerOpenAIOAuthRoutes(admin, h) @@ -172,6 +175,7 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) { users.POST("/:id/balance", h.Admin.User.UpdateBalance) users.GET("/:id/api-keys", h.Admin.User.GetUserAPIKeys) users.GET("/:id/usage", h.Admin.User.GetUserUsage) + users.GET("/:id/balance-history", h.Admin.User.GetBalanceHistory) // User attribute values users.GET("/:id/attributes", h.Admin.UserAttribute.GetUserAttributes) @@ -229,6 +233,18 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) { } } +func registerAnnouncementRoutes(admin *gin.RouterGroup, h *handler.Handlers) { + announcements := admin.Group("/announcements") + { + announcements.GET("", h.Admin.Announcement.List) + announcements.POST("", h.Admin.Announcement.Create) + announcements.GET("/:id", h.Admin.Announcement.GetByID) + announcements.PUT("/:id", h.Admin.Announcement.Update) + announcements.DELETE("/:id", h.Admin.Announcement.Delete) + announcements.GET("/:id/read-status", h.Admin.Announcement.ListReadStatus) + } +} + func registerOpenAIOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) { openai := admin.Group("/openai") { diff --git a/backend/internal/server/routes/auth.go b/backend/internal/server/routes/auth.go index aa691eba..24f6d549 100644 --- a/backend/internal/server/routes/auth.go +++ b/backend/internal/server/routes/auth.go @@ -26,11 +26,24 @@ func RegisterAuthRoutes( { auth.POST("/register", h.Auth.Register) auth.POST("/login", h.Auth.Login) + auth.POST("/login/2fa", h.Auth.Login2FA) auth.POST("/send-verify-code", h.Auth.SendVerifyCode) // 优惠码验证接口添加速率限制:每分钟最多 10 次(Redis 故障时 fail-close) auth.POST("/validate-promo-code", rateLimiter.LimitWithOptions("validate-promo", 10, time.Minute, middleware.RateLimitOptions{ FailureMode: middleware.RateLimitFailClose, }), h.Auth.ValidatePromoCode) + // 邀请码验证接口添加速率限制:每分钟最多 10 次(Redis 故障时 fail-close) + auth.POST("/validate-invitation-code", rateLimiter.LimitWithOptions("validate-invitation", 10, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), h.Auth.ValidateInvitationCode) + // 忘记密码接口添加速率限制:每分钟最多 5 次(Redis 故障时 fail-close) + auth.POST("/forgot-password", rateLimiter.LimitWithOptions("forgot-password", 5, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), h.Auth.ForgotPassword) + // 重置密码接口添加速率限制:每分钟最多 10 次(Redis 故障时 fail-close) + auth.POST("/reset-password", rateLimiter.LimitWithOptions("reset-password", 10, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), h.Auth.ResetPassword) auth.GET("/oauth/linuxdo/start", h.Auth.LinuxDoOAuthStart) auth.GET("/oauth/linuxdo/callback", h.Auth.LinuxDoOAuthCallback) } diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go index ad2166fe..5581e1e1 100644 --- a/backend/internal/server/routes/user.go +++ b/backend/internal/server/routes/user.go @@ -22,6 +22,17 @@ func RegisterUserRoutes( user.GET("/profile", h.User.GetProfile) user.PUT("/password", h.User.ChangePassword) user.PUT("", h.User.UpdateProfile) + + // TOTP 双因素认证 + totp := user.Group("/totp") + { + totp.GET("/status", h.Totp.GetStatus) + totp.GET("/verification-method", h.Totp.GetVerificationMethod) + totp.POST("/send-code", h.Totp.SendVerifyCode) + totp.POST("/setup", h.Totp.InitiateSetup) + totp.POST("/enable", h.Totp.Enable) + totp.POST("/disable", h.Totp.Disable) + } } // API Key管理 @@ -53,6 +64,13 @@ func RegisterUserRoutes( usage.POST("/dashboard/api-keys-usage", h.Usage.DashboardAPIKeysUsage) } + // 公告(用户可见) + announcements := authenticated.Group("/announcements") + { + announcements.GET("", h.Announcement.List) + announcements.POST("/:id/read", h.Announcement.MarkRead) + } + // 卡密兑换 redeem := authenticated.Group("/redeem") { diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 182e0161..7b958838 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -410,6 +410,22 @@ func (a *Account) GetExtraString(key string) string { return "" } +func (a *Account) GetClaudeUserID() string { + if v := strings.TrimSpace(a.GetExtraString("claude_user_id")); v != "" { + return v + } + if v := strings.TrimSpace(a.GetExtraString("anthropic_user_id")); v != "" { + return v + } + if v := strings.TrimSpace(a.GetCredential("claude_user_id")); v != "" { + return v + } + if v := strings.TrimSpace(a.GetCredential("anthropic_user_id")); v != "" { + return v + } + return "" +} + func (a *Account) IsCustomErrorCodesEnabled() bool { if a.Type != AccountTypeAPIKey || a.Credentials == nil { return false diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index a76c4d20..acb6eb69 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -124,7 +124,7 @@ func createTestPayload(modelID string) (map[string]any, error) { "system": []map[string]any{ { "type": "text", - "text": "You are Claude Code, Anthropic's official CLI for Claude.", + "text": claudeCodeSystemPrompt, "cache_control": map[string]string{ "type": "ephemeral", }, diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 94b18322..b1b37e11 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -22,6 +22,10 @@ type AdminService interface { UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int) ([]APIKey, int64, error) GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error) + // GetUserBalanceHistory returns paginated balance/concurrency change records for a user. + // codeType is optional - pass empty string to return all types. + // Also returns totalRecharged (sum of all positive balance top-ups). + GetUserBalanceHistory(ctx context.Context, userID int64, page, pageSize int, codeType string) ([]RedeemCode, int64, float64, error) // Group management ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool) ([]Group, int64, error) @@ -115,6 +119,8 @@ type CreateGroupInput struct { // 模型路由配置(仅 anthropic 平台使用) ModelRouting map[string][]int64 ModelRoutingEnabled bool // 是否启用模型路由 + // 从指定分组复制账号(创建分组后在同一事务内绑定) + CopyAccountsFromGroupIDs []int64 } type UpdateGroupInput struct { @@ -142,6 +148,8 @@ type UpdateGroupInput struct { // 模型路由配置(仅 anthropic 平台使用) ModelRouting map[string][]int64 ModelRoutingEnabled *bool // 是否启用模型路由 + // 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号) + CopyAccountsFromGroupIDs []int64 } type CreateAccountInput struct { @@ -535,6 +543,21 @@ func (s *adminServiceImpl) GetUserUsageStats(ctx context.Context, userID int64, }, nil } +// GetUserBalanceHistory returns paginated balance/concurrency change records for a user. +func (s *adminServiceImpl) GetUserBalanceHistory(ctx context.Context, userID int64, page, pageSize int, codeType string) ([]RedeemCode, int64, float64, error) { + params := pagination.PaginationParams{Page: page, PageSize: pageSize} + codes, result, err := s.redeemCodeRepo.ListByUserPaginated(ctx, userID, params, codeType) + if err != nil { + return nil, 0, 0, err + } + // Aggregate total recharged amount (only once, regardless of type filter) + totalRecharged, err := s.redeemCodeRepo.SumPositiveBalanceByUser(ctx, userID) + if err != nil { + return nil, 0, 0, err + } + return codes, result.Total, totalRecharged, nil +} + // Group management implementations func (s *adminServiceImpl) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool) ([]Group, int64, error) { params := pagination.PaginationParams{Page: page, PageSize: pageSize} @@ -589,6 +612,38 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn } } + // 如果指定了复制账号的源分组,先获取账号 ID 列表 + var accountIDsToCopy []int64 + if len(input.CopyAccountsFromGroupIDs) > 0 { + // 去重源分组 IDs + seen := make(map[int64]struct{}) + uniqueSourceGroupIDs := make([]int64, 0, len(input.CopyAccountsFromGroupIDs)) + for _, srcGroupID := range input.CopyAccountsFromGroupIDs { + if _, exists := seen[srcGroupID]; !exists { + seen[srcGroupID] = struct{}{} + uniqueSourceGroupIDs = append(uniqueSourceGroupIDs, srcGroupID) + } + } + + // 校验源分组的平台是否与新分组一致 + for _, srcGroupID := range uniqueSourceGroupIDs { + srcGroup, err := s.groupRepo.GetByIDLite(ctx, srcGroupID) + if err != nil { + return nil, fmt.Errorf("source group %d not found: %w", srcGroupID, err) + } + if srcGroup.Platform != platform { + return nil, fmt.Errorf("source group %d platform mismatch: expected %s, got %s", srcGroupID, platform, srcGroup.Platform) + } + } + + // 获取所有源分组的账号(去重) + var err error + accountIDsToCopy, err = s.groupRepo.GetAccountIDsByGroupIDs(ctx, uniqueSourceGroupIDs) + if err != nil { + return nil, fmt.Errorf("failed to get accounts from source groups: %w", err) + } + } + group := &Group{ Name: input.Name, Description: input.Description, @@ -614,6 +669,15 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn if err := s.groupRepo.Create(ctx, group); err != nil { return nil, err } + + // 如果有需要复制的账号,绑定到新分组 + if len(accountIDsToCopy) > 0 { + if err := s.groupRepo.BindAccountsToGroup(ctx, group.ID, accountIDsToCopy); err != nil { + return nil, fmt.Errorf("failed to bind accounts to new group: %w", err) + } + group.AccountCount = int64(len(accountIDsToCopy)) + } + return group, nil } @@ -761,6 +825,54 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if err := s.groupRepo.Update(ctx, group); err != nil { return nil, err } + + // 如果指定了复制账号的源分组,同步绑定(替换当前分组的账号) + if len(input.CopyAccountsFromGroupIDs) > 0 { + // 去重源分组 IDs + seen := make(map[int64]struct{}) + uniqueSourceGroupIDs := make([]int64, 0, len(input.CopyAccountsFromGroupIDs)) + for _, srcGroupID := range input.CopyAccountsFromGroupIDs { + // 校验:源分组不能是自身 + if srcGroupID == id { + return nil, fmt.Errorf("cannot copy accounts from self") + } + // 去重 + if _, exists := seen[srcGroupID]; !exists { + seen[srcGroupID] = struct{}{} + uniqueSourceGroupIDs = append(uniqueSourceGroupIDs, srcGroupID) + } + } + + // 校验源分组的平台是否与当前分组一致 + for _, srcGroupID := range uniqueSourceGroupIDs { + srcGroup, err := s.groupRepo.GetByIDLite(ctx, srcGroupID) + if err != nil { + return nil, fmt.Errorf("source group %d not found: %w", srcGroupID, err) + } + if srcGroup.Platform != group.Platform { + return nil, fmt.Errorf("source group %d platform mismatch: expected %s, got %s", srcGroupID, group.Platform, srcGroup.Platform) + } + } + + // 获取所有源分组的账号(去重) + accountIDsToCopy, err := s.groupRepo.GetAccountIDsByGroupIDs(ctx, uniqueSourceGroupIDs) + if err != nil { + return nil, fmt.Errorf("failed to get accounts from source groups: %w", err) + } + + // 先清空当前分组的所有账号绑定 + if _, err := s.groupRepo.DeleteAccountGroupsByGroupID(ctx, id); err != nil { + return nil, fmt.Errorf("failed to clear existing account bindings: %w", err) + } + + // 再绑定源分组的账号 + if len(accountIDsToCopy) > 0 { + if err := s.groupRepo.BindAccountsToGroup(ctx, id, accountIDsToCopy); err != nil { + return nil, fmt.Errorf("failed to bind accounts to group: %w", err) + } + } + } + if s.authCacheInvalidator != nil { s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, id) } diff --git a/backend/internal/service/admin_service_delete_test.go b/backend/internal/service/admin_service_delete_test.go index afa433af..e2aa83d9 100644 --- a/backend/internal/service/admin_service_delete_test.go +++ b/backend/internal/service/admin_service_delete_test.go @@ -93,6 +93,18 @@ func (s *userRepoStub) RemoveGroupFromAllowedGroups(ctx context.Context, groupID panic("unexpected RemoveGroupFromAllowedGroups call") } +func (s *userRepoStub) UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error { + panic("unexpected UpdateTotpSecret call") +} + +func (s *userRepoStub) EnableTotp(ctx context.Context, userID int64) error { + panic("unexpected EnableTotp call") +} + +func (s *userRepoStub) DisableTotp(ctx context.Context, userID int64) error { + panic("unexpected DisableTotp call") +} + type groupRepoStub struct { affectedUserIDs []int64 deleteErr error @@ -152,6 +164,14 @@ func (s *groupRepoStub) DeleteAccountGroupsByGroupID(ctx context.Context, groupI panic("unexpected DeleteAccountGroupsByGroupID call") } +func (s *groupRepoStub) BindAccountsToGroup(ctx context.Context, groupID int64, accountIDs []int64) error { + panic("unexpected BindAccountsToGroup call") +} + +func (s *groupRepoStub) GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error) { + panic("unexpected GetAccountIDsByGroupIDs call") +} + type proxyRepoStub struct { deleteErr error countErr error @@ -262,6 +282,14 @@ func (s *redeemRepoStub) ListByUser(ctx context.Context, userID int64, limit int panic("unexpected ListByUser call") } +func (s *redeemRepoStub) ListByUserPaginated(ctx context.Context, userID int64, params pagination.PaginationParams, codeType string) ([]RedeemCode, *pagination.PaginationResult, error) { + panic("unexpected ListByUserPaginated call") +} + +func (s *redeemRepoStub) SumPositiveBalanceByUser(ctx context.Context, userID int64) (float64, error) { + panic("unexpected SumPositiveBalanceByUser call") +} + type subscriptionInvalidateCall struct { userID int64 groupID int64 diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index e0574e2e..1daee89f 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -108,6 +108,14 @@ func (s *groupRepoStubForAdmin) DeleteAccountGroupsByGroupID(_ context.Context, panic("unexpected DeleteAccountGroupsByGroupID call") } +func (s *groupRepoStubForAdmin) BindAccountsToGroup(_ context.Context, _ int64, _ []int64) error { + panic("unexpected BindAccountsToGroup call") +} + +func (s *groupRepoStubForAdmin) GetAccountIDsByGroupIDs(_ context.Context, _ []int64) ([]int64, error) { + panic("unexpected GetAccountIDsByGroupIDs call") +} + // TestAdminService_CreateGroup_WithImagePricing 测试创建分组时 ImagePrice 字段正确传递 func TestAdminService_CreateGroup_WithImagePricing(t *testing.T) { repo := &groupRepoStubForAdmin{} @@ -378,3 +386,11 @@ func (s *groupRepoStubForFallbackCycle) GetAccountCount(_ context.Context, _ int func (s *groupRepoStubForFallbackCycle) DeleteAccountGroupsByGroupID(_ context.Context, _ int64) (int64, error) { panic("unexpected DeleteAccountGroupsByGroupID call") } + +func (s *groupRepoStubForFallbackCycle) BindAccountsToGroup(_ context.Context, _ int64, _ []int64) error { + panic("unexpected BindAccountsToGroup call") +} + +func (s *groupRepoStubForFallbackCycle) GetAccountIDsByGroupIDs(_ context.Context, _ []int64) ([]int64, error) { + panic("unexpected GetAccountIDsByGroupIDs call") +} diff --git a/backend/internal/service/admin_service_search_test.go b/backend/internal/service/admin_service_search_test.go index 7506c6db..d661b710 100644 --- a/backend/internal/service/admin_service_search_test.go +++ b/backend/internal/service/admin_service_search_test.go @@ -152,6 +152,14 @@ func (s *redeemRepoStubForAdminList) ListWithFilters(_ context.Context, params p return s.listWithFiltersCodes, result, nil } +func (s *redeemRepoStubForAdminList) ListByUserPaginated(_ context.Context, userID int64, params pagination.PaginationParams, codeType string) ([]RedeemCode, *pagination.PaginationResult, error) { + panic("unexpected ListByUserPaginated call") +} + +func (s *redeemRepoStubForAdminList) SumPositiveBalanceByUser(_ context.Context, userID int64) (float64, error) { + panic("unexpected SumPositiveBalanceByUser call") +} + func TestAdminService_ListAccounts_WithSearch(t *testing.T) { t.Run("search 参数正常传递到 repository 层", func(t *testing.T) { repo := &accountRepoStubForAdminList{ diff --git a/backend/internal/service/announcement.go b/backend/internal/service/announcement.go new file mode 100644 index 00000000..2ba5af5d --- /dev/null +++ b/backend/internal/service/announcement.go @@ -0,0 +1,64 @@ +package service + +import ( + "context" + "time" + + "github.com/Wei-Shaw/sub2api/internal/domain" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" +) + +const ( + AnnouncementStatusDraft = domain.AnnouncementStatusDraft + AnnouncementStatusActive = domain.AnnouncementStatusActive + AnnouncementStatusArchived = domain.AnnouncementStatusArchived +) + +const ( + AnnouncementConditionTypeSubscription = domain.AnnouncementConditionTypeSubscription + AnnouncementConditionTypeBalance = domain.AnnouncementConditionTypeBalance +) + +const ( + AnnouncementOperatorIn = domain.AnnouncementOperatorIn + AnnouncementOperatorGT = domain.AnnouncementOperatorGT + AnnouncementOperatorGTE = domain.AnnouncementOperatorGTE + AnnouncementOperatorLT = domain.AnnouncementOperatorLT + AnnouncementOperatorLTE = domain.AnnouncementOperatorLTE + AnnouncementOperatorEQ = domain.AnnouncementOperatorEQ +) + +var ( + ErrAnnouncementNotFound = domain.ErrAnnouncementNotFound + ErrAnnouncementInvalidTarget = domain.ErrAnnouncementInvalidTarget +) + +type AnnouncementTargeting = domain.AnnouncementTargeting + +type AnnouncementConditionGroup = domain.AnnouncementConditionGroup + +type AnnouncementCondition = domain.AnnouncementCondition + +type Announcement = domain.Announcement + +type AnnouncementListFilters struct { + Status string + Search string +} + +type AnnouncementRepository interface { + Create(ctx context.Context, a *Announcement) error + GetByID(ctx context.Context, id int64) (*Announcement, error) + Update(ctx context.Context, a *Announcement) error + Delete(ctx context.Context, id int64) error + + List(ctx context.Context, params pagination.PaginationParams, filters AnnouncementListFilters) ([]Announcement, *pagination.PaginationResult, error) + ListActive(ctx context.Context, now time.Time) ([]Announcement, error) +} + +type AnnouncementReadRepository interface { + MarkRead(ctx context.Context, announcementID, userID int64, readAt time.Time) error + GetReadMapByUser(ctx context.Context, userID int64, announcementIDs []int64) (map[int64]time.Time, error) + GetReadMapByUsers(ctx context.Context, announcementID int64, userIDs []int64) (map[int64]time.Time, error) + CountByAnnouncementID(ctx context.Context, announcementID int64) (int64, error) +} diff --git a/backend/internal/service/announcement_service.go b/backend/internal/service/announcement_service.go new file mode 100644 index 00000000..c2588e6c --- /dev/null +++ b/backend/internal/service/announcement_service.go @@ -0,0 +1,378 @@ +package service + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/domain" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" +) + +type AnnouncementService struct { + announcementRepo AnnouncementRepository + readRepo AnnouncementReadRepository + userRepo UserRepository + userSubRepo UserSubscriptionRepository +} + +func NewAnnouncementService( + announcementRepo AnnouncementRepository, + readRepo AnnouncementReadRepository, + userRepo UserRepository, + userSubRepo UserSubscriptionRepository, +) *AnnouncementService { + return &AnnouncementService{ + announcementRepo: announcementRepo, + readRepo: readRepo, + userRepo: userRepo, + userSubRepo: userSubRepo, + } +} + +type CreateAnnouncementInput struct { + Title string + Content string + Status string + Targeting AnnouncementTargeting + StartsAt *time.Time + EndsAt *time.Time + ActorID *int64 // 管理员用户ID +} + +type UpdateAnnouncementInput struct { + Title *string + Content *string + Status *string + Targeting *AnnouncementTargeting + StartsAt **time.Time + EndsAt **time.Time + ActorID *int64 // 管理员用户ID +} + +type UserAnnouncement struct { + Announcement Announcement + ReadAt *time.Time +} + +type AnnouncementUserReadStatus struct { + UserID int64 `json:"user_id"` + Email string `json:"email"` + Username string `json:"username"` + Balance float64 `json:"balance"` + Eligible bool `json:"eligible"` + ReadAt *time.Time `json:"read_at,omitempty"` +} + +func (s *AnnouncementService) Create(ctx context.Context, input *CreateAnnouncementInput) (*Announcement, error) { + if input == nil { + return nil, fmt.Errorf("create announcement: nil input") + } + + title := strings.TrimSpace(input.Title) + content := strings.TrimSpace(input.Content) + if title == "" || len(title) > 200 { + return nil, fmt.Errorf("create announcement: invalid title") + } + if content == "" { + return nil, fmt.Errorf("create announcement: content is required") + } + + status := strings.TrimSpace(input.Status) + if status == "" { + status = AnnouncementStatusDraft + } + if !isValidAnnouncementStatus(status) { + return nil, fmt.Errorf("create announcement: invalid status") + } + + targeting, err := domain.AnnouncementTargeting(input.Targeting).NormalizeAndValidate() + if err != nil { + return nil, err + } + + if input.StartsAt != nil && input.EndsAt != nil { + if !input.StartsAt.Before(*input.EndsAt) { + return nil, fmt.Errorf("create announcement: starts_at must be before ends_at") + } + } + + a := &Announcement{ + Title: title, + Content: content, + Status: status, + Targeting: targeting, + StartsAt: input.StartsAt, + EndsAt: input.EndsAt, + } + if input.ActorID != nil && *input.ActorID > 0 { + a.CreatedBy = input.ActorID + a.UpdatedBy = input.ActorID + } + + if err := s.announcementRepo.Create(ctx, a); err != nil { + return nil, fmt.Errorf("create announcement: %w", err) + } + return a, nil +} + +func (s *AnnouncementService) Update(ctx context.Context, id int64, input *UpdateAnnouncementInput) (*Announcement, error) { + if input == nil { + return nil, fmt.Errorf("update announcement: nil input") + } + + a, err := s.announcementRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + if input.Title != nil { + title := strings.TrimSpace(*input.Title) + if title == "" || len(title) > 200 { + return nil, fmt.Errorf("update announcement: invalid title") + } + a.Title = title + } + if input.Content != nil { + content := strings.TrimSpace(*input.Content) + if content == "" { + return nil, fmt.Errorf("update announcement: content is required") + } + a.Content = content + } + if input.Status != nil { + status := strings.TrimSpace(*input.Status) + if !isValidAnnouncementStatus(status) { + return nil, fmt.Errorf("update announcement: invalid status") + } + a.Status = status + } + + if input.Targeting != nil { + targeting, err := domain.AnnouncementTargeting(*input.Targeting).NormalizeAndValidate() + if err != nil { + return nil, err + } + a.Targeting = targeting + } + + if input.StartsAt != nil { + a.StartsAt = *input.StartsAt + } + if input.EndsAt != nil { + a.EndsAt = *input.EndsAt + } + + if a.StartsAt != nil && a.EndsAt != nil { + if !a.StartsAt.Before(*a.EndsAt) { + return nil, fmt.Errorf("update announcement: starts_at must be before ends_at") + } + } + + if input.ActorID != nil && *input.ActorID > 0 { + a.UpdatedBy = input.ActorID + } + + if err := s.announcementRepo.Update(ctx, a); err != nil { + return nil, fmt.Errorf("update announcement: %w", err) + } + return a, nil +} + +func (s *AnnouncementService) Delete(ctx context.Context, id int64) error { + if err := s.announcementRepo.Delete(ctx, id); err != nil { + return fmt.Errorf("delete announcement: %w", err) + } + return nil +} + +func (s *AnnouncementService) GetByID(ctx context.Context, id int64) (*Announcement, error) { + return s.announcementRepo.GetByID(ctx, id) +} + +func (s *AnnouncementService) List(ctx context.Context, params pagination.PaginationParams, filters AnnouncementListFilters) ([]Announcement, *pagination.PaginationResult, error) { + return s.announcementRepo.List(ctx, params, filters) +} + +func (s *AnnouncementService) ListForUser(ctx context.Context, userID int64, unreadOnly bool) ([]UserAnnouncement, error) { + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + + activeSubs, err := s.userSubRepo.ListActiveByUserID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("list active subscriptions: %w", err) + } + activeGroupIDs := make(map[int64]struct{}, len(activeSubs)) + for i := range activeSubs { + activeGroupIDs[activeSubs[i].GroupID] = struct{}{} + } + + now := time.Now() + anns, err := s.announcementRepo.ListActive(ctx, now) + if err != nil { + return nil, fmt.Errorf("list active announcements: %w", err) + } + + visible := make([]Announcement, 0, len(anns)) + ids := make([]int64, 0, len(anns)) + for i := range anns { + a := anns[i] + if !a.IsActiveAt(now) { + continue + } + if !a.Targeting.Matches(user.Balance, activeGroupIDs) { + continue + } + visible = append(visible, a) + ids = append(ids, a.ID) + } + + if len(visible) == 0 { + return []UserAnnouncement{}, nil + } + + readMap, err := s.readRepo.GetReadMapByUser(ctx, userID, ids) + if err != nil { + return nil, fmt.Errorf("get read map: %w", err) + } + + out := make([]UserAnnouncement, 0, len(visible)) + for i := range visible { + a := visible[i] + readAt, ok := readMap[a.ID] + if unreadOnly && ok { + continue + } + var ptr *time.Time + if ok { + t := readAt + ptr = &t + } + out = append(out, UserAnnouncement{ + Announcement: a, + ReadAt: ptr, + }) + } + + // 未读优先、同状态按创建时间倒序 + sort.Slice(out, func(i, j int) bool { + ai, aj := out[i], out[j] + if (ai.ReadAt == nil) != (aj.ReadAt == nil) { + return ai.ReadAt == nil + } + return ai.Announcement.ID > aj.Announcement.ID + }) + + return out, nil +} + +func (s *AnnouncementService) MarkRead(ctx context.Context, userID, announcementID int64) error { + // 安全:仅允许标记当前用户“可见”的公告 + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + return fmt.Errorf("get user: %w", err) + } + + a, err := s.announcementRepo.GetByID(ctx, announcementID) + if err != nil { + return err + } + + now := time.Now() + if !a.IsActiveAt(now) { + return ErrAnnouncementNotFound + } + + activeSubs, err := s.userSubRepo.ListActiveByUserID(ctx, userID) + if err != nil { + return fmt.Errorf("list active subscriptions: %w", err) + } + activeGroupIDs := make(map[int64]struct{}, len(activeSubs)) + for i := range activeSubs { + activeGroupIDs[activeSubs[i].GroupID] = struct{}{} + } + + if !a.Targeting.Matches(user.Balance, activeGroupIDs) { + return ErrAnnouncementNotFound + } + + if err := s.readRepo.MarkRead(ctx, announcementID, userID, now); err != nil { + return fmt.Errorf("mark read: %w", err) + } + return nil +} + +func (s *AnnouncementService) ListUserReadStatus( + ctx context.Context, + announcementID int64, + params pagination.PaginationParams, + search string, +) ([]AnnouncementUserReadStatus, *pagination.PaginationResult, error) { + ann, err := s.announcementRepo.GetByID(ctx, announcementID) + if err != nil { + return nil, nil, err + } + + filters := UserListFilters{ + Search: strings.TrimSpace(search), + } + + users, page, err := s.userRepo.ListWithFilters(ctx, params, filters) + if err != nil { + return nil, nil, fmt.Errorf("list users: %w", err) + } + + userIDs := make([]int64, 0, len(users)) + for i := range users { + userIDs = append(userIDs, users[i].ID) + } + + readMap, err := s.readRepo.GetReadMapByUsers(ctx, announcementID, userIDs) + if err != nil { + return nil, nil, fmt.Errorf("get read map: %w", err) + } + + out := make([]AnnouncementUserReadStatus, 0, len(users)) + for i := range users { + u := users[i] + subs, err := s.userSubRepo.ListActiveByUserID(ctx, u.ID) + if err != nil { + return nil, nil, fmt.Errorf("list active subscriptions: %w", err) + } + activeGroupIDs := make(map[int64]struct{}, len(subs)) + for j := range subs { + activeGroupIDs[subs[j].GroupID] = struct{}{} + } + + readAt, ok := readMap[u.ID] + var ptr *time.Time + if ok { + t := readAt + ptr = &t + } + + out = append(out, AnnouncementUserReadStatus{ + UserID: u.ID, + Email: u.Email, + Username: u.Username, + Balance: u.Balance, + Eligible: domain.AnnouncementTargeting(ann.Targeting).Matches(u.Balance, activeGroupIDs), + ReadAt: ptr, + }) + } + + return out, page, nil +} + +func isValidAnnouncementStatus(status string) bool { + switch status { + case AnnouncementStatusDraft, AnnouncementStatusActive, AnnouncementStatusArchived: + return true + default: + return false + } +} diff --git a/backend/internal/service/announcement_targeting_test.go b/backend/internal/service/announcement_targeting_test.go new file mode 100644 index 00000000..4d904c7d --- /dev/null +++ b/backend/internal/service/announcement_targeting_test.go @@ -0,0 +1,66 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAnnouncementTargeting_Matches_EmptyMatchesAll(t *testing.T) { + var targeting AnnouncementTargeting + require.True(t, targeting.Matches(0, nil)) + require.True(t, targeting.Matches(123.45, map[int64]struct{}{1: {}})) +} + +func TestAnnouncementTargeting_NormalizeAndValidate_RejectsEmptyGroup(t *testing.T) { + targeting := AnnouncementTargeting{ + AnyOf: []AnnouncementConditionGroup{ + {AllOf: nil}, + }, + } + _, err := targeting.NormalizeAndValidate() + require.Error(t, err) + require.ErrorIs(t, err, ErrAnnouncementInvalidTarget) +} + +func TestAnnouncementTargeting_NormalizeAndValidate_RejectsInvalidCondition(t *testing.T) { + targeting := AnnouncementTargeting{ + AnyOf: []AnnouncementConditionGroup{ + { + AllOf: []AnnouncementCondition{ + {Type: "balance", Operator: "between", Value: 10}, + }, + }, + }, + } + _, err := targeting.NormalizeAndValidate() + require.Error(t, err) + require.ErrorIs(t, err, ErrAnnouncementInvalidTarget) +} + +func TestAnnouncementTargeting_Matches_AndOrSemantics(t *testing.T) { + targeting := AnnouncementTargeting{ + AnyOf: []AnnouncementConditionGroup{ + { + AllOf: []AnnouncementCondition{ + {Type: AnnouncementConditionTypeBalance, Operator: AnnouncementOperatorGTE, Value: 100}, + {Type: AnnouncementConditionTypeSubscription, Operator: AnnouncementOperatorIn, GroupIDs: []int64{10}}, + }, + }, + { + AllOf: []AnnouncementCondition{ + {Type: AnnouncementConditionTypeBalance, Operator: AnnouncementOperatorLT, Value: 5}, + }, + }, + }, + } + + // 命中第 2 组(balance < 5) + require.True(t, targeting.Matches(4.99, nil)) + require.False(t, targeting.Matches(5, nil)) + + // 命中第 1 组(balance >= 100 AND 订阅 in [10]) + require.False(t, targeting.Matches(100, map[int64]struct{}{})) + require.False(t, targeting.Matches(99.9, map[int64]struct{}{10: {}})) + require.True(t, targeting.Matches(100, map[int64]struct{}{10: {}})) +} diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index 3b847bcb..9b8156e6 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -273,13 +273,11 @@ func logPrefix(sessionID, accountName string) string { } // Antigravity 直接支持的模型(精确匹配透传) +// 注意:gemini-2.5 系列已移除,统一映射到 gemini-3 系列 var antigravitySupportedModels = map[string]bool{ "claude-opus-4-5-thinking": true, "claude-sonnet-4-5": true, "claude-sonnet-4-5-thinking": true, - "gemini-2.5-flash": true, - "gemini-2.5-flash-lite": true, - "gemini-2.5-flash-thinking": true, "gemini-3-flash": true, "gemini-3-pro-low": true, "gemini-3-pro-high": true, @@ -288,23 +286,32 @@ var antigravitySupportedModels = map[string]bool{ // Antigravity 前缀映射表(按前缀长度降序排列,确保最长匹配优先) // 用于处理模型版本号变化(如 -20251111, -thinking, -preview 等后缀) +// gemini-2.5 系列统一映射到 gemini-3 系列(Antigravity 上游不再支持 2.5) var antigravityPrefixMapping = []struct { prefix string target string }{ - // 长前缀优先 - {"gemini-2.5-flash-image", "gemini-3-pro-image"}, // gemini-2.5-flash-image → 3-pro-image - {"gemini-3-pro-image", "gemini-3-pro-image"}, // gemini-3-pro-image-preview 等 - {"gemini-3-flash", "gemini-3-flash"}, // gemini-3-flash-preview 等 → gemini-3-flash - {"claude-3-5-sonnet", "claude-sonnet-4-5"}, // 旧版 claude-3-5-sonnet-xxx - {"claude-sonnet-4-5", "claude-sonnet-4-5"}, // claude-sonnet-4-5-xxx - {"claude-haiku-4-5", "claude-sonnet-4-5"}, // claude-haiku-4-5-xxx → sonnet + // gemini-2.5 → gemini-3 映射(长前缀优先) + {"gemini-2.5-flash-thinking", "gemini-3-flash"}, // gemini-2.5-flash-thinking → gemini-3-flash + {"gemini-2.5-flash-image", "gemini-3-pro-image"}, // gemini-2.5-flash-image → gemini-3-pro-image + {"gemini-2.5-flash-lite", "gemini-3-flash"}, // gemini-2.5-flash-lite → gemini-3-flash + {"gemini-2.5-flash", "gemini-3-flash"}, // gemini-2.5-flash → gemini-3-flash + {"gemini-2.5-pro-preview", "gemini-3-pro-high"}, // gemini-2.5-pro-preview → gemini-3-pro-high + {"gemini-2.5-pro-exp", "gemini-3-pro-high"}, // gemini-2.5-pro-exp → gemini-3-pro-high + {"gemini-2.5-pro", "gemini-3-pro-high"}, // gemini-2.5-pro → gemini-3-pro-high + // gemini-3 前缀映射 + {"gemini-3-pro-image", "gemini-3-pro-image"}, // gemini-3-pro-image-preview 等 + {"gemini-3-flash", "gemini-3-flash"}, // gemini-3-flash-preview 等 → gemini-3-flash + {"gemini-3-pro", "gemini-3-pro-high"}, // gemini-3-pro, gemini-3-pro-preview 等 + // Claude 映射 + {"claude-3-5-sonnet", "claude-sonnet-4-5"}, // 旧版 claude-3-5-sonnet-xxx + {"claude-sonnet-4-5", "claude-sonnet-4-5"}, // claude-sonnet-4-5-xxx + {"claude-haiku-4-5", "claude-sonnet-4-5"}, // claude-haiku-4-5-xxx → sonnet {"claude-opus-4-5", "claude-opus-4-5-thinking"}, {"claude-3-haiku", "claude-sonnet-4-5"}, // 旧版 claude-3-haiku-xxx → sonnet {"claude-sonnet-4", "claude-sonnet-4-5"}, {"claude-haiku-4", "claude-sonnet-4-5"}, // → sonnet {"claude-opus-4", "claude-opus-4-5-thinking"}, - {"gemini-3-pro", "gemini-3-pro-high"}, // gemini-3-pro, gemini-3-pro-preview 等 } // AntigravityGatewayService 处理 Antigravity 平台的 API 转发 @@ -1530,7 +1537,11 @@ func sleepAntigravityBackoffWithContext(ctx context.Context, attempt int) bool { func antigravityUseScopeRateLimit() bool { v := strings.ToLower(strings.TrimSpace(os.Getenv(antigravityScopeRateLimitEnv))) - return v == "1" || v == "true" || v == "yes" || v == "on" + // 默认开启按配额域限流,只有明确设置为禁用值时才关闭 + if v == "0" || v == "false" || v == "no" || v == "off" { + return false + } + return true } func (s *AntigravityGatewayService) handleUpstreamError(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, quotaScope AntigravityQuotaScope) { diff --git a/backend/internal/service/antigravity_model_mapping_test.go b/backend/internal/service/antigravity_model_mapping_test.go index 179a3520..e269103a 100644 --- a/backend/internal/service/antigravity_model_mapping_test.go +++ b/backend/internal/service/antigravity_model_mapping_test.go @@ -134,18 +134,18 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) { expected: "claude-sonnet-4-5", }, - // 3. Gemini 透传 + // 3. Gemini 2.5 → 3 映射 { - name: "Gemini透传 - gemini-2.5-flash", + name: "Gemini映射 - gemini-2.5-flash → gemini-3-flash", requestedModel: "gemini-2.5-flash", accountMapping: nil, - expected: "gemini-2.5-flash", + expected: "gemini-3-flash", }, { - name: "Gemini透传 - gemini-2.5-pro", + name: "Gemini映射 - gemini-2.5-pro → gemini-3-pro-high", requestedModel: "gemini-2.5-pro", accountMapping: nil, - expected: "gemini-2.5-pro", + expected: "gemini-3-pro-high", }, { name: "Gemini透传 - gemini-future-model", diff --git a/backend/internal/service/antigravity_oauth_service.go b/backend/internal/service/antigravity_oauth_service.go index 52293cd5..fa8379ed 100644 --- a/backend/internal/service/antigravity_oauth_service.go +++ b/backend/internal/service/antigravity_oauth_service.go @@ -142,12 +142,13 @@ func (s *AntigravityOAuthService) ExchangeCode(ctx context.Context, input *Antig result.Email = userInfo.Email } - // 获取 project_id(部分账户类型可能没有) - loadResp, _, err := client.LoadCodeAssist(ctx, tokenResp.AccessToken) - if err != nil { - fmt.Printf("[AntigravityOAuth] 警告: 获取 project_id 失败: %v\n", err) - } else if loadResp != nil && loadResp.CloudAICompanionProject != "" { - result.ProjectID = loadResp.CloudAICompanionProject + // 获取 project_id(部分账户类型可能没有),失败时重试 + projectID, loadErr := s.loadProjectIDWithRetry(ctx, tokenResp.AccessToken, proxyURL, 3) + if loadErr != nil { + fmt.Printf("[AntigravityOAuth] 警告: 获取 project_id 失败(重试后): %v\n", loadErr) + result.ProjectIDMissing = true + } else { + result.ProjectID = projectID } return result, nil @@ -237,21 +238,60 @@ func (s *AntigravityOAuthService) RefreshAccountToken(ctx context.Context, accou tokenInfo.Email = existingEmail } - // 每次刷新都调用 LoadCodeAssist 获取 project_id - client := antigravity.NewClient(proxyURL) - loadResp, _, err := client.LoadCodeAssist(ctx, tokenInfo.AccessToken) - if err != nil || loadResp == nil || loadResp.CloudAICompanionProject == "" { - // LoadCodeAssist 失败或返回空,保留原有 project_id,标记缺失 - existingProjectID := strings.TrimSpace(account.GetCredential("project_id")) + // 每次刷新都调用 LoadCodeAssist 获取 project_id,失败时重试 + existingProjectID := strings.TrimSpace(account.GetCredential("project_id")) + projectID, loadErr := s.loadProjectIDWithRetry(ctx, tokenInfo.AccessToken, proxyURL, 3) + + if loadErr != nil { + // LoadCodeAssist 失败,保留原有 project_id tokenInfo.ProjectID = existingProjectID - tokenInfo.ProjectIDMissing = true + // 只有从未获取过 project_id 且本次也获取失败时,才标记为真正缺失 + // 如果之前有 project_id,本次只是临时故障,不应标记为错误 + if existingProjectID == "" { + tokenInfo.ProjectIDMissing = true + } } else { - tokenInfo.ProjectID = loadResp.CloudAICompanionProject + tokenInfo.ProjectID = projectID } return tokenInfo, nil } +// loadProjectIDWithRetry 带重试机制获取 project_id +// 返回 project_id 和错误,失败时会重试指定次数 +func (s *AntigravityOAuthService) loadProjectIDWithRetry(ctx context.Context, accessToken, proxyURL string, maxRetries int) (string, error) { + var lastErr error + + for attempt := 0; attempt <= maxRetries; attempt++ { + if attempt > 0 { + // 指数退避:1s, 2s, 4s + backoff := time.Duration(1< 8*time.Second { + backoff = 8 * time.Second + } + time.Sleep(backoff) + } + + client := antigravity.NewClient(proxyURL) + loadResp, _, err := client.LoadCodeAssist(ctx, accessToken) + + if err == nil && loadResp != nil && loadResp.CloudAICompanionProject != "" { + return loadResp.CloudAICompanionProject, nil + } + + // 记录错误 + if err != nil { + lastErr = err + } else if loadResp == nil { + lastErr = fmt.Errorf("LoadCodeAssist 返回空响应") + } else { + lastErr = fmt.Errorf("LoadCodeAssist 返回空 project_id") + } + } + + return "", fmt.Errorf("获取 project_id 失败 (重试 %d 次后): %w", maxRetries, lastErr) +} + // BuildAccountCredentials 构建账户凭证 func (s *AntigravityOAuthService) BuildAccountCredentials(tokenInfo *AntigravityTokenInfo) map[string]any { creds := map[string]any{ diff --git a/backend/internal/service/antigravity_quota_scope.go b/backend/internal/service/antigravity_quota_scope.go index a3b2ec66..34cd9a4c 100644 --- a/backend/internal/service/antigravity_quota_scope.go +++ b/backend/internal/service/antigravity_quota_scope.go @@ -89,3 +89,30 @@ func (a *Account) antigravityQuotaScopeResetAt(scope AntigravityQuotaScope) *tim } return &resetAt } + +var antigravityAllScopes = []AntigravityQuotaScope{ + AntigravityQuotaScopeClaude, + AntigravityQuotaScopeGeminiText, + AntigravityQuotaScopeGeminiImage, +} + +func (a *Account) GetAntigravityScopeRateLimits() map[string]int64 { + if a == nil || a.Platform != PlatformAntigravity { + return nil + } + now := time.Now() + result := make(map[string]int64) + for _, scope := range antigravityAllScopes { + resetAt := a.antigravityQuotaScopeResetAt(scope) + if resetAt != nil && now.Before(*resetAt) { + remainingSec := int64(time.Until(*resetAt).Seconds()) + if remainingSec > 0 { + result[string(scope)] = remainingSec + } + } + } + if len(result) == 0 { + return nil + } + return result +} diff --git a/backend/internal/service/antigravity_token_refresher.go b/backend/internal/service/antigravity_token_refresher.go index a07c86e6..e33f88d0 100644 --- a/backend/internal/service/antigravity_token_refresher.go +++ b/backend/internal/service/antigravity_token_refresher.go @@ -3,6 +3,8 @@ package service import ( "context" "fmt" + "log" + "strings" "time" ) @@ -55,15 +57,32 @@ func (r *AntigravityTokenRefresher) Refresh(ctx context.Context, account *Accoun } newCredentials := r.antigravityOAuthService.BuildAccountCredentials(tokenInfo) + // 合并旧的 credentials,保留新 credentials 中不存在的字段 for k, v := range account.Credentials { if _, exists := newCredentials[k]; !exists { newCredentials[k] = v } } - // 如果 project_id 获取失败,返回 credentials 但同时返回错误让账户被标记 + // 特殊处理 project_id:如果新值为空但旧值非空,保留旧值 + // 这确保了即使 LoadCodeAssist 失败,project_id 也不会丢失 + if newProjectID, _ := newCredentials["project_id"].(string); newProjectID == "" { + if oldProjectID := strings.TrimSpace(account.GetCredential("project_id")); oldProjectID != "" { + newCredentials["project_id"] = oldProjectID + } + } + + // 如果 project_id 获取失败,只记录警告,不返回错误 + // LoadCodeAssist 失败可能是临时网络问题,应该允许重试而不是立即标记为不可重试错误 + // Token 刷新本身是成功的(access_token 和 refresh_token 已更新) if tokenInfo.ProjectIDMissing { - return newCredentials, fmt.Errorf("missing_project_id: 账户缺少project id,可能无法使用Antigravity") + if tokenInfo.ProjectID != "" { + // 有旧的 project_id,本次获取失败,保留旧值 + log.Printf("[AntigravityTokenRefresher] Account %d: LoadCodeAssist 临时失败,保留旧 project_id", account.ID) + } else { + // 从未获取过 project_id,本次也失败,但不返回错误以允许下次重试 + log.Printf("[AntigravityTokenRefresher] Account %d: LoadCodeAssist 失败,project_id 缺失,但 token 已更新,将在下次刷新时重试", account.ID) + } } return newCredentials, nil diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 854e7732..c824ec1e 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -19,17 +19,19 @@ import ( ) var ( - ErrInvalidCredentials = infraerrors.Unauthorized("INVALID_CREDENTIALS", "invalid email or password") - ErrUserNotActive = infraerrors.Forbidden("USER_NOT_ACTIVE", "user is not active") - ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists") - ErrEmailReserved = infraerrors.BadRequest("EMAIL_RESERVED", "email is reserved") - ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token") - ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired") - ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large") - ErrTokenRevoked = infraerrors.Unauthorized("TOKEN_REVOKED", "token has been revoked") - ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required") - ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled") - ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable") + ErrInvalidCredentials = infraerrors.Unauthorized("INVALID_CREDENTIALS", "invalid email or password") + ErrUserNotActive = infraerrors.Forbidden("USER_NOT_ACTIVE", "user is not active") + ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists") + ErrEmailReserved = infraerrors.BadRequest("EMAIL_RESERVED", "email is reserved") + ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token") + ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired") + ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large") + ErrTokenRevoked = infraerrors.Unauthorized("TOKEN_REVOKED", "token has been revoked") + ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required") + ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled") + ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable") + ErrInvitationCodeRequired = infraerrors.BadRequest("INVITATION_CODE_REQUIRED", "invitation code is required") + ErrInvitationCodeInvalid = infraerrors.BadRequest("INVITATION_CODE_INVALID", "invalid or used invitation code") ) // maxTokenLength 限制 token 大小,避免超长 header 触发解析时的异常内存分配。 @@ -47,6 +49,7 @@ type JWTClaims struct { // AuthService 认证服务 type AuthService struct { userRepo UserRepository + redeemRepo RedeemCodeRepository cfg *config.Config settingService *SettingService emailService *EmailService @@ -58,6 +61,7 @@ type AuthService struct { // NewAuthService 创建认证服务实例 func NewAuthService( userRepo UserRepository, + redeemRepo RedeemCodeRepository, cfg *config.Config, settingService *SettingService, emailService *EmailService, @@ -67,6 +71,7 @@ func NewAuthService( ) *AuthService { return &AuthService{ userRepo: userRepo, + redeemRepo: redeemRepo, cfg: cfg, settingService: settingService, emailService: emailService, @@ -78,11 +83,11 @@ func NewAuthService( // Register 用户注册,返回token和用户 func (s *AuthService) Register(ctx context.Context, email, password string) (string, *User, error) { - return s.RegisterWithVerification(ctx, email, password, "", "") + return s.RegisterWithVerification(ctx, email, password, "", "", "") } -// RegisterWithVerification 用户注册(支持邮件验证和优惠码),返回token和用户 -func (s *AuthService) RegisterWithVerification(ctx context.Context, email, password, verifyCode, promoCode string) (string, *User, error) { +// RegisterWithVerification 用户注册(支持邮件验证、优惠码和邀请码),返回token和用户 +func (s *AuthService) RegisterWithVerification(ctx context.Context, email, password, verifyCode, promoCode, invitationCode string) (string, *User, error) { // 检查是否开放注册(默认关闭:settingService 未配置时不允许注册) if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) { return "", nil, ErrRegDisabled @@ -93,6 +98,26 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw return "", nil, ErrEmailReserved } + // 检查是否需要邀请码 + var invitationRedeemCode *RedeemCode + if s.settingService != nil && s.settingService.IsInvitationCodeEnabled(ctx) { + if invitationCode == "" { + return "", nil, ErrInvitationCodeRequired + } + // 验证邀请码 + redeemCode, err := s.redeemRepo.GetByCode(ctx, invitationCode) + if err != nil { + log.Printf("[Auth] Invalid invitation code: %s, error: %v", invitationCode, err) + return "", nil, ErrInvitationCodeInvalid + } + // 检查类型和状态 + if redeemCode.Type != RedeemTypeInvitation || redeemCode.Status != StatusUnused { + log.Printf("[Auth] Invitation code invalid: type=%s, status=%s", redeemCode.Type, redeemCode.Status) + return "", nil, ErrInvitationCodeInvalid + } + invitationRedeemCode = redeemCode + } + // 检查是否需要邮件验证 if s.settingService != nil && s.settingService.IsEmailVerifyEnabled(ctx) { // 如果邮件验证已开启但邮件服务未配置,拒绝注册 @@ -153,6 +178,14 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw return "", nil, ErrServiceUnavailable } + // 标记邀请码为已使用(如果使用了邀请码) + if invitationRedeemCode != nil { + if err := s.redeemRepo.Use(ctx, invitationRedeemCode.ID, user.ID); err != nil { + // 邀请码标记失败不影响注册,只记录日志 + log.Printf("[Auth] Failed to mark invitation code as used for user %d: %v", user.ID, err) + } + } + // 应用优惠码(如果提供且功能已启用) if promoCode != "" && s.promoService != nil && s.settingService != nil && s.settingService.IsPromoCodeEnabled(ctx) { if err := s.promoService.ApplyPromoCode(ctx, user.ID, promoCode); err != nil { @@ -580,3 +613,149 @@ func (s *AuthService) RefreshToken(ctx context.Context, oldTokenString string) ( // 生成新token return s.GenerateToken(user) } + +// IsPasswordResetEnabled 检查是否启用密码重置功能 +// 要求:必须同时开启邮件验证且 SMTP 配置正确 +func (s *AuthService) IsPasswordResetEnabled(ctx context.Context) bool { + if s.settingService == nil { + return false + } + // Must have email verification enabled and SMTP configured + if !s.settingService.IsEmailVerifyEnabled(ctx) { + return false + } + return s.settingService.IsPasswordResetEnabled(ctx) +} + +// preparePasswordReset validates the password reset request and returns necessary data +// Returns (siteName, resetURL, shouldProceed) +// shouldProceed is false when we should silently return success (to prevent enumeration) +func (s *AuthService) preparePasswordReset(ctx context.Context, email, frontendBaseURL string) (string, string, bool) { + // Check if user exists (but don't reveal this to the caller) + user, err := s.userRepo.GetByEmail(ctx, email) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + // Security: Log but don't reveal that user doesn't exist + log.Printf("[Auth] Password reset requested for non-existent email: %s", email) + return "", "", false + } + log.Printf("[Auth] Database error checking email for password reset: %v", err) + return "", "", false + } + + // Check if user is active + if !user.IsActive() { + log.Printf("[Auth] Password reset requested for inactive user: %s", email) + return "", "", false + } + + // Get site name + siteName := "Sub2API" + if s.settingService != nil { + siteName = s.settingService.GetSiteName(ctx) + } + + // Build reset URL base + resetURL := fmt.Sprintf("%s/reset-password", strings.TrimSuffix(frontendBaseURL, "/")) + + return siteName, resetURL, true +} + +// RequestPasswordReset 请求密码重置(同步发送) +// Security: Returns the same response regardless of whether the email exists (prevent user enumeration) +func (s *AuthService) RequestPasswordReset(ctx context.Context, email, frontendBaseURL string) error { + if !s.IsPasswordResetEnabled(ctx) { + return infraerrors.Forbidden("PASSWORD_RESET_DISABLED", "password reset is not enabled") + } + if s.emailService == nil { + return ErrServiceUnavailable + } + + siteName, resetURL, shouldProceed := s.preparePasswordReset(ctx, email, frontendBaseURL) + if !shouldProceed { + return nil // Silent success to prevent enumeration + } + + if err := s.emailService.SendPasswordResetEmail(ctx, email, siteName, resetURL); err != nil { + log.Printf("[Auth] Failed to send password reset email to %s: %v", email, err) + return nil // Silent success to prevent enumeration + } + + log.Printf("[Auth] Password reset email sent to: %s", email) + return nil +} + +// RequestPasswordResetAsync 异步请求密码重置(队列发送) +// Security: Returns the same response regardless of whether the email exists (prevent user enumeration) +func (s *AuthService) RequestPasswordResetAsync(ctx context.Context, email, frontendBaseURL string) error { + if !s.IsPasswordResetEnabled(ctx) { + return infraerrors.Forbidden("PASSWORD_RESET_DISABLED", "password reset is not enabled") + } + if s.emailQueueService == nil { + return ErrServiceUnavailable + } + + siteName, resetURL, shouldProceed := s.preparePasswordReset(ctx, email, frontendBaseURL) + if !shouldProceed { + return nil // Silent success to prevent enumeration + } + + if err := s.emailQueueService.EnqueuePasswordReset(email, siteName, resetURL); err != nil { + log.Printf("[Auth] Failed to enqueue password reset email for %s: %v", email, err) + return nil // Silent success to prevent enumeration + } + + log.Printf("[Auth] Password reset email enqueued for: %s", email) + return nil +} + +// ResetPassword 重置密码 +// Security: Increments TokenVersion to invalidate all existing JWT tokens +func (s *AuthService) ResetPassword(ctx context.Context, email, token, newPassword string) error { + // Check if password reset is enabled + if !s.IsPasswordResetEnabled(ctx) { + return infraerrors.Forbidden("PASSWORD_RESET_DISABLED", "password reset is not enabled") + } + + if s.emailService == nil { + return ErrServiceUnavailable + } + + // Verify and consume the reset token (one-time use) + if err := s.emailService.ConsumePasswordResetToken(ctx, email, token); err != nil { + return err + } + + // Get user + user, err := s.userRepo.GetByEmail(ctx, email) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + return ErrInvalidResetToken // Token was valid but user was deleted + } + log.Printf("[Auth] Database error getting user for password reset: %v", err) + return ErrServiceUnavailable + } + + // Check if user is active + if !user.IsActive() { + return ErrUserNotActive + } + + // Hash new password + hashedPassword, err := s.HashPassword(newPassword) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + + // Update password and increment TokenVersion + user.PasswordHash = hashedPassword + user.TokenVersion++ // Invalidate all existing tokens + + if err := s.userRepo.Update(ctx, user); err != nil { + log.Printf("[Auth] Database error updating password for user %d: %v", user.ID, err) + return ErrServiceUnavailable + } + + log.Printf("[Auth] Password reset successful for user: %s", email) + return nil +} diff --git a/backend/internal/service/auth_service_register_test.go b/backend/internal/service/auth_service_register_test.go index bc8f6f68..aa3c769e 100644 --- a/backend/internal/service/auth_service_register_test.go +++ b/backend/internal/service/auth_service_register_test.go @@ -71,6 +71,26 @@ func (s *emailCacheStub) DeleteVerificationCode(ctx context.Context, email strin return nil } +func (s *emailCacheStub) GetPasswordResetToken(ctx context.Context, email string) (*PasswordResetTokenData, error) { + return nil, nil +} + +func (s *emailCacheStub) SetPasswordResetToken(ctx context.Context, email string, data *PasswordResetTokenData, ttl time.Duration) error { + return nil +} + +func (s *emailCacheStub) DeletePasswordResetToken(ctx context.Context, email string) error { + return nil +} + +func (s *emailCacheStub) IsPasswordResetEmailInCooldown(ctx context.Context, email string) bool { + return false +} + +func (s *emailCacheStub) SetPasswordResetEmailCooldown(ctx context.Context, email string, ttl time.Duration) error { + return nil +} + func newAuthService(repo *userRepoStub, settings map[string]string, emailCache EmailCache) *AuthService { cfg := &config.Config{ JWT: config.JWTConfig{ @@ -95,6 +115,7 @@ func newAuthService(repo *userRepoStub, settings map[string]string, emailCache E return NewAuthService( repo, + nil, // redeemRepo cfg, settingService, emailService, @@ -132,7 +153,7 @@ func TestAuthService_Register_EmailVerifyEnabledButServiceNotConfigured(t *testi }, nil) // 应返回服务不可用错误,而不是允许绕过验证 - _, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "any-code", "") + _, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "any-code", "", "") require.ErrorIs(t, err, ErrServiceUnavailable) } @@ -144,7 +165,7 @@ func TestAuthService_Register_EmailVerifyRequired(t *testing.T) { SettingKeyEmailVerifyEnabled: "true", }, cache) - _, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "", "") + _, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "", "", "") require.ErrorIs(t, err, ErrEmailVerifyRequired) } @@ -158,7 +179,7 @@ func TestAuthService_Register_EmailVerifyInvalid(t *testing.T) { SettingKeyEmailVerifyEnabled: "true", }, cache) - _, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "wrong", "") + _, _, err := service.RegisterWithVerification(context.Background(), "user@test.com", "password", "wrong", "", "") require.ErrorIs(t, err, ErrInvalidVerifyCode) require.ErrorContains(t, err, "verify code") } diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 9b72bf6e..5ff2c866 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -241,6 +241,76 @@ func (s *BillingService) CalculateCostWithConfig(model string, tokens UsageToken return s.CalculateCost(model, tokens, multiplier) } +// CalculateCostWithLongContext 计算费用,支持长上下文双倍计费 +// threshold: 阈值(如 200000),超过此值的部分按 extraMultiplier 倍计费 +// extraMultiplier: 超出部分的倍率(如 2.0 表示双倍) +// +// 示例:缓存 210k + 输入 10k = 220k,阈值 200k,倍率 2.0 +// 拆分为:范围内 (200k, 0) + 范围外 (10k, 10k) +// 范围内正常计费,范围外 × 2 计费 +func (s *BillingService) CalculateCostWithLongContext(model string, tokens UsageTokens, rateMultiplier float64, threshold int, extraMultiplier float64) (*CostBreakdown, error) { + // 未启用长上下文计费,直接走正常计费 + if threshold <= 0 || extraMultiplier <= 1 { + return s.CalculateCost(model, tokens, rateMultiplier) + } + + // 计算总输入 token(缓存读取 + 新输入) + total := tokens.CacheReadTokens + tokens.InputTokens + if total <= threshold { + return s.CalculateCost(model, tokens, rateMultiplier) + } + + // 拆分成范围内和范围外 + var inRangeCacheTokens, inRangeInputTokens int + var outRangeCacheTokens, outRangeInputTokens int + + if tokens.CacheReadTokens >= threshold { + // 缓存已超过阈值:范围内只有缓存,范围外是超出的缓存+全部输入 + inRangeCacheTokens = threshold + inRangeInputTokens = 0 + outRangeCacheTokens = tokens.CacheReadTokens - threshold + outRangeInputTokens = tokens.InputTokens + } else { + // 缓存未超过阈值:范围内是全部缓存+部分输入,范围外是剩余输入 + inRangeCacheTokens = tokens.CacheReadTokens + inRangeInputTokens = threshold - tokens.CacheReadTokens + outRangeCacheTokens = 0 + outRangeInputTokens = tokens.InputTokens - inRangeInputTokens + } + + // 范围内部分:正常计费 + inRangeTokens := UsageTokens{ + InputTokens: inRangeInputTokens, + OutputTokens: tokens.OutputTokens, // 输出只算一次 + CacheCreationTokens: tokens.CacheCreationTokens, + CacheReadTokens: inRangeCacheTokens, + } + inRangeCost, err := s.CalculateCost(model, inRangeTokens, rateMultiplier) + if err != nil { + return nil, err + } + + // 范围外部分:× extraMultiplier 计费 + outRangeTokens := UsageTokens{ + InputTokens: outRangeInputTokens, + CacheReadTokens: outRangeCacheTokens, + } + outRangeCost, err := s.CalculateCost(model, outRangeTokens, rateMultiplier*extraMultiplier) + if err != nil { + return inRangeCost, nil // 出错时返回范围内成本 + } + + // 合并成本 + return &CostBreakdown{ + InputCost: inRangeCost.InputCost + outRangeCost.InputCost, + OutputCost: inRangeCost.OutputCost, + CacheCreationCost: inRangeCost.CacheCreationCost, + CacheReadCost: inRangeCost.CacheReadCost + outRangeCost.CacheReadCost, + TotalCost: inRangeCost.TotalCost + outRangeCost.TotalCost, + ActualCost: inRangeCost.ActualCost + outRangeCost.ActualCost, + }, nil +} + // ListSupportedModels 列出所有支持的模型(现在总是返回true,因为有模糊匹配) func (s *BillingService) ListSupportedModels() []string { models := make([]string, 0) diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 31c576ed..0f4f2be0 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -1,67 +1,70 @@ package service +import "github.com/Wei-Shaw/sub2api/internal/domain" + // Status constants const ( - StatusActive = "active" - StatusDisabled = "disabled" - StatusError = "error" - StatusUnused = "unused" - StatusUsed = "used" - StatusExpired = "expired" + StatusActive = domain.StatusActive + StatusDisabled = domain.StatusDisabled + StatusError = domain.StatusError + StatusUnused = domain.StatusUnused + StatusUsed = domain.StatusUsed + StatusExpired = domain.StatusExpired ) // Role constants const ( - RoleAdmin = "admin" - RoleUser = "user" + RoleAdmin = domain.RoleAdmin + RoleUser = domain.RoleUser ) // Platform constants const ( - PlatformAnthropic = "anthropic" - PlatformOpenAI = "openai" - PlatformGemini = "gemini" - PlatformAntigravity = "antigravity" - PlatformSora = "sora" + PlatformAnthropic = domain.PlatformAnthropic + PlatformOpenAI = domain.PlatformOpenAI + PlatformGemini = domain.PlatformGemini + PlatformAntigravity = domain.PlatformAntigravity + PlatformSora = domain.PlatformSora ) // Account type constants const ( - AccountTypeOAuth = "oauth" // OAuth类型账号(full scope: profile + inference) - AccountTypeSetupToken = "setup-token" // Setup Token类型账号(inference only scope) - AccountTypeAPIKey = "apikey" // API Key类型账号 + AccountTypeOAuth = domain.AccountTypeOAuth // OAuth类型账号(full scope: profile + inference) + AccountTypeSetupToken = domain.AccountTypeSetupToken // Setup Token类型账号(inference only scope) + AccountTypeAPIKey = domain.AccountTypeAPIKey // API Key类型账号 ) // Redeem type constants const ( - RedeemTypeBalance = "balance" - RedeemTypeConcurrency = "concurrency" - RedeemTypeSubscription = "subscription" + RedeemTypeBalance = domain.RedeemTypeBalance + RedeemTypeConcurrency = domain.RedeemTypeConcurrency + RedeemTypeSubscription = domain.RedeemTypeSubscription + RedeemTypeInvitation = domain.RedeemTypeInvitation ) // PromoCode status constants const ( - PromoCodeStatusActive = "active" - PromoCodeStatusDisabled = "disabled" + PromoCodeStatusActive = domain.PromoCodeStatusActive + PromoCodeStatusDisabled = domain.PromoCodeStatusDisabled ) // Admin adjustment type constants const ( - AdjustmentTypeAdminBalance = "admin_balance" // 管理员调整余额 - AdjustmentTypeAdminConcurrency = "admin_concurrency" // 管理员调整并发数 + AdjustmentTypeAdminBalance = domain.AdjustmentTypeAdminBalance // 管理员调整余额 + AdjustmentTypeAdminConcurrency = domain.AdjustmentTypeAdminConcurrency // 管理员调整并发数 ) // Group subscription type constants const ( - SubscriptionTypeStandard = "standard" // 标准计费模式(按余额扣费) - SubscriptionTypeSubscription = "subscription" // 订阅模式(按限额控制) + SubscriptionTypeStandard = domain.SubscriptionTypeStandard // 标准计费模式(按余额扣费) + SubscriptionTypeSubscription = domain.SubscriptionTypeSubscription // 订阅模式(按限额控制) ) // Subscription status constants const ( - SubscriptionStatusActive = "active" - SubscriptionStatusExpired = "expired" - SubscriptionStatusSuspended = "suspended" + SubscriptionStatusActive = domain.SubscriptionStatusActive + SubscriptionStatusExpired = domain.SubscriptionStatusExpired + SubscriptionStatusSuspended = domain.SubscriptionStatusSuspended ) // LinuxDoConnectSyntheticEmailDomain 是 LinuxDo Connect 用户的合成邮箱后缀(RFC 保留域名)。 @@ -70,9 +73,11 @@ const LinuxDoConnectSyntheticEmailDomain = "@linuxdo-connect.invalid" // Setting keys const ( // 注册设置 - SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册 - SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证 - SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能 + SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册 + SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证 + SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能 + SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证) + SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册 // 邮件服务设置 SettingKeySMTPHost = "smtp_host" // SMTP服务器地址 @@ -88,6 +93,9 @@ const ( SettingKeyTurnstileSiteKey = "turnstile_site_key" // Turnstile Site Key SettingKeyTurnstileSecretKey = "turnstile_secret_key" // Turnstile Secret Key + // TOTP 双因素认证设置 + SettingKeyTotpEnabled = "totp_enabled" // 是否启用 TOTP 2FA 功能 + // LinuxDo Connect OAuth 登录设置 SettingKeyLinuxDoConnectEnabled = "linuxdo_connect_enabled" SettingKeyLinuxDoConnectClientID = "linuxdo_connect_client_id" @@ -95,14 +103,16 @@ const ( SettingKeyLinuxDoConnectRedirectURL = "linuxdo_connect_redirect_url" // OEM设置 - SettingKeySiteName = "site_name" // 网站名称 - SettingKeySiteLogo = "site_logo" // 网站Logo (base64) - SettingKeySiteSubtitle = "site_subtitle" // 网站副标题 - SettingKeyAPIBaseURL = "api_base_url" // API端点地址(用于客户端配置和导入) - SettingKeyContactInfo = "contact_info" // 客服联系方式 - SettingKeyDocURL = "doc_url" // 文档链接 - SettingKeyHomeContent = "home_content" // 首页内容(支持 Markdown/HTML,或 URL 作为 iframe src) - SettingKeyHideCcsImportButton = "hide_ccs_import_button" // 是否隐藏 API Keys 页面的导入 CCS 按钮 + SettingKeySiteName = "site_name" // 网站名称 + SettingKeySiteLogo = "site_logo" // 网站Logo (base64) + SettingKeySiteSubtitle = "site_subtitle" // 网站副标题 + SettingKeyAPIBaseURL = "api_base_url" // API端点地址(用于客户端配置和导入) + SettingKeyContactInfo = "contact_info" // 客服联系方式 + SettingKeyDocURL = "doc_url" // 文档链接 + SettingKeyHomeContent = "home_content" // 首页内容(支持 Markdown/HTML,或 URL 作为 iframe src) + SettingKeyHideCcsImportButton = "hide_ccs_import_button" // 是否隐藏 API Keys 页面的导入 CCS 按钮 + SettingKeyPurchaseSubscriptionEnabled = "purchase_subscription_enabled" // 是否展示“购买订阅”页面入口 + SettingKeyPurchaseSubscriptionURL = "purchase_subscription_url" // “购买订阅”页面 URL(作为 iframe src) // 默认配置 SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量 diff --git a/backend/internal/service/email_queue_service.go b/backend/internal/service/email_queue_service.go index 1c22702c..6c975c69 100644 --- a/backend/internal/service/email_queue_service.go +++ b/backend/internal/service/email_queue_service.go @@ -8,11 +8,18 @@ import ( "time" ) +// Task type constants +const ( + TaskTypeVerifyCode = "verify_code" + TaskTypePasswordReset = "password_reset" +) + // EmailTask 邮件发送任务 type EmailTask struct { Email string SiteName string - TaskType string // "verify_code" + TaskType string // "verify_code" or "password_reset" + ResetURL string // Only used for password_reset task type } // EmailQueueService 异步邮件队列服务 @@ -73,12 +80,18 @@ func (s *EmailQueueService) processTask(workerID int, task EmailTask) { defer cancel() switch task.TaskType { - case "verify_code": + case TaskTypeVerifyCode: if err := s.emailService.SendVerifyCode(ctx, task.Email, task.SiteName); err != nil { log.Printf("[EmailQueue] Worker %d failed to send verify code to %s: %v", workerID, task.Email, err) } else { log.Printf("[EmailQueue] Worker %d sent verify code to %s", workerID, task.Email) } + case TaskTypePasswordReset: + if err := s.emailService.SendPasswordResetEmailWithCooldown(ctx, task.Email, task.SiteName, task.ResetURL); err != nil { + log.Printf("[EmailQueue] Worker %d failed to send password reset to %s: %v", workerID, task.Email, err) + } else { + log.Printf("[EmailQueue] Worker %d sent password reset to %s", workerID, task.Email) + } default: log.Printf("[EmailQueue] Worker %d unknown task type: %s", workerID, task.TaskType) } @@ -89,7 +102,7 @@ func (s *EmailQueueService) EnqueueVerifyCode(email, siteName string) error { task := EmailTask{ Email: email, SiteName: siteName, - TaskType: "verify_code", + TaskType: TaskTypeVerifyCode, } select { @@ -101,6 +114,24 @@ func (s *EmailQueueService) EnqueueVerifyCode(email, siteName string) error { } } +// EnqueuePasswordReset 将密码重置邮件任务加入队列 +func (s *EmailQueueService) EnqueuePasswordReset(email, siteName, resetURL string) error { + task := EmailTask{ + Email: email, + SiteName: siteName, + TaskType: TaskTypePasswordReset, + ResetURL: resetURL, + } + + select { + case s.taskChan <- task: + log.Printf("[EmailQueue] Enqueued password reset task for %s", email) + return nil + default: + return fmt.Errorf("email queue is full") + } +} + // Stop 停止队列服务 func (s *EmailQueueService) Stop() { close(s.stopChan) diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index 55e137d6..44edf7f7 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -3,11 +3,14 @@ package service import ( "context" "crypto/rand" + "crypto/subtle" "crypto/tls" + "encoding/hex" "fmt" "log" "math/big" "net/smtp" + "net/url" "strconv" "time" @@ -19,6 +22,9 @@ var ( ErrInvalidVerifyCode = infraerrors.BadRequest("INVALID_VERIFY_CODE", "invalid or expired verification code") ErrVerifyCodeTooFrequent = infraerrors.TooManyRequests("VERIFY_CODE_TOO_FREQUENT", "please wait before requesting a new code") ErrVerifyCodeMaxAttempts = infraerrors.TooManyRequests("VERIFY_CODE_MAX_ATTEMPTS", "too many failed attempts, please request a new code") + + // Password reset errors + ErrInvalidResetToken = infraerrors.BadRequest("INVALID_RESET_TOKEN", "invalid or expired password reset token") ) // EmailCache defines cache operations for email service @@ -26,6 +32,16 @@ type EmailCache interface { GetVerificationCode(ctx context.Context, email string) (*VerificationCodeData, error) SetVerificationCode(ctx context.Context, email string, data *VerificationCodeData, ttl time.Duration) error DeleteVerificationCode(ctx context.Context, email string) error + + // Password reset token methods + GetPasswordResetToken(ctx context.Context, email string) (*PasswordResetTokenData, error) + SetPasswordResetToken(ctx context.Context, email string, data *PasswordResetTokenData, ttl time.Duration) error + DeletePasswordResetToken(ctx context.Context, email string) error + + // Password reset email cooldown methods + // Returns true if in cooldown period (email was sent recently) + IsPasswordResetEmailInCooldown(ctx context.Context, email string) bool + SetPasswordResetEmailCooldown(ctx context.Context, email string, ttl time.Duration) error } // VerificationCodeData represents verification code data @@ -35,10 +51,22 @@ type VerificationCodeData struct { CreatedAt time.Time } +// PasswordResetTokenData represents password reset token data +type PasswordResetTokenData struct { + Token string + CreatedAt time.Time +} + const ( verifyCodeTTL = 15 * time.Minute verifyCodeCooldown = 1 * time.Minute maxVerifyCodeAttempts = 5 + + // Password reset token settings + passwordResetTokenTTL = 30 * time.Minute + + // Password reset email cooldown (prevent email bombing) + passwordResetEmailCooldown = 30 * time.Second ) // SMTPConfig SMTP配置 @@ -254,8 +282,8 @@ func (s *EmailService) VerifyCode(ctx context.Context, email, code string) error return ErrVerifyCodeMaxAttempts } - // 验证码不匹配 - if data.Code != code { + // 验证码不匹配 (constant-time comparison to prevent timing attacks) + if subtle.ConstantTimeCompare([]byte(data.Code), []byte(code)) != 1 { data.Attempts++ if err := s.cache.SetVerificationCode(ctx, email, data, verifyCodeTTL); err != nil { log.Printf("[Email] Failed to update verification attempt count: %v", err) @@ -357,3 +385,157 @@ func (s *EmailService) TestSMTPConnectionWithConfig(config *SMTPConfig) error { return client.Quit() } + +// GeneratePasswordResetToken generates a secure 32-byte random token (64 hex characters) +func (s *EmailService) GeneratePasswordResetToken() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +// SendPasswordResetEmail sends a password reset email with a reset link +func (s *EmailService) SendPasswordResetEmail(ctx context.Context, email, siteName, resetURL string) error { + var token string + var needSaveToken bool + + // Check if token already exists + existing, err := s.cache.GetPasswordResetToken(ctx, email) + if err == nil && existing != nil { + // Token exists, reuse it (allows resending email without generating new token) + token = existing.Token + needSaveToken = false + } else { + // Generate new token + token, err = s.GeneratePasswordResetToken() + if err != nil { + return fmt.Errorf("generate token: %w", err) + } + needSaveToken = true + } + + // Save token to Redis (only if new token generated) + if needSaveToken { + data := &PasswordResetTokenData{ + Token: token, + CreatedAt: time.Now(), + } + if err := s.cache.SetPasswordResetToken(ctx, email, data, passwordResetTokenTTL); err != nil { + return fmt.Errorf("save reset token: %w", err) + } + } + + // Build full reset URL with URL-encoded token and email + fullResetURL := fmt.Sprintf("%s?email=%s&token=%s", resetURL, url.QueryEscape(email), url.QueryEscape(token)) + + // Build email content + subject := fmt.Sprintf("[%s] 密码重置请求", siteName) + body := s.buildPasswordResetEmailBody(fullResetURL, siteName) + + // Send email + if err := s.SendEmail(ctx, email, subject, body); err != nil { + return fmt.Errorf("send email: %w", err) + } + + return nil +} + +// SendPasswordResetEmailWithCooldown sends password reset email with cooldown check (called by queue worker) +// This method wraps SendPasswordResetEmail with email cooldown to prevent email bombing +func (s *EmailService) SendPasswordResetEmailWithCooldown(ctx context.Context, email, siteName, resetURL string) error { + // Check email cooldown to prevent email bombing + if s.cache.IsPasswordResetEmailInCooldown(ctx, email) { + log.Printf("[Email] Password reset email skipped (cooldown): %s", email) + return nil // Silent success to prevent revealing cooldown to attackers + } + + // Send email using core method + if err := s.SendPasswordResetEmail(ctx, email, siteName, resetURL); err != nil { + return err + } + + // Set cooldown marker (Redis TTL handles expiration) + if err := s.cache.SetPasswordResetEmailCooldown(ctx, email, passwordResetEmailCooldown); err != nil { + log.Printf("[Email] Failed to set password reset cooldown for %s: %v", email, err) + } + + return nil +} + +// VerifyPasswordResetToken verifies the password reset token without consuming it +func (s *EmailService) VerifyPasswordResetToken(ctx context.Context, email, token string) error { + data, err := s.cache.GetPasswordResetToken(ctx, email) + if err != nil || data == nil { + return ErrInvalidResetToken + } + + // Use constant-time comparison to prevent timing attacks + if subtle.ConstantTimeCompare([]byte(data.Token), []byte(token)) != 1 { + return ErrInvalidResetToken + } + + return nil +} + +// ConsumePasswordResetToken verifies and deletes the token (one-time use) +func (s *EmailService) ConsumePasswordResetToken(ctx context.Context, email, token string) error { + // Verify first + if err := s.VerifyPasswordResetToken(ctx, email, token); err != nil { + return err + } + + // Delete after verification (one-time use) + if err := s.cache.DeletePasswordResetToken(ctx, email); err != nil { + log.Printf("[Email] Failed to delete password reset token after consumption: %v", err) + } + return nil +} + +// buildPasswordResetEmailBody builds the HTML content for password reset email +func (s *EmailService) buildPasswordResetEmailBody(resetURL, siteName string) string { + return fmt.Sprintf(` + + + + + + + +
+
+

%s

+
+
+

密码重置请求

+

您已请求重置密码。请点击下方按钮设置新密码:

+ 重置密码 +
+

此链接将在 30 分钟后失效。

+

如果您没有请求重置密码,请忽略此邮件。您的密码将保持不变。

+
+ +
+ +
+ + +`, siteName, resetURL, resetURL) +} diff --git a/backend/internal/service/gateway_beta_test.go b/backend/internal/service/gateway_beta_test.go new file mode 100644 index 00000000..dd58c183 --- /dev/null +++ b/backend/internal/service/gateway_beta_test.go @@ -0,0 +1,23 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMergeAnthropicBeta(t *testing.T) { + got := mergeAnthropicBeta( + []string{"oauth-2025-04-20", "interleaved-thinking-2025-05-14"}, + "foo, oauth-2025-04-20,bar, foo", + ) + require.Equal(t, "oauth-2025-04-20,interleaved-thinking-2025-05-14,foo,bar", got) +} + +func TestMergeAnthropicBeta_EmptyIncoming(t *testing.T) { + got := mergeAnthropicBeta( + []string{"oauth-2025-04-20", "interleaved-thinking-2025-05-14"}, + "", + ) + require.Equal(t, "oauth-2025-04-20,interleaved-thinking-2025-05-14", got) +} diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index d9ae6709..b1a0cc7a 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -269,6 +269,14 @@ func (m *mockGroupRepoForGateway) DeleteAccountGroupsByGroupID(ctx context.Conte return 0, nil } +func (m *mockGroupRepoForGateway) BindAccountsToGroup(ctx context.Context, groupID int64, accountIDs []int64) error { + return nil +} + +func (m *mockGroupRepoForGateway) GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error) { + return nil, nil +} + func ptr[T any](v T) *T { return &v } diff --git a/backend/internal/service/gateway_oauth_metadata_test.go b/backend/internal/service/gateway_oauth_metadata_test.go new file mode 100644 index 00000000..ed6f1887 --- /dev/null +++ b/backend/internal/service/gateway_oauth_metadata_test.go @@ -0,0 +1,62 @@ +package service + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBuildOAuthMetadataUserID_FallbackWithoutAccountUUID(t *testing.T) { + svc := &GatewayService{} + + parsed := &ParsedRequest{ + Model: "claude-sonnet-4-5", + Stream: true, + MetadataUserID: "", + System: nil, + Messages: nil, + } + + account := &Account{ + ID: 123, + Type: AccountTypeOAuth, + Extra: map[string]any{}, // intentionally missing account_uuid / claude_user_id + } + + fp := &Fingerprint{ClientID: "deadbeef"} // should be used as user id in legacy format + + got := svc.buildOAuthMetadataUserID(parsed, account, fp) + require.NotEmpty(t, got) + + // Legacy format: user_{client}_account__session_{uuid} + re := regexp.MustCompile(`^user_[a-zA-Z0-9]+_account__session_[a-f0-9-]{36}$`) + require.True(t, re.MatchString(got), "unexpected user_id format: %s", got) +} + +func TestBuildOAuthMetadataUserID_UsesAccountUUIDWhenPresent(t *testing.T) { + svc := &GatewayService{} + + parsed := &ParsedRequest{ + Model: "claude-sonnet-4-5", + Stream: true, + MetadataUserID: "", + } + + account := &Account{ + ID: 123, + Type: AccountTypeOAuth, + Extra: map[string]any{ + "account_uuid": "acc-uuid", + "claude_user_id": "clientid123", + "anthropic_user_id": "", + }, + } + + got := svc.buildOAuthMetadataUserID(parsed, account, nil) + require.NotEmpty(t, got) + + // New format: user_{client}_account_{account_uuid}_session_{uuid} + re := regexp.MustCompile(`^user_clientid123_account_acc-uuid_session_[a-f0-9-]{36}$`) + require.True(t, re.MatchString(got), "unexpected user_id format: %s", got) +} diff --git a/backend/internal/service/gateway_prompt_test.go b/backend/internal/service/gateway_prompt_test.go index b056f8fa..52c75d1d 100644 --- a/backend/internal/service/gateway_prompt_test.go +++ b/backend/internal/service/gateway_prompt_test.go @@ -2,6 +2,7 @@ package service import ( "encoding/json" + "strings" "testing" "github.com/stretchr/testify/require" @@ -134,6 +135,8 @@ func TestSystemIncludesClaudeCodePrompt(t *testing.T) { } func TestInjectClaudeCodePrompt(t *testing.T) { + claudePrefix := strings.TrimSpace(claudeCodeSystemPrompt) + tests := []struct { name string body string @@ -162,7 +165,7 @@ func TestInjectClaudeCodePrompt(t *testing.T) { system: "Custom prompt", wantSystemLen: 2, wantFirstText: claudeCodeSystemPrompt, - wantSecondText: "Custom prompt", + wantSecondText: claudePrefix + "\n\nCustom prompt", }, { name: "string system equals Claude Code prompt", @@ -178,7 +181,7 @@ func TestInjectClaudeCodePrompt(t *testing.T) { // Claude Code + Custom = 2 wantSystemLen: 2, wantFirstText: claudeCodeSystemPrompt, - wantSecondText: "Custom", + wantSecondText: claudePrefix + "\n\nCustom", }, { name: "array system with existing Claude Code prompt (should dedupe)", @@ -190,7 +193,7 @@ func TestInjectClaudeCodePrompt(t *testing.T) { // Claude Code at start + Other = 2 (deduped) wantSystemLen: 2, wantFirstText: claudeCodeSystemPrompt, - wantSecondText: "Other", + wantSecondText: claudePrefix + "\n\nOther", }, { name: "empty array", diff --git a/backend/internal/service/gateway_sanitize_test.go b/backend/internal/service/gateway_sanitize_test.go new file mode 100644 index 00000000..8fa971ca --- /dev/null +++ b/backend/internal/service/gateway_sanitize_test.go @@ -0,0 +1,21 @@ +package service + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSanitizeOpenCodeText_RewritesCanonicalSentence(t *testing.T) { + in := "You are OpenCode, the best coding agent on the planet." + got := sanitizeSystemText(in) + require.Equal(t, strings.TrimSpace(claudeCodeSystemPrompt), got) +} + +func TestSanitizeToolDescription_DoesNotRewriteKeywords(t *testing.T) { + in := "OpenCode and opencode are mentioned." + got := sanitizeToolDescription(in) + // We no longer rewrite tool descriptions; only redact obvious path leaks. + require.Equal(t, in, got) +} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 6925801d..5058f265 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -20,12 +20,14 @@ import ( "strings" "sync/atomic" "time" + "unicode" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" + "github.com/google/uuid" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -37,8 +39,15 @@ const ( claudeAPICountTokensURL = "https://api.anthropic.com/v1/messages/count_tokens?beta=true" stickySessionTTL = time.Hour // 粘性会话TTL defaultMaxLineSize = 40 * 1024 * 1024 - claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude." - maxCacheControlBlocks = 4 // Anthropic API 允许的最大 cache_control 块数量 + // Canonical Claude Code banner. Keep it EXACT (no trailing whitespace/newlines) + // to match real Claude CLI traffic as closely as possible. When we need a visual + // separator between system blocks, we add "\n\n" at concatenation time. + claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude." + maxCacheControlBlocks = 4 // Anthropic API 允许的最大 cache_control 块数量 +) + +const ( + claudeMimicDebugInfoKey = "claude_mimic_debug_info" ) func (s *GatewayService) debugModelRoutingEnabled() bool { @@ -46,6 +55,11 @@ func (s *GatewayService) debugModelRoutingEnabled() bool { return v == "1" || v == "true" || v == "yes" || v == "on" } +func (s *GatewayService) debugClaudeMimicEnabled() bool { + v := strings.ToLower(strings.TrimSpace(os.Getenv("SUB2API_DEBUG_CLAUDE_MIMIC"))) + return v == "1" || v == "true" || v == "yes" || v == "on" +} + func shortSessionHash(sessionHash string) string { if sessionHash == "" { return "" @@ -56,12 +70,178 @@ func shortSessionHash(sessionHash string) string { return sessionHash[:8] } +func redactAuthHeaderValue(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "" + } + // Keep scheme for debugging, redact secret. + if strings.HasPrefix(strings.ToLower(v), "bearer ") { + return "Bearer [redacted]" + } + return "[redacted]" +} + +func safeHeaderValueForLog(key string, v string) string { + key = strings.ToLower(strings.TrimSpace(key)) + switch key { + case "authorization", "x-api-key": + return redactAuthHeaderValue(v) + default: + return strings.TrimSpace(v) + } +} + +func extractSystemPreviewFromBody(body []byte) string { + if len(body) == 0 { + return "" + } + sys := gjson.GetBytes(body, "system") + if !sys.Exists() { + return "" + } + + switch { + case sys.IsArray(): + for _, item := range sys.Array() { + if !item.IsObject() { + continue + } + if strings.EqualFold(item.Get("type").String(), "text") { + if t := item.Get("text").String(); strings.TrimSpace(t) != "" { + return t + } + } + } + return "" + case sys.Type == gjson.String: + return sys.String() + default: + return "" + } +} + +func buildClaudeMimicDebugLine(req *http.Request, body []byte, account *Account, tokenType string, mimicClaudeCode bool) string { + if req == nil { + return "" + } + + // Only log a minimal fingerprint to avoid leaking user content. + interesting := []string{ + "user-agent", + "x-app", + "anthropic-dangerous-direct-browser-access", + "anthropic-version", + "anthropic-beta", + "x-stainless-lang", + "x-stainless-package-version", + "x-stainless-os", + "x-stainless-arch", + "x-stainless-runtime", + "x-stainless-runtime-version", + "x-stainless-retry-count", + "x-stainless-timeout", + "authorization", + "x-api-key", + "content-type", + "accept", + "x-stainless-helper-method", + } + + h := make([]string, 0, len(interesting)) + for _, k := range interesting { + if v := req.Header.Get(k); v != "" { + h = append(h, fmt.Sprintf("%s=%q", k, safeHeaderValueForLog(k, v))) + } + } + + metaUserID := strings.TrimSpace(gjson.GetBytes(body, "metadata.user_id").String()) + sysPreview := strings.TrimSpace(extractSystemPreviewFromBody(body)) + + // Truncate preview to keep logs sane. + if len(sysPreview) > 300 { + sysPreview = sysPreview[:300] + "..." + } + sysPreview = strings.ReplaceAll(sysPreview, "\n", "\\n") + sysPreview = strings.ReplaceAll(sysPreview, "\r", "\\r") + + aid := int64(0) + aname := "" + if account != nil { + aid = account.ID + aname = account.Name + } + + return fmt.Sprintf( + "url=%s account=%d(%s) tokenType=%s mimic=%t meta.user_id=%q system.preview=%q headers={%s}", + req.URL.String(), + aid, + aname, + tokenType, + mimicClaudeCode, + metaUserID, + sysPreview, + strings.Join(h, " "), + ) +} + +func logClaudeMimicDebug(req *http.Request, body []byte, account *Account, tokenType string, mimicClaudeCode bool) { + line := buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode) + if line == "" { + return + } + log.Printf("[ClaudeMimicDebug] %s", line) +} + +func isClaudeCodeCredentialScopeError(msg string) bool { + m := strings.ToLower(strings.TrimSpace(msg)) + if m == "" { + return false + } + return strings.Contains(m, "only authorized for use with claude code") && + strings.Contains(m, "cannot be used for other api requests") +} + // sseDataRe matches SSE data lines with optional whitespace after colon. // Some upstream APIs return non-standard "data:" without space (should be "data: "). var ( sseDataRe = regexp.MustCompile(`^data:\s*`) sessionIDRegex = regexp.MustCompile(`session_([a-f0-9-]{36})`) claudeCliUserAgentRe = regexp.MustCompile(`^claude-cli/\d+\.\d+\.\d+`) + toolPrefixRe = regexp.MustCompile(`(?i)^(?:oc_|mcp_)`) + toolNameBoundaryRe = regexp.MustCompile(`[^a-zA-Z0-9]+`) + toolNameCamelRe = regexp.MustCompile(`([a-z0-9])([A-Z])`) + toolNameFieldRe = regexp.MustCompile(`"name"\s*:\s*"([^"]+)"`) + modelFieldRe = regexp.MustCompile(`"model"\s*:\s*"([^"]+)"`) + toolDescAbsPathRe = regexp.MustCompile(`/\/?(?:home|Users|tmp|var|opt|usr|etc)\/[^\s,\)"'\]]+`) + toolDescWinPathRe = regexp.MustCompile(`(?i)[A-Z]:\\[^\s,\)"'\]]+`) + + claudeToolNameOverrides = map[string]string{ + "bash": "Bash", + "read": "Read", + "edit": "Edit", + "write": "Write", + "task": "Task", + "glob": "Glob", + "grep": "Grep", + "webfetch": "WebFetch", + "websearch": "WebSearch", + "todowrite": "TodoWrite", + "question": "AskUserQuestion", + } + openCodeToolOverrides = map[string]string{ + "Bash": "bash", + "Read": "read", + "Edit": "edit", + "Write": "write", + "Task": "task", + "Glob": "glob", + "Grep": "grep", + "WebFetch": "webfetch", + "WebSearch": "websearch", + "TodoWrite": "todowrite", + "AskUserQuestion": "question", + } // claudeCodePromptPrefixes 用于检测 Claude Code 系统提示词的前缀列表 // 支持多种变体:标准版、Agent SDK 版、Explore Agent 版、Compact 版等 @@ -309,6 +489,19 @@ func (s *GatewayService) BindStickySession(ctx context.Context, groupID *int64, return s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, accountID, stickySessionTTL) } +// GetCachedSessionAccountID retrieves the account ID bound to a sticky session. +// Returns 0 if no binding exists or on error. +func (s *GatewayService) GetCachedSessionAccountID(ctx context.Context, groupID *int64, sessionHash string) (int64, error) { + if sessionHash == "" || s.cache == nil { + return 0, nil + } + accountID, err := s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), sessionHash) + if err != nil { + return 0, err + } + return accountID, nil +} + func (s *GatewayService) extractCacheableContent(parsed *ParsedRequest) string { if parsed == nil { return "" @@ -409,6 +602,394 @@ func (s *GatewayService) replaceModelInBody(body []byte, newModel string) []byte return newBody } +type claudeOAuthNormalizeOptions struct { + injectMetadata bool + metadataUserID string + stripSystemCacheControl bool +} + +func stripToolPrefix(value string) string { + if value == "" { + return value + } + return toolPrefixRe.ReplaceAllString(value, "") +} + +func toPascalCase(value string) string { + if value == "" { + return value + } + normalized := toolNameBoundaryRe.ReplaceAllString(value, " ") + tokens := make([]string, 0) + for _, token := range strings.Fields(normalized) { + expanded := toolNameCamelRe.ReplaceAllString(token, "$1 $2") + parts := strings.Fields(expanded) + if len(parts) > 0 { + tokens = append(tokens, parts...) + } + } + if len(tokens) == 0 { + return value + } + var builder strings.Builder + for _, token := range tokens { + lower := strings.ToLower(token) + if lower == "" { + continue + } + runes := []rune(lower) + runes[0] = unicode.ToUpper(runes[0]) + _, _ = builder.WriteString(string(runes)) + } + return builder.String() +} + +func toSnakeCase(value string) string { + if value == "" { + return value + } + output := toolNameCamelRe.ReplaceAllString(value, "$1_$2") + output = toolNameBoundaryRe.ReplaceAllString(output, "_") + output = strings.Trim(output, "_") + return strings.ToLower(output) +} + +func normalizeToolNameForClaude(name string, cache map[string]string) string { + if name == "" { + return name + } + stripped := stripToolPrefix(name) + mapped, ok := claudeToolNameOverrides[strings.ToLower(stripped)] + if !ok { + mapped = toPascalCase(stripped) + } + if mapped != "" && cache != nil && mapped != stripped { + cache[mapped] = stripped + } + if mapped == "" { + return stripped + } + return mapped +} + +func normalizeToolNameForOpenCode(name string, cache map[string]string) string { + if name == "" { + return name + } + stripped := stripToolPrefix(name) + if cache != nil { + if mapped, ok := cache[stripped]; ok { + return mapped + } + } + if mapped, ok := openCodeToolOverrides[stripped]; ok { + return mapped + } + return toSnakeCase(stripped) +} + +func normalizeParamNameForOpenCode(name string, cache map[string]string) string { + if name == "" { + return name + } + if cache != nil { + if mapped, ok := cache[name]; ok { + return mapped + } + } + return name +} + +// sanitizeSystemText rewrites only the fixed OpenCode identity sentence (if present). +// We intentionally avoid broad keyword replacement in system prompts to prevent +// accidentally changing user-provided instructions. +func sanitizeSystemText(text string) string { + if text == "" { + return text + } + // Some clients include a fixed OpenCode identity sentence. Anthropic may treat + // this as a non-Claude-Code fingerprint, so rewrite it to the canonical + // Claude Code banner before generic "OpenCode"/"opencode" replacements. + text = strings.ReplaceAll( + text, + "You are OpenCode, the best coding agent on the planet.", + strings.TrimSpace(claudeCodeSystemPrompt), + ) + return text +} + +func sanitizeToolDescription(description string) string { + if description == "" { + return description + } + description = toolDescAbsPathRe.ReplaceAllString(description, "[path]") + description = toolDescWinPathRe.ReplaceAllString(description, "[path]") + // Intentionally do NOT rewrite tool descriptions (OpenCode/Claude strings). + // Tool names/skill names may rely on exact wording, and rewriting can be misleading. + return description +} + +func normalizeToolInputSchema(inputSchema any, cache map[string]string) { + schema, ok := inputSchema.(map[string]any) + if !ok { + return + } + properties, ok := schema["properties"].(map[string]any) + if !ok { + return + } + + newProperties := make(map[string]any, len(properties)) + for key, value := range properties { + snakeKey := toSnakeCase(key) + newProperties[snakeKey] = value + if snakeKey != key && cache != nil { + cache[snakeKey] = key + } + } + schema["properties"] = newProperties + + if required, ok := schema["required"].([]any); ok { + newRequired := make([]any, 0, len(required)) + for _, item := range required { + name, ok := item.(string) + if !ok { + newRequired = append(newRequired, item) + continue + } + snakeName := toSnakeCase(name) + newRequired = append(newRequired, snakeName) + if snakeName != name && cache != nil { + cache[snakeName] = name + } + } + schema["required"] = newRequired + } +} + +func stripCacheControlFromSystemBlocks(system any) bool { + blocks, ok := system.([]any) + if !ok { + return false + } + changed := false + for _, item := range blocks { + block, ok := item.(map[string]any) + if !ok { + continue + } + if _, exists := block["cache_control"]; !exists { + continue + } + delete(block, "cache_control") + changed = true + } + return changed +} + +func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAuthNormalizeOptions) ([]byte, string, map[string]string) { + if len(body) == 0 { + return body, modelID, nil + } + var req map[string]any + if err := json.Unmarshal(body, &req); err != nil { + return body, modelID, nil + } + + toolNameMap := make(map[string]string) + + if system, ok := req["system"]; ok { + switch v := system.(type) { + case string: + sanitized := sanitizeSystemText(v) + if sanitized != v { + req["system"] = sanitized + } + case []any: + for _, item := range v { + block, ok := item.(map[string]any) + if !ok { + continue + } + if blockType, _ := block["type"].(string); blockType != "text" { + continue + } + text, ok := block["text"].(string) + if !ok || text == "" { + continue + } + sanitized := sanitizeSystemText(text) + if sanitized != text { + block["text"] = sanitized + } + } + } + } + + if rawModel, ok := req["model"].(string); ok { + normalized := claude.NormalizeModelID(rawModel) + if normalized != rawModel { + req["model"] = normalized + modelID = normalized + } + } + + if rawTools, exists := req["tools"]; exists { + switch tools := rawTools.(type) { + case []any: + for idx, tool := range tools { + toolMap, ok := tool.(map[string]any) + if !ok { + continue + } + if name, ok := toolMap["name"].(string); ok { + normalized := normalizeToolNameForClaude(name, toolNameMap) + if normalized != "" && normalized != name { + toolMap["name"] = normalized + } + } + if desc, ok := toolMap["description"].(string); ok { + sanitized := sanitizeToolDescription(desc) + if sanitized != desc { + toolMap["description"] = sanitized + } + } + if schema, ok := toolMap["input_schema"]; ok { + normalizeToolInputSchema(schema, toolNameMap) + } + tools[idx] = toolMap + } + req["tools"] = tools + case map[string]any: + normalizedTools := make(map[string]any, len(tools)) + for name, value := range tools { + normalized := normalizeToolNameForClaude(name, toolNameMap) + if normalized == "" { + normalized = name + } + if toolMap, ok := value.(map[string]any); ok { + toolMap["name"] = normalized + if desc, ok := toolMap["description"].(string); ok { + sanitized := sanitizeToolDescription(desc) + if sanitized != desc { + toolMap["description"] = sanitized + } + } + if schema, ok := toolMap["input_schema"]; ok { + normalizeToolInputSchema(schema, toolNameMap) + } + normalizedTools[normalized] = toolMap + continue + } + normalizedTools[normalized] = value + } + req["tools"] = normalizedTools + } + } else { + req["tools"] = []any{} + } + + if messages, ok := req["messages"].([]any); ok { + for _, msg := range messages { + msgMap, ok := msg.(map[string]any) + if !ok { + continue + } + content, ok := msgMap["content"].([]any) + if !ok { + continue + } + for _, block := range content { + blockMap, ok := block.(map[string]any) + if !ok { + continue + } + if blockType, _ := blockMap["type"].(string); blockType != "tool_use" { + continue + } + if name, ok := blockMap["name"].(string); ok { + normalized := normalizeToolNameForClaude(name, toolNameMap) + if normalized != "" && normalized != name { + blockMap["name"] = normalized + } + } + } + } + } + + if opts.stripSystemCacheControl { + if system, ok := req["system"]; ok { + _ = stripCacheControlFromSystemBlocks(system) + } + } + + if opts.injectMetadata && opts.metadataUserID != "" { + metadata, ok := req["metadata"].(map[string]any) + if !ok { + metadata = map[string]any{} + req["metadata"] = metadata + } + if existing, ok := metadata["user_id"].(string); !ok || existing == "" { + metadata["user_id"] = opts.metadataUserID + } + } + + delete(req, "temperature") + delete(req, "tool_choice") + + newBody, err := json.Marshal(req) + if err != nil { + return body, modelID, toolNameMap + } + return newBody, modelID, toolNameMap +} + +func (s *GatewayService) buildOAuthMetadataUserID(parsed *ParsedRequest, account *Account, fp *Fingerprint) string { + if parsed == nil || account == nil { + return "" + } + if parsed.MetadataUserID != "" { + return "" + } + + userID := strings.TrimSpace(account.GetClaudeUserID()) + if userID == "" && fp != nil { + userID = fp.ClientID + } + if userID == "" { + // Fall back to a random, well-formed client id so we can still satisfy + // Claude Code OAuth requirements when account metadata is incomplete. + userID = generateClientID() + } + + sessionHash := s.GenerateSessionHash(parsed) + sessionID := uuid.NewString() + if sessionHash != "" { + seed := fmt.Sprintf("%d::%s", account.ID, sessionHash) + sessionID = generateSessionUUID(seed) + } + + // Prefer the newer format that includes account_uuid (if present), + // otherwise fall back to the legacy Claude Code format. + accountUUID := strings.TrimSpace(account.GetExtraString("account_uuid")) + if accountUUID != "" { + return fmt.Sprintf("user_%s_account_%s_session_%s", userID, accountUUID, sessionID) + } + return fmt.Sprintf("user_%s_account__session_%s", userID, sessionID) +} + +func generateSessionUUID(seed string) string { + if seed == "" { + return uuid.NewString() + } + hash := sha256.Sum256([]byte(seed)) + bytes := hash[:16] + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", + bytes[0:4], bytes[4:6], bytes[6:8], bytes[8:10], bytes[10:16]) +} + // SelectAccount 选择账号(粘性会话+优先级) func (s *GatewayService) SelectAccount(ctx context.Context, groupID *int64, sessionHash string) (*Account, error) { return s.SelectAccountForModel(ctx, groupID, sessionHash, "") @@ -1884,6 +2465,10 @@ func (s *GatewayService) isModelSupportedByAccount(account *Account, requestedMo // Antigravity 平台使用专门的模型支持检查 return IsAntigravityModelSupported(requestedModel) } + // Gemini API Key 账户直接透传,由上游判断模型是否支持 + if account.Platform == PlatformGemini && account.Type == AccountTypeAPIKey { + return true + } // 其他平台使用账户的模型支持检查 return account.IsModelSupported(requestedModel) } @@ -2008,6 +2593,16 @@ func isClaudeCodeClient(userAgent string, metadataUserID string) bool { return claudeCliUserAgentRe.MatchString(userAgent) } +func isClaudeCodeRequest(ctx context.Context, c *gin.Context, parsed *ParsedRequest) bool { + if IsClaudeCodeClient(ctx) { + return true + } + if parsed == nil || c == nil { + return false + } + return isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID) +} + // systemIncludesClaudeCodePrompt 检查 system 中是否已包含 Claude Code 提示词 // 使用前缀匹配支持多种变体(标准版、Agent SDK 版等) func systemIncludesClaudeCodePrompt(system any) bool { @@ -2044,6 +2639,10 @@ func injectClaudeCodePrompt(body []byte, system any) []byte { "text": claudeCodeSystemPrompt, "cache_control": map[string]string{"type": "ephemeral"}, } + // Opencode plugin applies an extra safeguard: it not only prepends the Claude Code + // banner, it also prefixes the next system instruction with the same banner plus + // a blank line. This helps when upstream concatenates system instructions. + claudeCodePrefix := strings.TrimSpace(claudeCodeSystemPrompt) var newSystem []any @@ -2051,19 +2650,36 @@ func injectClaudeCodePrompt(body []byte, system any) []byte { case nil: newSystem = []any{claudeCodeBlock} case string: - if v == "" || v == claudeCodeSystemPrompt { + // Be tolerant of older/newer clients that may differ only by trailing whitespace/newlines. + if strings.TrimSpace(v) == "" || strings.TrimSpace(v) == strings.TrimSpace(claudeCodeSystemPrompt) { newSystem = []any{claudeCodeBlock} } else { - newSystem = []any{claudeCodeBlock, map[string]any{"type": "text", "text": v}} + // Mirror opencode behavior: keep the banner as a separate system entry, + // but also prefix the next system text with the banner. + merged := v + if !strings.HasPrefix(v, claudeCodePrefix) { + merged = claudeCodePrefix + "\n\n" + v + } + newSystem = []any{claudeCodeBlock, map[string]any{"type": "text", "text": merged}} } case []any: newSystem = make([]any, 0, len(v)+1) newSystem = append(newSystem, claudeCodeBlock) + prefixedNext := false for _, item := range v { if m, ok := item.(map[string]any); ok { - if text, ok := m["text"].(string); ok && text == claudeCodeSystemPrompt { + if text, ok := m["text"].(string); ok && strings.TrimSpace(text) == strings.TrimSpace(claudeCodeSystemPrompt) { continue } + // Prefix the first subsequent text system block once. + if !prefixedNext { + if blockType, _ := m["type"].(string); blockType == "text" { + if text, ok := m["text"].(string); ok && strings.TrimSpace(text) != "" && !strings.HasPrefix(text, claudeCodePrefix) { + m["text"] = claudeCodePrefix + "\n\n" + text + prefixedNext = true + } + } + } } newSystem = append(newSystem, item) } @@ -2267,21 +2883,38 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A body := parsed.Body reqModel := parsed.Model reqStream := parsed.Stream + originalModel := reqModel + var toolNameMap map[string]string - // 智能注入 Claude Code 系统提示词(仅 OAuth/SetupToken 账号需要) - // 条件:1) OAuth/SetupToken 账号 2) 不是 Claude Code 客户端 3) 不是 Haiku 模型 4) system 中还没有 Claude Code 提示词 - if account.IsOAuth() && - !isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID) && - !strings.Contains(strings.ToLower(reqModel), "haiku") && - !systemIncludesClaudeCodePrompt(parsed.System) { - body = injectClaudeCodePrompt(body, parsed.System) + isClaudeCode := isClaudeCodeRequest(ctx, c, parsed) + shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode + + if shouldMimicClaudeCode { + // 智能注入 Claude Code 系统提示词(仅 OAuth/SetupToken 账号需要) + // 条件:1) OAuth/SetupToken 账号 2) 不是 Claude Code 客户端 3) 不是 Haiku 模型 4) system 中还没有 Claude Code 提示词 + if !strings.Contains(strings.ToLower(reqModel), "haiku") && + !systemIncludesClaudeCodePrompt(parsed.System) { + body = injectClaudeCodePrompt(body, parsed.System) + } + + normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true} + if s.identityService != nil { + fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header) + if err == nil && fp != nil { + if metadataUserID := s.buildOAuthMetadataUserID(parsed, account, fp); metadataUserID != "" { + normalizeOpts.injectMetadata = true + normalizeOpts.metadataUserID = metadataUserID + } + } + } + + body, reqModel, toolNameMap = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts) } // 强制执行 cache_control 块数量限制(最多 4 个) body = enforceCacheControlLimit(body) // 应用模型映射(仅对apikey类型账号) - originalModel := reqModel if account.Type == AccountTypeAPIKey { mappedModel := account.GetMappedModel(reqModel) if mappedModel != reqModel { @@ -2313,10 +2946,9 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A retryStart := time.Now() for attempt := 1; attempt <= maxRetryAttempts; attempt++ { // 构建上游请求(每次重试需要重新构建,因为请求体需要重新读取) - upstreamReq, err := s.buildUpstreamRequest(ctx, c, account, body, token, tokenType, reqModel) // Capture upstream request body for ops retry of this attempt. c.Set(OpsUpstreamRequestBodyKey, string(body)) - + upstreamReq, err := s.buildUpstreamRequest(ctx, c, account, body, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode) if err != nil { return nil, err } @@ -2394,7 +3026,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A // also downgrade tool_use/tool_result blocks to text. filteredBody := FilterThinkingBlocksForRetry(body) - retryReq, buildErr := s.buildUpstreamRequest(ctx, c, account, filteredBody, token, tokenType, reqModel) + retryReq, buildErr := s.buildUpstreamRequest(ctx, c, account, filteredBody, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode) if buildErr == nil { retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled()) if retryErr == nil { @@ -2426,7 +3058,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A if looksLikeToolSignatureError(msg2) && time.Since(retryStart) < maxRetryElapsed { log.Printf("Account %d: signature retry still failing and looks tool-related, retrying with tool blocks downgraded", account.ID) filteredBody2 := FilterSignatureSensitiveBlocksForRetry(body) - retryReq2, buildErr2 := s.buildUpstreamRequest(ctx, c, account, filteredBody2, token, tokenType, reqModel) + retryReq2, buildErr2 := s.buildUpstreamRequest(ctx, c, account, filteredBody2, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode) if buildErr2 == nil { retryResp2, retryErr2 := s.httpUpstream.DoWithTLS(retryReq2, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled()) if retryErr2 == nil { @@ -2651,7 +3283,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A var firstTokenMs *int var clientDisconnect bool if reqStream { - streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, reqModel) + streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, reqModel, toolNameMap, shouldMimicClaudeCode) if err != nil { if err.Error() == "have error in stream" { return nil, &UpstreamFailoverError{ @@ -2664,7 +3296,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A firstTokenMs = streamResult.firstTokenMs clientDisconnect = streamResult.clientDisconnect } else { - usage, err = s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, reqModel) + usage, err = s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, reqModel, toolNameMap, shouldMimicClaudeCode) if err != nil { return nil, err } @@ -2681,7 +3313,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A }, nil } -func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string) (*http.Request, error) { +func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string, reqStream bool, mimicClaudeCode bool) (*http.Request, error) { // 确定目标URL targetURL := claudeAPIURL if account.Type == AccountTypeAPIKey { @@ -2695,11 +3327,16 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex } } + clientHeaders := http.Header{} + if c != nil && c.Request != nil { + clientHeaders = c.Request.Header + } + // OAuth账号:应用统一指纹 var fingerprint *Fingerprint if account.IsOAuth() && s.identityService != nil { // 1. 获取或创建指纹(包含随机生成的ClientID) - fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header) + fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders) if err != nil { log.Printf("Warning: failed to get fingerprint for account %d: %v", account.ID, err) // 失败时降级为透传原始headers @@ -2730,7 +3367,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex } // 白名单透传headers - for key, values := range c.Request.Header { + for key, values := range clientHeaders { lowerKey := strings.ToLower(key) if allowedHeaders[lowerKey] { for _, v := range values { @@ -2751,10 +3388,30 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex if req.Header.Get("anthropic-version") == "" { req.Header.Set("anthropic-version", "2023-06-01") } - - // 处理anthropic-beta header(OAuth账号需要特殊处理) if tokenType == "oauth" { - req.Header.Set("anthropic-beta", s.getBetaHeader(modelID, c.GetHeader("anthropic-beta"))) + applyClaudeOAuthHeaderDefaults(req, reqStream) + } + + // 处理 anthropic-beta header(OAuth 账号需要包含 oauth beta) + if tokenType == "oauth" { + if mimicClaudeCode { + // 非 Claude Code 客户端:按 opencode 的策略处理: + // - 强制 Claude Code 指纹相关请求头(尤其是 user-agent/x-stainless/x-app) + // - 保留 incoming beta 的同时,确保 OAuth 所需 beta 存在 + applyClaudeCodeMimicHeaders(req, reqStream) + + incomingBeta := req.Header.Get("anthropic-beta") + // Match real Claude CLI traffic (per mitmproxy reports): + // messages requests typically use only oauth + interleaved-thinking. + // Also drop claude-code beta if a downstream client added it. + requiredBetas := []string{claude.BetaOAuth, claude.BetaInterleavedThinking} + drop := map[string]struct{}{claude.BetaClaudeCode: {}} + req.Header.Set("anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, drop)) + } else { + // Claude Code 客户端:尽量透传原始 header,仅补齐 oauth beta + clientBetaHeader := req.Header.Get("anthropic-beta") + req.Header.Set("anthropic-beta", s.getBetaHeader(modelID, clientBetaHeader)) + } } else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey && req.Header.Get("anthropic-beta") == "" { // API-key:仅在请求显式使用 beta 特性且客户端未提供时,按需补齐(默认关闭) if requestNeedsBetaFeatures(body) { @@ -2764,6 +3421,15 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex } } + // Always capture a compact fingerprint line for later error diagnostics. + // We only print it when needed (or when the explicit debug flag is enabled). + if c != nil && tokenType == "oauth" { + c.Set(claudeMimicDebugInfoKey, buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode)) + } + if s.debugClaudeMimicEnabled() { + logClaudeMimicDebug(req, body, account, tokenType, mimicClaudeCode) + } + return req, nil } @@ -2833,6 +3499,93 @@ func defaultAPIKeyBetaHeader(body []byte) string { return claude.APIKeyBetaHeader } +func applyClaudeOAuthHeaderDefaults(req *http.Request, isStream bool) { + if req == nil { + return + } + if req.Header.Get("accept") == "" { + req.Header.Set("accept", "application/json") + } + for key, value := range claude.DefaultHeaders { + if value == "" { + continue + } + if req.Header.Get(key) == "" { + req.Header.Set(key, value) + } + } + if isStream && req.Header.Get("x-stainless-helper-method") == "" { + req.Header.Set("x-stainless-helper-method", "stream") + } +} + +func mergeAnthropicBeta(required []string, incoming string) string { + seen := make(map[string]struct{}, len(required)+8) + out := make([]string, 0, len(required)+8) + + add := func(v string) { + v = strings.TrimSpace(v) + if v == "" { + return + } + if _, ok := seen[v]; ok { + return + } + seen[v] = struct{}{} + out = append(out, v) + } + + for _, r := range required { + add(r) + } + for _, p := range strings.Split(incoming, ",") { + add(p) + } + return strings.Join(out, ",") +} + +func mergeAnthropicBetaDropping(required []string, incoming string, drop map[string]struct{}) string { + merged := mergeAnthropicBeta(required, incoming) + if merged == "" || len(drop) == 0 { + return merged + } + out := make([]string, 0, 8) + for _, p := range strings.Split(merged, ",") { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if _, ok := drop[p]; ok { + continue + } + out = append(out, p) + } + return strings.Join(out, ",") +} + +// applyClaudeCodeMimicHeaders forces "Claude Code-like" request headers. +// This mirrors opencode-anthropic-auth behavior: do not trust downstream +// headers when using Claude Code-scoped OAuth credentials. +func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) { + if req == nil { + return + } + // Start with the standard defaults (fill missing). + applyClaudeOAuthHeaderDefaults(req, isStream) + // Then force key headers to match Claude Code fingerprint regardless of what the client sent. + for key, value := range claude.DefaultHeaders { + if value == "" { + continue + } + req.Header.Set(key, value) + } + // Real Claude CLI uses Accept: application/json (even for streaming). + req.Header.Set("accept", "application/json") + if isStream { + req.Header.Set("x-stainless-helper-method", "stream") + } +} + func truncateForLog(b []byte, maxBytes int) string { if maxBytes <= 0 { maxBytes = 2048 @@ -2936,6 +3689,20 @@ func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Res upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body)) upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) + // Print a compact upstream request fingerprint when we hit the Claude Code OAuth + // credential scope error. This avoids requiring env-var tweaks in a fixed deploy. + if isClaudeCodeCredentialScopeError(upstreamMsg) && c != nil { + if v, ok := c.Get(claudeMimicDebugInfoKey); ok { + if line, ok := v.(string); ok && strings.TrimSpace(line) != "" { + log.Printf("[ClaudeMimicDebugOnError] status=%d request_id=%s %s", + resp.StatusCode, + resp.Header.Get("x-request-id"), + line, + ) + } + } + } + // Enrich Ops error logs with upstream status + message, and optionally a truncated body snippet. upstreamDetail := "" if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { @@ -3065,6 +3832,19 @@ func (s *GatewayService) handleRetryExhaustedError(ctx context.Context, resp *ht upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) + + if isClaudeCodeCredentialScopeError(upstreamMsg) && c != nil { + if v, ok := c.Get(claudeMimicDebugInfoKey); ok { + if line, ok := v.(string); ok && strings.TrimSpace(line) != "" { + log.Printf("[ClaudeMimicDebugOnError] status=%d request_id=%s %s", + resp.StatusCode, + resp.Header.Get("x-request-id"), + line, + ) + } + } + } + upstreamDetail := "" if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes @@ -3117,7 +3897,7 @@ type streamingResult struct { clientDisconnect bool // 客户端是否在流式传输过程中断开 } -func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string) (*streamingResult, error) { +func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string, toolNameMap map[string]string, mimicClaudeCode bool) (*streamingResult, error) { // 更新5h窗口状态 s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header) @@ -3212,6 +3992,171 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http needModelReplace := originalModel != mappedModel clientDisconnected := false // 客户端断开标志,断开后继续读取上游以获取完整usage + pendingEventLines := make([]string, 0, 4) + var toolInputBuffers map[int]string + if mimicClaudeCode { + toolInputBuffers = make(map[int]string) + } + + transformToolInputJSON := func(raw string) string { + if !mimicClaudeCode { + return raw + } + raw = strings.TrimSpace(raw) + if raw == "" { + return raw + } + + var parsed any + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return replaceToolNamesInText(raw, toolNameMap) + } + + rewritten, changed := rewriteParamKeysInValue(parsed, toolNameMap) + if changed { + if bytes, err := json.Marshal(rewritten); err == nil { + return string(bytes) + } + } + return raw + } + + processSSEEvent := func(lines []string) ([]string, string, error) { + if len(lines) == 0 { + return nil, "", nil + } + + eventName := "" + dataLine := "" + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "event:") { + eventName = strings.TrimSpace(strings.TrimPrefix(trimmed, "event:")) + continue + } + if dataLine == "" && sseDataRe.MatchString(trimmed) { + dataLine = sseDataRe.ReplaceAllString(trimmed, "") + } + } + + if eventName == "error" { + return nil, dataLine, errors.New("have error in stream") + } + + if dataLine == "" { + return []string{strings.Join(lines, "\n") + "\n\n"}, "", nil + } + + if dataLine == "[DONE]" { + block := "" + if eventName != "" { + block = "event: " + eventName + "\n" + } + block += "data: " + dataLine + "\n\n" + return []string{block}, dataLine, nil + } + + var event map[string]any + if err := json.Unmarshal([]byte(dataLine), &event); err != nil { + replaced := dataLine + if mimicClaudeCode { + replaced = replaceToolNamesInText(dataLine, toolNameMap) + } + block := "" + if eventName != "" { + block = "event: " + eventName + "\n" + } + block += "data: " + replaced + "\n\n" + return []string{block}, replaced, nil + } + + eventType, _ := event["type"].(string) + if eventName == "" { + eventName = eventType + } + + if needModelReplace { + if msg, ok := event["message"].(map[string]any); ok { + if model, ok := msg["model"].(string); ok && model == mappedModel { + msg["model"] = originalModel + } + } + } + + if mimicClaudeCode && eventType == "content_block_delta" { + if delta, ok := event["delta"].(map[string]any); ok { + if deltaType, _ := delta["type"].(string); deltaType == "input_json_delta" { + if indexVal, ok := event["index"].(float64); ok { + index := int(indexVal) + if partial, ok := delta["partial_json"].(string); ok { + toolInputBuffers[index] += partial + } + } + return nil, dataLine, nil + } + } + } + + if mimicClaudeCode && eventType == "content_block_stop" { + if indexVal, ok := event["index"].(float64); ok { + index := int(indexVal) + if buffered := toolInputBuffers[index]; buffered != "" { + delete(toolInputBuffers, index) + + transformed := transformToolInputJSON(buffered) + synthetic := map[string]any{ + "type": "content_block_delta", + "index": index, + "delta": map[string]any{ + "type": "input_json_delta", + "partial_json": transformed, + }, + } + + synthBytes, synthErr := json.Marshal(synthetic) + if synthErr == nil { + synthBlock := "event: content_block_delta\n" + "data: " + string(synthBytes) + "\n\n" + + rewriteToolNamesInValue(event, toolNameMap) + stopBytes, stopErr := json.Marshal(event) + if stopErr == nil { + stopBlock := "" + if eventName != "" { + stopBlock = "event: " + eventName + "\n" + } + stopBlock += "data: " + string(stopBytes) + "\n\n" + return []string{synthBlock, stopBlock}, string(stopBytes), nil + } + } + } + } + } + + if mimicClaudeCode { + rewriteToolNamesInValue(event, toolNameMap) + } + newData, err := json.Marshal(event) + if err != nil { + replaced := dataLine + if mimicClaudeCode { + replaced = replaceToolNamesInText(dataLine, toolNameMap) + } + block := "" + if eventName != "" { + block = "event: " + eventName + "\n" + } + block += "data: " + replaced + "\n\n" + return []string{block}, replaced, nil + } + + block := "" + if eventName != "" { + block = "event: " + eventName + "\n" + } + block += "data: " + string(newData) + "\n\n" + return []string{block}, string(newData), nil + } + for { select { case ev, ok := <-events: @@ -3240,42 +4185,43 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http return &streamingResult{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream read error: %w", ev.err) } line := ev.line - if line == "event: error" { - // 上游返回错误事件,如果客户端已断开仍返回已收集的 usage - if clientDisconnected { - return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, nil + trimmed := strings.TrimSpace(line) + + if trimmed == "" { + if len(pendingEventLines) == 0 { + continue } - return nil, errors.New("have error in stream") + + outputBlocks, data, err := processSSEEvent(pendingEventLines) + pendingEventLines = pendingEventLines[:0] + if err != nil { + if clientDisconnected { + return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, nil + } + return nil, err + } + + for _, block := range outputBlocks { + if !clientDisconnected { + if _, werr := fmt.Fprint(w, block); werr != nil { + clientDisconnected = true + log.Printf("Client disconnected during streaming, continuing to drain upstream for billing") + break + } + flusher.Flush() + } + if data != "" { + if firstTokenMs == nil && data != "[DONE]" { + ms := int(time.Since(startTime).Milliseconds()) + firstTokenMs = &ms + } + s.parseSSEUsage(data, usage) + } + } + continue } - // Extract data from SSE line (supports both "data: " and "data:" formats) - var data string - if sseDataRe.MatchString(line) { - data = sseDataRe.ReplaceAllString(line, "") - // 如果有模型映射,替换响应中的model字段 - if needModelReplace { - line = s.replaceModelInSSELine(line, mappedModel, originalModel) - } - } - - // 写入客户端(统一处理 data 行和非 data 行) - if !clientDisconnected { - if _, err := fmt.Fprintf(w, "%s\n", line); err != nil { - clientDisconnected = true - log.Printf("Client disconnected during streaming, continuing to drain upstream for billing") - } else { - flusher.Flush() - } - } - - // 无论客户端是否断开,都解析 usage(仅对 data 行) - if data != "" { - if firstTokenMs == nil && data != "[DONE]" { - ms := int(time.Since(startTime).Milliseconds()) - firstTokenMs = &ms - } - s.parseSSEUsage(data, usage) - } + pendingEventLines = append(pendingEventLines, line) case <-intervalCh: lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) @@ -3299,43 +4245,124 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http } -// replaceModelInSSELine 替换SSE数据行中的model字段 -func (s *GatewayService) replaceModelInSSELine(line, fromModel, toModel string) string { - if !sseDataRe.MatchString(line) { - return line +func rewriteParamKeysInValue(value any, cache map[string]string) (any, bool) { + switch v := value.(type) { + case map[string]any: + changed := false + rewritten := make(map[string]any, len(v)) + for key, item := range v { + newKey := normalizeParamNameForOpenCode(key, cache) + newItem, childChanged := rewriteParamKeysInValue(item, cache) + if childChanged { + changed = true + } + if newKey != key { + changed = true + } + rewritten[newKey] = newItem + } + if !changed { + return value, false + } + return rewritten, true + case []any: + changed := false + rewritten := make([]any, len(v)) + for idx, item := range v { + newItem, childChanged := rewriteParamKeysInValue(item, cache) + if childChanged { + changed = true + } + rewritten[idx] = newItem + } + if !changed { + return value, false + } + return rewritten, true + default: + return value, false } - data := sseDataRe.ReplaceAllString(line, "") - if data == "" || data == "[DONE]" { - return line +} + +func rewriteToolNamesInValue(value any, toolNameMap map[string]string) bool { + switch v := value.(type) { + case map[string]any: + changed := false + if blockType, _ := v["type"].(string); blockType == "tool_use" { + if name, ok := v["name"].(string); ok { + mapped := normalizeToolNameForOpenCode(name, toolNameMap) + if mapped != name { + v["name"] = mapped + changed = true + } + } + if input, ok := v["input"].(map[string]any); ok { + rewrittenInput, inputChanged := rewriteParamKeysInValue(input, toolNameMap) + if inputChanged { + if m, ok := rewrittenInput.(map[string]any); ok { + v["input"] = m + changed = true + } + } + } + } + for _, item := range v { + if rewriteToolNamesInValue(item, toolNameMap) { + changed = true + } + } + return changed + case []any: + changed := false + for _, item := range v { + if rewriteToolNamesInValue(item, toolNameMap) { + changed = true + } + } + return changed + default: + return false + } +} + +func replaceToolNamesInText(text string, toolNameMap map[string]string) string { + if text == "" { + return text + } + output := toolNameFieldRe.ReplaceAllStringFunc(text, func(match string) string { + submatches := toolNameFieldRe.FindStringSubmatch(match) + if len(submatches) < 2 { + return match + } + name := submatches[1] + mapped := normalizeToolNameForOpenCode(name, toolNameMap) + if mapped == name { + return match + } + return strings.Replace(match, name, mapped, 1) + }) + output = modelFieldRe.ReplaceAllStringFunc(output, func(match string) string { + submatches := modelFieldRe.FindStringSubmatch(match) + if len(submatches) < 2 { + return match + } + model := submatches[1] + mapped := claude.DenormalizeModelID(model) + if mapped == model { + return match + } + return strings.Replace(match, model, mapped, 1) + }) + + for mapped, original := range toolNameMap { + if mapped == "" || original == "" || mapped == original { + continue + } + output = strings.ReplaceAll(output, "\""+mapped+"\":", "\""+original+"\":") + output = strings.ReplaceAll(output, "\\\""+mapped+"\\\":", "\\\""+original+"\\\":") } - var event map[string]any - if err := json.Unmarshal([]byte(data), &event); err != nil { - return line - } - - // 只替换 message_start 事件中的 message.model - if event["type"] != "message_start" { - return line - } - - msg, ok := event["message"].(map[string]any) - if !ok { - return line - } - - model, ok := msg["model"].(string) - if !ok || model != fromModel { - return line - } - - msg["model"] = toModel - newData, err := json.Marshal(event) - if err != nil { - return line - } - - return "data: " + string(newData) + return output } func (s *GatewayService) parseSSEUsage(data string, usage *ClaudeUsage) { @@ -3363,23 +4390,25 @@ func (s *GatewayService) parseSSEUsage(data string, usage *ClaudeUsage) { } `json:"usage"` } if json.Unmarshal([]byte(data), &msgDelta) == nil && msgDelta.Type == "message_delta" { - // output_tokens 总是从 message_delta 获取 - usage.OutputTokens = msgDelta.Usage.OutputTokens - - // 如果 message_start 中没有值,则从 message_delta 获取(兼容GLM等API) - if usage.InputTokens == 0 { + // message_delta 仅覆盖存在且非0的字段 + // 避免覆盖 message_start 中已有的值(如 input_tokens) + // Claude API 的 message_delta 通常只包含 output_tokens + if msgDelta.Usage.InputTokens > 0 { usage.InputTokens = msgDelta.Usage.InputTokens } - if usage.CacheCreationInputTokens == 0 { + if msgDelta.Usage.OutputTokens > 0 { + usage.OutputTokens = msgDelta.Usage.OutputTokens + } + if msgDelta.Usage.CacheCreationInputTokens > 0 { usage.CacheCreationInputTokens = msgDelta.Usage.CacheCreationInputTokens } - if usage.CacheReadInputTokens == 0 { + if msgDelta.Usage.CacheReadInputTokens > 0 { usage.CacheReadInputTokens = msgDelta.Usage.CacheReadInputTokens } } } -func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string) (*ClaudeUsage, error) { +func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string, toolNameMap map[string]string, mimicClaudeCode bool) (*ClaudeUsage, error) { // 更新5h窗口状态 s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header) @@ -3400,6 +4429,9 @@ func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *h if originalModel != mappedModel { body = s.replaceModelInResponseBody(body, mappedModel, originalModel) } + if mimicClaudeCode { + body = s.replaceToolNamesInResponseBody(body, toolNameMap) + } responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.cfg.Security.ResponseHeaders) @@ -3437,6 +4469,28 @@ func (s *GatewayService) replaceModelInResponseBody(body []byte, fromModel, toMo return newBody } +func (s *GatewayService) replaceToolNamesInResponseBody(body []byte, toolNameMap map[string]string) []byte { + if len(body) == 0 { + return body + } + var resp map[string]any + if err := json.Unmarshal(body, &resp); err != nil { + replaced := replaceToolNamesInText(string(body), toolNameMap) + if replaced == string(body) { + return body + } + return []byte(replaced) + } + if !rewriteToolNamesInValue(resp, toolNameMap) { + return body + } + newBody, err := json.Marshal(resp) + if err != nil { + return body + } + return newBody +} + // RecordUsageInput 记录使用量的输入参数 type RecordUsageInput struct { Result *ForwardResult @@ -3613,6 +4667,162 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu return nil } +// RecordUsageLongContextInput 记录使用量的输入参数(支持长上下文双倍计费) +type RecordUsageLongContextInput struct { + Result *ForwardResult + APIKey *APIKey + User *User + Account *Account + Subscription *UserSubscription // 可选:订阅信息 + UserAgent string // 请求的 User-Agent + IPAddress string // 请求的客户端 IP 地址 + LongContextThreshold int // 长上下文阈值(如 200000) + LongContextMultiplier float64 // 超出阈值部分的倍率(如 2.0) +} + +// RecordUsageWithLongContext 记录使用量并扣费,支持长上下文双倍计费(用于 Gemini) +func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *RecordUsageLongContextInput) error { + result := input.Result + apiKey := input.APIKey + user := input.User + account := input.Account + subscription := input.Subscription + + // 获取费率倍数 + multiplier := s.cfg.Default.RateMultiplier + if apiKey.GroupID != nil && apiKey.Group != nil { + multiplier = apiKey.Group.RateMultiplier + } + + var cost *CostBreakdown + + // 根据请求类型选择计费方式 + if result.ImageCount > 0 { + // 图片生成计费 + var groupConfig *ImagePriceConfig + if apiKey.Group != nil { + groupConfig = &ImagePriceConfig{ + Price1K: apiKey.Group.ImagePrice1K, + Price2K: apiKey.Group.ImagePrice2K, + Price4K: apiKey.Group.ImagePrice4K, + } + } + cost = s.billingService.CalculateImageCost(result.Model, result.ImageSize, result.ImageCount, groupConfig, multiplier) + } else { + // Token 计费(使用长上下文计费方法) + tokens := UsageTokens{ + InputTokens: result.Usage.InputTokens, + OutputTokens: result.Usage.OutputTokens, + CacheCreationTokens: result.Usage.CacheCreationInputTokens, + CacheReadTokens: result.Usage.CacheReadInputTokens, + } + var err error + cost, err = s.billingService.CalculateCostWithLongContext(result.Model, tokens, multiplier, input.LongContextThreshold, input.LongContextMultiplier) + if err != nil { + log.Printf("Calculate cost failed: %v", err) + cost = &CostBreakdown{ActualCost: 0} + } + } + + // 判断计费方式:订阅模式 vs 余额模式 + isSubscriptionBilling := subscription != nil && apiKey.Group != nil && apiKey.Group.IsSubscriptionType() + billingType := BillingTypeBalance + if isSubscriptionBilling { + billingType = BillingTypeSubscription + } + + // 创建使用日志 + durationMs := int(result.Duration.Milliseconds()) + var imageSize *string + if result.ImageSize != "" { + imageSize = &result.ImageSize + } + accountRateMultiplier := account.BillingRateMultiplier() + usageLog := &UsageLog{ + UserID: user.ID, + APIKeyID: apiKey.ID, + AccountID: account.ID, + RequestID: result.RequestID, + Model: result.Model, + InputTokens: result.Usage.InputTokens, + OutputTokens: result.Usage.OutputTokens, + CacheCreationTokens: result.Usage.CacheCreationInputTokens, + CacheReadTokens: result.Usage.CacheReadInputTokens, + InputCost: cost.InputCost, + OutputCost: cost.OutputCost, + CacheCreationCost: cost.CacheCreationCost, + CacheReadCost: cost.CacheReadCost, + TotalCost: cost.TotalCost, + ActualCost: cost.ActualCost, + RateMultiplier: multiplier, + AccountRateMultiplier: &accountRateMultiplier, + BillingType: billingType, + Stream: result.Stream, + DurationMs: &durationMs, + FirstTokenMs: result.FirstTokenMs, + ImageCount: result.ImageCount, + ImageSize: imageSize, + CreatedAt: time.Now(), + } + + // 添加 UserAgent + if input.UserAgent != "" { + usageLog.UserAgent = &input.UserAgent + } + + // 添加 IPAddress + if input.IPAddress != "" { + usageLog.IPAddress = &input.IPAddress + } + + // 添加分组和订阅关联 + if apiKey.GroupID != nil { + usageLog.GroupID = apiKey.GroupID + } + if subscription != nil { + usageLog.SubscriptionID = &subscription.ID + } + + inserted, err := s.usageLogRepo.Create(ctx, usageLog) + if err != nil { + log.Printf("Create usage log failed: %v", err) + } + + if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple { + log.Printf("[SIMPLE MODE] Usage recorded (not billed): user=%d, tokens=%d", usageLog.UserID, usageLog.TotalTokens()) + s.deferredService.ScheduleLastUsedUpdate(account.ID) + return nil + } + + shouldBill := inserted || err != nil + + // 根据计费类型执行扣费 + if isSubscriptionBilling { + // 订阅模式:更新订阅用量(使用 TotalCost 原始费用,不考虑倍率) + if shouldBill && cost.TotalCost > 0 { + if err := s.userSubRepo.IncrementUsage(ctx, subscription.ID, cost.TotalCost); err != nil { + log.Printf("Increment subscription usage failed: %v", err) + } + // 异步更新订阅缓存 + s.billingCacheService.QueueUpdateSubscriptionUsage(user.ID, *apiKey.GroupID, cost.TotalCost) + } + } else { + // 余额模式:扣除用户余额(使用 ActualCost 考虑倍率后的费用) + if shouldBill && cost.ActualCost > 0 { + if err := s.userRepo.DeductBalance(ctx, user.ID, cost.ActualCost); err != nil { + log.Printf("Deduct balance failed: %v", err) + } + // 异步更新余额缓存 + s.billingCacheService.QueueDeductBalance(user.ID, cost.ActualCost) + } + } + + // Schedule batch update for account last_used_at + s.deferredService.ScheduleLastUsedUpdate(account.ID) + + return nil +} + // ForwardCountTokens 转发 count_tokens 请求到上游 API // 特点:不记录使用量、仅支持非流式响应 func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) error { @@ -3624,6 +4834,14 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, body := parsed.Body reqModel := parsed.Model + isClaudeCode := isClaudeCodeRequest(ctx, c, parsed) + shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode + + if shouldMimicClaudeCode { + normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true} + body, reqModel, _ = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts) + } + // Antigravity 账户不支持 count_tokens 转发,直接返回空值 if account.Platform == PlatformAntigravity { c.JSON(http.StatusOK, gin.H{"input_tokens": 0}) @@ -3650,7 +4868,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, } // 构建上游请求 - upstreamReq, err := s.buildCountTokensRequest(ctx, c, account, body, token, tokenType, reqModel) + upstreamReq, err := s.buildCountTokensRequest(ctx, c, account, body, token, tokenType, reqModel, shouldMimicClaudeCode) if err != nil { s.countTokensError(c, http.StatusInternalServerError, "api_error", "Failed to build request") return err @@ -3683,7 +4901,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, log.Printf("Account %d: detected thinking block signature error on count_tokens, retrying with filtered thinking blocks", account.ID) filteredBody := FilterThinkingBlocksForRetry(body) - retryReq, buildErr := s.buildCountTokensRequest(ctx, c, account, filteredBody, token, tokenType, reqModel) + retryReq, buildErr := s.buildCountTokensRequest(ctx, c, account, filteredBody, token, tokenType, reqModel, shouldMimicClaudeCode) if buildErr == nil { retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled()) if retryErr == nil { @@ -3748,7 +4966,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, } // buildCountTokensRequest 构建 count_tokens 上游请求 -func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string) (*http.Request, error) { +func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string, mimicClaudeCode bool) (*http.Request, error) { // 确定目标 URL targetURL := claudeAPICountTokensURL if account.Type == AccountTypeAPIKey { @@ -3762,10 +4980,15 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con } } + clientHeaders := http.Header{} + if c != nil && c.Request != nil { + clientHeaders = c.Request.Header + } + // OAuth 账号:应用统一指纹和重写 userID // 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值 if account.IsOAuth() && s.identityService != nil { - fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header) + fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders) if err == nil { accountUUID := account.GetExtraString("account_uuid") if accountUUID != "" && fp.ClientID != "" { @@ -3789,7 +5012,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con } // 白名单透传 headers - for key, values := range c.Request.Header { + for key, values := range clientHeaders { lowerKey := strings.ToLower(key) if allowedHeaders[lowerKey] { for _, v := range values { @@ -3800,7 +5023,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con // OAuth 账号:应用指纹到请求头 if account.IsOAuth() && s.identityService != nil { - fp, _ := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header) + fp, _ := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders) if fp != nil { s.identityService.ApplyFingerprint(req, fp) } @@ -3813,10 +5036,30 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con if req.Header.Get("anthropic-version") == "" { req.Header.Set("anthropic-version", "2023-06-01") } + if tokenType == "oauth" { + applyClaudeOAuthHeaderDefaults(req, false) + } // OAuth 账号:处理 anthropic-beta header if tokenType == "oauth" { - req.Header.Set("anthropic-beta", s.getBetaHeader(modelID, c.GetHeader("anthropic-beta"))) + if mimicClaudeCode { + applyClaudeCodeMimicHeaders(req, false) + + incomingBeta := req.Header.Get("anthropic-beta") + requiredBetas := []string{claude.BetaClaudeCode, claude.BetaOAuth, claude.BetaInterleavedThinking, claude.BetaTokenCounting} + req.Header.Set("anthropic-beta", mergeAnthropicBeta(requiredBetas, incomingBeta)) + } else { + clientBetaHeader := req.Header.Get("anthropic-beta") + if clientBetaHeader == "" { + req.Header.Set("anthropic-beta", claude.CountTokensBetaHeader) + } else { + beta := s.getBetaHeader(modelID, clientBetaHeader) + if !strings.Contains(beta, claude.BetaTokenCounting) { + beta = beta + "," + claude.BetaTokenCounting + } + req.Header.Set("anthropic-beta", beta) + } + } } else if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey && req.Header.Get("anthropic-beta") == "" { // API-key:与 messages 同步的按需 beta 注入(默认关闭) if requestNeedsBetaFeatures(body) { @@ -3826,6 +5069,13 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con } } + if c != nil && tokenType == "oauth" { + c.Set(claudeMimicDebugInfoKey, buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode)) + } + if s.debugClaudeMimicEnabled() { + logClaudeMimicDebug(req, body, account, tokenType, mimicClaudeCode) + } + return req, nil } diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 396c4829..2d2e86d5 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -36,6 +36,11 @@ const ( geminiRetryMaxDelay = 16 * time.Second ) +// Gemini tool calling now requires `thoughtSignature` in parts that include `functionCall`. +// Many clients don't send it; we inject a known dummy signature to satisfy the validator. +// Ref: https://ai.google.dev/gemini-api/docs/thought-signatures +const geminiDummyThoughtSignature = "skip_thought_signature_validator" + type GeminiMessagesCompatService struct { accountRepo AccountRepository groupRepo GroupRepository @@ -528,6 +533,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex if err != nil { return nil, s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) } + geminiReq = ensureGeminiFunctionCallThoughtSignatures(geminiReq) originalClaudeBody := body proxyURL := "" @@ -931,6 +937,13 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex } } + // 图片生成计费 + imageCount := 0 + imageSize := s.extractImageSize(body) + if isImageGenerationModel(originalModel) { + imageCount = 1 + } + return &ForwardResult{ RequestID: requestID, Usage: *usage, @@ -938,6 +951,8 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex Stream: req.Stream, Duration: time.Since(startTime), FirstTokenMs: firstTokenMs, + ImageCount: imageCount, + ImageSize: imageSize, }, nil } @@ -969,6 +984,10 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. return nil, s.writeGoogleError(c, http.StatusNotFound, "Unsupported action: "+action) } + // Some Gemini upstreams validate tool call parts strictly; ensure any `functionCall` part includes a + // `thoughtSignature` to avoid frequent INVALID_ARGUMENT 400s. + body = ensureGeminiFunctionCallThoughtSignatures(body) + mappedModel := originalModel if account.Type == AccountTypeAPIKey { mappedModel = account.GetMappedModel(originalModel) @@ -1371,6 +1390,13 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. usage = &ClaudeUsage{} } + // 图片生成计费 + imageCount := 0 + imageSize := s.extractImageSize(body) + if isImageGenerationModel(originalModel) { + imageCount = 1 + } + return &ForwardResult{ RequestID: requestID, Usage: *usage, @@ -1378,6 +1404,8 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. Stream: stream, Duration: time.Since(startTime), FirstTokenMs: firstTokenMs, + ImageCount: imageCount, + ImageSize: imageSize, }, nil } @@ -2504,9 +2532,13 @@ func extractGeminiUsage(geminiResp map[string]any) *ClaudeUsage { } prompt, _ := asInt(usageMeta["promptTokenCount"]) cand, _ := asInt(usageMeta["candidatesTokenCount"]) + cached, _ := asInt(usageMeta["cachedContentTokenCount"]) + // 注意:Gemini 的 promptTokenCount 包含 cachedContentTokenCount, + // 但 Claude 的 input_tokens 不包含 cache_read_input_tokens,需要减去 return &ClaudeUsage{ - InputTokens: prompt, - OutputTokens: cand, + InputTokens: prompt - cached, + OutputTokens: cand, + CacheReadInputTokens: cached, } } @@ -2635,6 +2667,58 @@ func nextGeminiDailyResetUnix() *int64 { return &ts } +func ensureGeminiFunctionCallThoughtSignatures(body []byte) []byte { + // Fast path: only run when functionCall is present. + if !bytes.Contains(body, []byte(`"functionCall"`)) { + return body + } + + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return body + } + + contentsAny, ok := payload["contents"].([]any) + if !ok || len(contentsAny) == 0 { + return body + } + + modified := false + for _, c := range contentsAny { + cm, ok := c.(map[string]any) + if !ok { + continue + } + partsAny, ok := cm["parts"].([]any) + if !ok || len(partsAny) == 0 { + continue + } + for _, p := range partsAny { + pm, ok := p.(map[string]any) + if !ok || pm == nil { + continue + } + if fc, ok := pm["functionCall"].(map[string]any); !ok || fc == nil { + continue + } + ts, _ := pm["thoughtSignature"].(string) + if strings.TrimSpace(ts) == "" { + pm["thoughtSignature"] = geminiDummyThoughtSignature + modified = true + } + } + } + + if !modified { + return body + } + b, err := json.Marshal(payload) + if err != nil { + return body + } + return b +} + func extractGeminiFinishReason(geminiResp map[string]any) string { if candidates, ok := geminiResp["candidates"].([]any); ok && len(candidates) > 0 { if cand, ok := candidates[0].(map[string]any); ok { @@ -2834,7 +2918,13 @@ func convertClaudeMessagesToGeminiContents(messages any, toolUseIDToName map[str if strings.TrimSpace(id) != "" && strings.TrimSpace(name) != "" { toolUseIDToName[id] = name } + signature, _ := bm["signature"].(string) + signature = strings.TrimSpace(signature) + if signature == "" { + signature = geminiDummyThoughtSignature + } parts = append(parts, map[string]any{ + "thoughtSignature": signature, "functionCall": map[string]any{ "name": name, "args": bm["input"], @@ -3031,3 +3121,26 @@ func convertClaudeGenerationConfig(req map[string]any) map[string]any { } return out } + +// extractImageSize 从 Gemini 请求中提取 image_size 参数 +func (s *GeminiMessagesCompatService) extractImageSize(body []byte) string { + var req struct { + GenerationConfig *struct { + ImageConfig *struct { + ImageSize string `json:"imageSize"` + } `json:"imageConfig"` + } `json:"generationConfig"` + } + if err := json.Unmarshal(body, &req); err != nil { + return "2K" + } + + if req.GenerationConfig != nil && req.GenerationConfig.ImageConfig != nil { + size := strings.ToUpper(strings.TrimSpace(req.GenerationConfig.ImageConfig.ImageSize)) + if size == "1K" || size == "2K" || size == "4K" { + return size + } + } + + return "2K" +} diff --git a/backend/internal/service/gemini_messages_compat_service_test.go b/backend/internal/service/gemini_messages_compat_service_test.go index d49f2eb3..f31b40ec 100644 --- a/backend/internal/service/gemini_messages_compat_service_test.go +++ b/backend/internal/service/gemini_messages_compat_service_test.go @@ -1,6 +1,8 @@ package service import ( + "encoding/json" + "strings" "testing" ) @@ -126,3 +128,78 @@ func TestConvertClaudeToolsToGeminiTools_CustomType(t *testing.T) { }) } } + +func TestConvertClaudeMessagesToGeminiGenerateContent_AddsThoughtSignatureForToolUse(t *testing.T) { + claudeReq := map[string]any{ + "model": "claude-haiku-4-5-20251001", + "max_tokens": 10, + "messages": []any{ + map[string]any{ + "role": "user", + "content": []any{ + map[string]any{"type": "text", "text": "hi"}, + }, + }, + map[string]any{ + "role": "assistant", + "content": []any{ + map[string]any{"type": "text", "text": "ok"}, + map[string]any{ + "type": "tool_use", + "id": "toolu_123", + "name": "default_api:write_file", + "input": map[string]any{"path": "a.txt", "content": "x"}, + // no signature on purpose + }, + }, + }, + }, + "tools": []any{ + map[string]any{ + "name": "default_api:write_file", + "description": "write file", + "input_schema": map[string]any{ + "type": "object", + "properties": map[string]any{"path": map[string]any{"type": "string"}}, + }, + }, + }, + } + b, _ := json.Marshal(claudeReq) + + out, err := convertClaudeMessagesToGeminiGenerateContent(b) + if err != nil { + t.Fatalf("convert failed: %v", err) + } + s := string(out) + if !strings.Contains(s, "\"functionCall\"") { + t.Fatalf("expected functionCall in output, got: %s", s) + } + if !strings.Contains(s, "\"thoughtSignature\":\""+geminiDummyThoughtSignature+"\"") { + t.Fatalf("expected injected thoughtSignature %q, got: %s", geminiDummyThoughtSignature, s) + } +} + +func TestEnsureGeminiFunctionCallThoughtSignatures_InsertsWhenMissing(t *testing.T) { + geminiReq := map[string]any{ + "contents": []any{ + map[string]any{ + "role": "user", + "parts": []any{ + map[string]any{ + "functionCall": map[string]any{ + "name": "default_api:write_file", + "args": map[string]any{"path": "a.txt"}, + }, + }, + }, + }, + }, + } + b, _ := json.Marshal(geminiReq) + out := ensureGeminiFunctionCallThoughtSignatures(b) + s := string(out) + if !strings.Contains(s, "\"thoughtSignature\":\""+geminiDummyThoughtSignature+"\"") { + t.Fatalf("expected injected thoughtSignature %q, got: %s", geminiDummyThoughtSignature, s) + } +} diff --git a/backend/internal/service/gemini_multiplatform_test.go b/backend/internal/service/gemini_multiplatform_test.go index a2c6f937..be72b8d7 100644 --- a/backend/internal/service/gemini_multiplatform_test.go +++ b/backend/internal/service/gemini_multiplatform_test.go @@ -221,6 +221,14 @@ func (m *mockGroupRepoForGemini) DeleteAccountGroupsByGroupID(ctx context.Contex return 0, nil } +func (m *mockGroupRepoForGemini) BindAccountsToGroup(ctx context.Context, groupID int64, accountIDs []int64) error { + return nil +} + +func (m *mockGroupRepoForGemini) GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error) { + return nil, nil +} + var _ GroupRepository = (*mockGroupRepoForGemini)(nil) // mockGatewayCacheForGemini Gemini 测试用的 cache mock diff --git a/backend/internal/service/gemini_native_signature_cleaner.go b/backend/internal/service/gemini_native_signature_cleaner.go new file mode 100644 index 00000000..b3352fb0 --- /dev/null +++ b/backend/internal/service/gemini_native_signature_cleaner.go @@ -0,0 +1,72 @@ +package service + +import ( + "encoding/json" +) + +// CleanGeminiNativeThoughtSignatures 从 Gemini 原生 API 请求中移除 thoughtSignature 字段, +// 以避免跨账号签名验证错误。 +// +// 当粘性会话切换账号时(例如原账号异常、不可调度等),旧账号返回的 thoughtSignature +// 会导致新账号的签名验证失败。通过移除这些签名,让新账号重新生成有效的签名。 +// +// CleanGeminiNativeThoughtSignatures removes thoughtSignature fields from Gemini native API requests +// to avoid cross-account signature validation errors. +// +// When sticky session switches accounts (e.g., original account becomes unavailable), +// thoughtSignatures from the old account will cause validation failures on the new account. +// By removing these signatures, we allow the new account to generate valid signatures. +func CleanGeminiNativeThoughtSignatures(body []byte) []byte { + if len(body) == 0 { + return body + } + + // 解析 JSON + var data any + if err := json.Unmarshal(body, &data); err != nil { + // 如果解析失败,返回原始 body(可能不是 JSON 或格式不正确) + return body + } + + // 递归清理 thoughtSignature + cleaned := cleanThoughtSignaturesRecursive(data) + + // 重新序列化 + result, err := json.Marshal(cleaned) + if err != nil { + // 如果序列化失败,返回原始 body + return body + } + + return result +} + +// cleanThoughtSignaturesRecursive 递归遍历数据结构,移除所有 thoughtSignature 字段 +func cleanThoughtSignaturesRecursive(data any) any { + switch v := data.(type) { + case map[string]any: + // 创建新的 map,移除 thoughtSignature + result := make(map[string]any, len(v)) + for key, value := range v { + // 跳过 thoughtSignature 字段 + if key == "thoughtSignature" { + continue + } + // 递归处理嵌套结构 + result[key] = cleanThoughtSignaturesRecursive(value) + } + return result + + case []any: + // 递归处理数组中的每个元素 + result := make([]any, len(v)) + for i, item := range v { + result[i] = cleanThoughtSignaturesRecursive(item) + } + return result + + default: + // 基本类型(string, number, bool, null)直接返回 + return v + } +} diff --git a/backend/internal/service/group_service.go b/backend/internal/service/group_service.go index 324f347b..a2bf2073 100644 --- a/backend/internal/service/group_service.go +++ b/backend/internal/service/group_service.go @@ -29,6 +29,10 @@ type GroupRepository interface { ExistsByName(ctx context.Context, name string) (bool, error) GetAccountCount(ctx context.Context, groupID int64) (int64, error) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) + // GetAccountIDsByGroupIDs 获取多个分组的所有账号 ID(去重) + GetAccountIDsByGroupIDs(ctx context.Context, groupIDs []int64) ([]int64, error) + // BindAccountsToGroup 将多个账号绑定到指定分组 + BindAccountsToGroup(ctx context.Context, groupID int64, accountIDs []int64) error } // CreateGroupRequest 创建分组请求 diff --git a/backend/internal/service/identity_service.go b/backend/internal/service/identity_service.go index e2e723b0..a620ac4d 100644 --- a/backend/internal/service/identity_service.go +++ b/backend/internal/service/identity_service.go @@ -26,13 +26,13 @@ var ( // 默认指纹值(当客户端未提供时使用) var defaultFingerprint = Fingerprint{ - UserAgent: "claude-cli/2.0.62 (external, cli)", + UserAgent: "claude-cli/2.1.22 (external, cli)", StainlessLang: "js", - StainlessPackageVersion: "0.52.0", + StainlessPackageVersion: "0.70.0", StainlessOS: "Linux", - StainlessArch: "x64", + StainlessArch: "arm64", StainlessRuntime: "node", - StainlessRuntimeVersion: "v22.14.0", + StainlessRuntimeVersion: "v24.13.0", } // Fingerprint represents account fingerprint data @@ -327,7 +327,7 @@ func generateUUIDFromSeed(seed string) string { } // parseUserAgentVersion 解析user-agent版本号 -// 例如:claude-cli/2.0.62 -> (2, 0, 62) +// 例如:claude-cli/2.1.2 -> (2, 1, 2) func parseUserAgentVersion(ua string) (major, minor, patch int, ok bool) { // 匹配 xxx/x.y.z 格式 matches := userAgentVersionRegex.FindStringSubmatch(ua) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 65ba01b3..6d93e92d 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -60,6 +60,92 @@ type OpenAICodexUsageSnapshot struct { UpdatedAt string `json:"updated_at,omitempty"` } +// NormalizedCodexLimits contains normalized 5h/7d rate limit data +type NormalizedCodexLimits struct { + Used5hPercent *float64 + Reset5hSeconds *int + Window5hMinutes *int + Used7dPercent *float64 + Reset7dSeconds *int + Window7dMinutes *int +} + +// Normalize converts primary/secondary fields to canonical 5h/7d fields. +// Strategy: Compare window_minutes to determine which is 5h vs 7d. +// Returns nil if snapshot is nil or has no useful data. +func (s *OpenAICodexUsageSnapshot) Normalize() *NormalizedCodexLimits { + if s == nil { + return nil + } + + result := &NormalizedCodexLimits{} + + primaryMins := 0 + secondaryMins := 0 + hasPrimaryWindow := false + hasSecondaryWindow := false + + if s.PrimaryWindowMinutes != nil { + primaryMins = *s.PrimaryWindowMinutes + hasPrimaryWindow = true + } + if s.SecondaryWindowMinutes != nil { + secondaryMins = *s.SecondaryWindowMinutes + hasSecondaryWindow = true + } + + // Determine mapping based on window_minutes + use5hFromPrimary := false + use7dFromPrimary := false + + if hasPrimaryWindow && hasSecondaryWindow { + // Both known: smaller window is 5h, larger is 7d + if primaryMins < secondaryMins { + use5hFromPrimary = true + } else { + use7dFromPrimary = true + } + } else if hasPrimaryWindow { + // Only primary known: classify by threshold (<=360 min = 6h -> 5h window) + if primaryMins <= 360 { + use5hFromPrimary = true + } else { + use7dFromPrimary = true + } + } else if hasSecondaryWindow { + // Only secondary known: classify by threshold + if secondaryMins <= 360 { + // 5h from secondary, so primary (if any data) is 7d + use7dFromPrimary = true + } else { + // 7d from secondary, so primary (if any data) is 5h + use5hFromPrimary = true + } + } else { + // No window_minutes: fall back to legacy assumption (primary=7d, secondary=5h) + use7dFromPrimary = true + } + + // Assign values + if use5hFromPrimary { + result.Used5hPercent = s.PrimaryUsedPercent + result.Reset5hSeconds = s.PrimaryResetAfterSeconds + result.Window5hMinutes = s.PrimaryWindowMinutes + result.Used7dPercent = s.SecondaryUsedPercent + result.Reset7dSeconds = s.SecondaryResetAfterSeconds + result.Window7dMinutes = s.SecondaryWindowMinutes + } else if use7dFromPrimary { + result.Used7dPercent = s.PrimaryUsedPercent + result.Reset7dSeconds = s.PrimaryResetAfterSeconds + result.Window7dMinutes = s.PrimaryWindowMinutes + result.Used5hPercent = s.SecondaryUsedPercent + result.Reset5hSeconds = s.SecondaryResetAfterSeconds + result.Window5hMinutes = s.SecondaryWindowMinutes + } + + return result +} + // OpenAIUsage represents OpenAI API response usage type OpenAIUsage struct { InputTokens int `json:"input_tokens"` @@ -70,12 +156,15 @@ type OpenAIUsage struct { // OpenAIForwardResult represents the result of forwarding type OpenAIForwardResult struct { - RequestID string - Usage OpenAIUsage - Model string - Stream bool - Duration time.Duration - FirstTokenMs *int + RequestID string + Usage OpenAIUsage + Model string + // ReasoningEffort is extracted from request body (reasoning.effort) or derived from model suffix. + // Stored for usage records display; nil means not provided / not applicable. + ReasoningEffort *string + Stream bool + Duration time.Duration + FirstTokenMs *int } // OpenAIGatewayService handles OpenAI API gateway operations @@ -756,6 +845,12 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco bodyModified = true } } + + // Remove prompt_cache_retention (not supported by upstream OpenAI API) + if _, has := reqBody["prompt_cache_retention"]; has { + delete(reqBody, "prompt_cache_retention") + bodyModified = true + } } // Re-serialize body only if modified @@ -867,18 +962,21 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco // Extract and save Codex usage snapshot from response headers (for OAuth accounts) if account.Type == AccountTypeOAuth { - if snapshot := extractCodexUsageHeaders(resp.Header); snapshot != nil { + if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil { s.updateCodexUsageSnapshot(ctx, account.ID, snapshot) } } + reasoningEffort := extractOpenAIReasoningEffort(reqBody, originalModel) + return &OpenAIForwardResult{ - RequestID: resp.Header.Get("x-request-id"), - Usage: *usage, - Model: originalModel, - Stream: reqStream, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, + RequestID: resp.Header.Get("x-request-id"), + Usage: *usage, + Model: originalModel, + ReasoningEffort: reasoningEffort, + Stream: reqStream, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, }, nil } @@ -1174,15 +1272,29 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp // 记录上次收到上游数据的时间,用于控制 keepalive 发送频率 lastDataAt := time.Now() - // 仅发送一次错误事件,避免多次写入导致协议混乱(写失败时尽力通知客户端) + // 仅发送一次错误事件,避免多次写入导致协议混乱。 + // 注意:OpenAI `/v1/responses` streaming 事件必须符合 OpenAI Responses schema; + // 否则下游 SDK(例如 OpenCode)会因为类型校验失败而报错。 errorEventSent := false + clientDisconnected := false // 客户端断开后继续 drain 上游以收集 usage sendErrorEvent := func(reason string) { - if errorEventSent { + if errorEventSent || clientDisconnected { return } errorEventSent = true - _, _ = fmt.Fprintf(w, "event: error\ndata: {\"error\":\"%s\"}\n\n", reason) - flusher.Flush() + payload := map[string]any{ + "type": "error", + "sequence_number": 0, + "error": map[string]any{ + "type": "upstream_error", + "message": reason, + "code": reason, + }, + } + if b, err := json.Marshal(payload); err == nil { + _, _ = fmt.Fprintf(w, "data: %s\n\n", b) + flusher.Flush() + } } needModelReplace := originalModel != mappedModel @@ -1194,6 +1306,17 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, nil } if ev.err != nil { + // 客户端断开/取消请求时,上游读取往往会返回 context canceled。 + // /v1/responses 的 SSE 事件必须符合 OpenAI 协议;这里不注入自定义 error event,避免下游 SDK 解析失败。 + if errors.Is(ev.err, context.Canceled) || errors.Is(ev.err, context.DeadlineExceeded) { + log.Printf("Context canceled during streaming, returning collected usage") + return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, nil + } + // 客户端已断开时,上游出错仅影响体验,不影响计费;返回已收集 usage + if clientDisconnected { + log.Printf("Upstream read error after client disconnect: %v, returning collected usage", ev.err) + return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, nil + } if errors.Is(ev.err, bufio.ErrTooLong) { log.Printf("SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, ev.err) sendErrorEvent("response_too_large") @@ -1217,15 +1340,19 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp // Correct Codex tool calls if needed (apply_patch -> edit, etc.) if correctedData, corrected := s.toolCorrector.CorrectToolCallsInSSEData(data); corrected { + data = correctedData line = "data: " + correctedData } - // Forward line - if _, err := fmt.Fprintf(w, "%s\n", line); err != nil { - sendErrorEvent("write_failed") - return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, err + // 写入客户端(客户端断开后继续 drain 上游) + if !clientDisconnected { + if _, err := fmt.Fprintf(w, "%s\n", line); err != nil { + clientDisconnected = true + log.Printf("Client disconnected during streaming, continuing to drain upstream for billing") + } else { + flusher.Flush() + } } - flusher.Flush() // Record first token time if firstTokenMs == nil && data != "" && data != "[DONE]" { @@ -1235,11 +1362,14 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp s.parseSSEUsage(data, usage) } else { // Forward non-data lines as-is - if _, err := fmt.Fprintf(w, "%s\n", line); err != nil { - sendErrorEvent("write_failed") - return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, err + if !clientDisconnected { + if _, err := fmt.Fprintf(w, "%s\n", line); err != nil { + clientDisconnected = true + log.Printf("Client disconnected during streaming, continuing to drain upstream for billing") + } else { + flusher.Flush() + } } - flusher.Flush() } case <-intervalCh: @@ -1247,6 +1377,10 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp if time.Since(lastRead) < streamInterval { continue } + if clientDisconnected { + log.Printf("Upstream timeout after client disconnect, returning collected usage") + return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, nil + } log.Printf("Stream data interval timeout: account=%d model=%s interval=%s", account.ID, originalModel, streamInterval) // 处理流超时,可能标记账户为临时不可调度或错误状态 if s.rateLimitService != nil { @@ -1256,11 +1390,16 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream data interval timeout") case <-keepaliveCh: + if clientDisconnected { + continue + } if time.Since(lastDataAt) < keepaliveInterval { continue } if _, err := fmt.Fprint(w, ":\n\n"); err != nil { - return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}, err + clientDisconnected = true + log.Printf("Client disconnected during streaming, continuing to drain upstream for billing") + continue } flusher.Flush() } @@ -1601,6 +1740,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec AccountID: account.ID, RequestID: result.RequestID, Model: result.Model, + ReasoningEffort: result.ReasoningEffort, InputTokens: actualInputTokens, OutputTokens: result.Usage.OutputTokens, CacheCreationTokens: result.Usage.CacheCreationInputTokens, @@ -1665,8 +1805,9 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec return nil } -// extractCodexUsageHeaders extracts Codex usage limits from response headers -func extractCodexUsageHeaders(headers http.Header) *OpenAICodexUsageSnapshot { +// ParseCodexRateLimitHeaders extracts Codex usage limits from response headers. +// Exported for use in ratelimit_service when handling OpenAI 429 responses. +func ParseCodexRateLimitHeaders(headers http.Header) *OpenAICodexUsageSnapshot { snapshot := &OpenAICodexUsageSnapshot{} hasData := false @@ -1740,6 +1881,8 @@ func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, acc // Convert snapshot to map for merging into Extra updates := make(map[string]any) + + // Save raw primary/secondary fields for debugging/tracing if snapshot.PrimaryUsedPercent != nil { updates["codex_primary_used_percent"] = *snapshot.PrimaryUsedPercent } @@ -1763,109 +1906,25 @@ func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, acc } updates["codex_usage_updated_at"] = snapshot.UpdatedAt - // Normalize to canonical 5h/7d fields based on window_minutes - // This fixes the issue where OpenAI's primary/secondary naming is reversed - // Strategy: Compare the two windows and assign the smaller one to 5h, larger one to 7d - - // IMPORTANT: We can only reliably determine window type from window_minutes field - // The reset_after_seconds is remaining time, not window size, so it cannot be used for comparison - - var primaryWindowMins, secondaryWindowMins int - var hasPrimaryWindow, hasSecondaryWindow bool - - // Only use window_minutes for reliable window size comparison - if snapshot.PrimaryWindowMinutes != nil { - primaryWindowMins = *snapshot.PrimaryWindowMinutes - hasPrimaryWindow = true - } - - if snapshot.SecondaryWindowMinutes != nil { - secondaryWindowMins = *snapshot.SecondaryWindowMinutes - hasSecondaryWindow = true - } - - // Determine which is 5h and which is 7d - var use5hFromPrimary, use7dFromPrimary bool - var use5hFromSecondary, use7dFromSecondary bool - - if hasPrimaryWindow && hasSecondaryWindow { - // Both window sizes known: compare and assign smaller to 5h, larger to 7d - if primaryWindowMins < secondaryWindowMins { - use5hFromPrimary = true - use7dFromSecondary = true - } else { - use5hFromSecondary = true - use7dFromPrimary = true + // Normalize to canonical 5h/7d fields + if normalized := snapshot.Normalize(); normalized != nil { + if normalized.Used5hPercent != nil { + updates["codex_5h_used_percent"] = *normalized.Used5hPercent } - } else if hasPrimaryWindow { - // Only primary window size known: classify by absolute threshold - if primaryWindowMins <= 360 { - use5hFromPrimary = true - } else { - use7dFromPrimary = true + if normalized.Reset5hSeconds != nil { + updates["codex_5h_reset_after_seconds"] = *normalized.Reset5hSeconds } - } else if hasSecondaryWindow { - // Only secondary window size known: classify by absolute threshold - if secondaryWindowMins <= 360 { - use5hFromSecondary = true - } else { - use7dFromSecondary = true + if normalized.Window5hMinutes != nil { + updates["codex_5h_window_minutes"] = *normalized.Window5hMinutes } - } else { - // No window_minutes available: cannot reliably determine window types - // Fall back to legacy assumption (may be incorrect) - // Assume primary=7d, secondary=5h based on historical observation - if snapshot.SecondaryUsedPercent != nil || snapshot.SecondaryResetAfterSeconds != nil || snapshot.SecondaryWindowMinutes != nil { - use5hFromSecondary = true + if normalized.Used7dPercent != nil { + updates["codex_7d_used_percent"] = *normalized.Used7dPercent } - if snapshot.PrimaryUsedPercent != nil || snapshot.PrimaryResetAfterSeconds != nil || snapshot.PrimaryWindowMinutes != nil { - use7dFromPrimary = true + if normalized.Reset7dSeconds != nil { + updates["codex_7d_reset_after_seconds"] = *normalized.Reset7dSeconds } - } - - // Write canonical 5h fields - if use5hFromPrimary { - if snapshot.PrimaryUsedPercent != nil { - updates["codex_5h_used_percent"] = *snapshot.PrimaryUsedPercent - } - if snapshot.PrimaryResetAfterSeconds != nil { - updates["codex_5h_reset_after_seconds"] = *snapshot.PrimaryResetAfterSeconds - } - if snapshot.PrimaryWindowMinutes != nil { - updates["codex_5h_window_minutes"] = *snapshot.PrimaryWindowMinutes - } - } else if use5hFromSecondary { - if snapshot.SecondaryUsedPercent != nil { - updates["codex_5h_used_percent"] = *snapshot.SecondaryUsedPercent - } - if snapshot.SecondaryResetAfterSeconds != nil { - updates["codex_5h_reset_after_seconds"] = *snapshot.SecondaryResetAfterSeconds - } - if snapshot.SecondaryWindowMinutes != nil { - updates["codex_5h_window_minutes"] = *snapshot.SecondaryWindowMinutes - } - } - - // Write canonical 7d fields - if use7dFromPrimary { - if snapshot.PrimaryUsedPercent != nil { - updates["codex_7d_used_percent"] = *snapshot.PrimaryUsedPercent - } - if snapshot.PrimaryResetAfterSeconds != nil { - updates["codex_7d_reset_after_seconds"] = *snapshot.PrimaryResetAfterSeconds - } - if snapshot.PrimaryWindowMinutes != nil { - updates["codex_7d_window_minutes"] = *snapshot.PrimaryWindowMinutes - } - } else if use7dFromSecondary { - if snapshot.SecondaryUsedPercent != nil { - updates["codex_7d_used_percent"] = *snapshot.SecondaryUsedPercent - } - if snapshot.SecondaryResetAfterSeconds != nil { - updates["codex_7d_reset_after_seconds"] = *snapshot.SecondaryResetAfterSeconds - } - if snapshot.SecondaryWindowMinutes != nil { - updates["codex_7d_window_minutes"] = *snapshot.SecondaryWindowMinutes + if normalized.Window7dMinutes != nil { + updates["codex_7d_window_minutes"] = *normalized.Window7dMinutes } } @@ -1876,3 +1935,86 @@ func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, acc _ = s.accountRepo.UpdateExtra(updateCtx, accountID, updates) }() } + +func getOpenAIReasoningEffortFromReqBody(reqBody map[string]any) (value string, present bool) { + if reqBody == nil { + return "", false + } + + // Primary: reasoning.effort + if reasoning, ok := reqBody["reasoning"].(map[string]any); ok { + if effort, ok := reasoning["effort"].(string); ok { + return normalizeOpenAIReasoningEffort(effort), true + } + } + + // Fallback: some clients may use a flat field. + if effort, ok := reqBody["reasoning_effort"].(string); ok { + return normalizeOpenAIReasoningEffort(effort), true + } + + return "", false +} + +func deriveOpenAIReasoningEffortFromModel(model string) string { + if strings.TrimSpace(model) == "" { + return "" + } + + modelID := strings.TrimSpace(model) + if strings.Contains(modelID, "/") { + parts := strings.Split(modelID, "/") + modelID = parts[len(parts)-1] + } + + parts := strings.FieldsFunc(strings.ToLower(modelID), func(r rune) bool { + switch r { + case '-', '_', ' ': + return true + default: + return false + } + }) + if len(parts) == 0 { + return "" + } + + return normalizeOpenAIReasoningEffort(parts[len(parts)-1]) +} + +func extractOpenAIReasoningEffort(reqBody map[string]any, requestedModel string) *string { + if value, present := getOpenAIReasoningEffortFromReqBody(reqBody); present { + if value == "" { + return nil + } + return &value + } + + value := deriveOpenAIReasoningEffortFromModel(requestedModel) + if value == "" { + return nil + } + return &value +} + +func normalizeOpenAIReasoningEffort(raw string) string { + value := strings.ToLower(strings.TrimSpace(raw)) + if value == "" { + return "" + } + + // Normalize separators for "x-high"/"x_high" variants. + value = strings.NewReplacer("-", "", "_", "", " ", "").Replace(value) + + switch value { + case "none", "minimal": + return "" + case "low", "medium", "high": + return value + case "xhigh", "extrahigh": + return "xhigh" + default: + // Only store known effort levels for now to keep UI consistent. + return "" + } +} diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index 1912e244..ae69a986 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -59,6 +59,25 @@ type stubConcurrencyCache struct { skipDefaultLoad bool } +type cancelReadCloser struct{} + +func (c cancelReadCloser) Read(p []byte) (int, error) { return 0, context.Canceled } +func (c cancelReadCloser) Close() error { return nil } + +type failingGinWriter struct { + gin.ResponseWriter + failAfter int + writes int +} + +func (w *failingGinWriter) Write(p []byte) (int, error) { + if w.writes >= w.failAfter { + return 0, errors.New("write failed") + } + w.writes++ + return w.ResponseWriter.Write(p) +} + func (c stubConcurrencyCache) AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) { if c.acquireResults != nil { if result, ok := c.acquireResults[accountID]; ok { @@ -814,8 +833,85 @@ func TestOpenAIStreamingTimeout(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") { t.Fatalf("expected stream timeout error, got %v", err) } - if !strings.Contains(rec.Body.String(), "stream_timeout") { - t.Fatalf("expected stream_timeout SSE error, got %q", rec.Body.String()) + if !strings.Contains(rec.Body.String(), "\"type\":\"error\"") || !strings.Contains(rec.Body.String(), "stream_timeout") { + t.Fatalf("expected OpenAI-compatible error SSE event, got %q", rec.Body.String()) + } +} + +func TestOpenAIStreamingContextCanceledDoesNotInjectErrorEvent(t *testing.T) { + gin.SetMode(gin.TestMode) + cfg := &config.Config{ + Gateway: config.GatewayConfig{ + StreamDataIntervalTimeout: 0, + StreamKeepaliveInterval: 0, + MaxLineSize: defaultMaxLineSize, + }, + } + svc := &OpenAIGatewayService{cfg: cfg} + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + c.Request = httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx) + + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: cancelReadCloser{}, + Header: http.Header{}, + } + + _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1}, time.Now(), "model", "model") + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if strings.Contains(rec.Body.String(), "event: error") || strings.Contains(rec.Body.String(), "stream_read_error") { + t.Fatalf("expected no injected SSE error event, got %q", rec.Body.String()) + } +} + +func TestOpenAIStreamingClientDisconnectDrainsUpstreamUsage(t *testing.T) { + gin.SetMode(gin.TestMode) + cfg := &config.Config{ + Gateway: config.GatewayConfig{ + StreamDataIntervalTimeout: 0, + StreamKeepaliveInterval: 0, + MaxLineSize: defaultMaxLineSize, + }, + } + svc := &OpenAIGatewayService{cfg: cfg} + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + c.Writer = &failingGinWriter{ResponseWriter: c.Writer, failAfter: 0} + + pr, pw := io.Pipe() + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: pr, + Header: http.Header{}, + } + + go func() { + defer func() { _ = pw.Close() }() + _, _ = pw.Write([]byte("data: {\"type\":\"response.in_progress\",\"response\":{}}\n\n")) + _, _ = pw.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":3,\"output_tokens\":5,\"input_tokens_details\":{\"cached_tokens\":1}}}}\n\n")) + }() + + result, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1}, time.Now(), "model", "model") + _ = pr.Close() + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if result == nil || result.usage == nil { + t.Fatalf("expected usage result") + } + if result.usage.InputTokens != 3 || result.usage.OutputTokens != 5 || result.usage.CacheReadInputTokens != 1 { + t.Fatalf("unexpected usage: %+v", *result.usage) + } + if strings.Contains(rec.Body.String(), "event: error") || strings.Contains(rec.Body.String(), "write_failed") { + t.Fatalf("expected no injected SSE error event, got %q", rec.Body.String()) } } @@ -854,8 +950,8 @@ func TestOpenAIStreamingTooLong(t *testing.T) { if !errors.Is(err, bufio.ErrTooLong) { t.Fatalf("expected ErrTooLong, got %v", err) } - if !strings.Contains(rec.Body.String(), "response_too_large") { - t.Fatalf("expected response_too_large SSE error, got %q", rec.Body.String()) + if !strings.Contains(rec.Body.String(), "\"type\":\"error\"") || !strings.Contains(rec.Body.String(), "response_too_large") { + t.Fatalf("expected OpenAI-compatible error SSE event, got %q", rec.Body.String()) } } diff --git a/backend/internal/service/openai_oauth_service.go b/backend/internal/service/openai_oauth_service.go index 182e08fe..ca7470b9 100644 --- a/backend/internal/service/openai_oauth_service.go +++ b/backend/internal/service/openai_oauth_service.go @@ -2,9 +2,10 @@ package service import ( "context" - "fmt" + "net/http" "time" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" ) @@ -35,12 +36,12 @@ func (s *OpenAIOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64 // Generate PKCE values state, err := openai.GenerateState() if err != nil { - return nil, fmt.Errorf("failed to generate state: %w", err) + return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_OAUTH_STATE_FAILED", "failed to generate state: %v", err) } codeVerifier, err := openai.GenerateCodeVerifier() if err != nil { - return nil, fmt.Errorf("failed to generate code verifier: %w", err) + return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_OAUTH_VERIFIER_FAILED", "failed to generate code verifier: %v", err) } codeChallenge := openai.GenerateCodeChallenge(codeVerifier) @@ -48,14 +49,17 @@ func (s *OpenAIOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64 // Generate session ID sessionID, err := openai.GenerateSessionID() if err != nil { - return nil, fmt.Errorf("failed to generate session ID: %w", err) + return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_OAUTH_SESSION_FAILED", "failed to generate session ID: %v", err) } // Get proxy URL if specified var proxyURL string if proxyID != nil { proxy, err := s.proxyRepo.GetByID(ctx, *proxyID) - if err == nil && proxy != nil { + if err != nil { + return nil, infraerrors.Newf(http.StatusBadRequest, "OPENAI_OAUTH_PROXY_NOT_FOUND", "proxy not found: %v", err) + } + if proxy != nil { proxyURL = proxy.URL() } } @@ -110,14 +114,17 @@ func (s *OpenAIOAuthService) ExchangeCode(ctx context.Context, input *OpenAIExch // Get session session, ok := s.sessionStore.Get(input.SessionID) if !ok { - return nil, fmt.Errorf("session not found or expired") + return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_OAUTH_SESSION_NOT_FOUND", "session not found or expired") } - // Get proxy URL + // Get proxy URL: prefer input.ProxyID, fallback to session.ProxyURL proxyURL := session.ProxyURL if input.ProxyID != nil { proxy, err := s.proxyRepo.GetByID(ctx, *input.ProxyID) - if err == nil && proxy != nil { + if err != nil { + return nil, infraerrors.Newf(http.StatusBadRequest, "OPENAI_OAUTH_PROXY_NOT_FOUND", "proxy not found: %v", err) + } + if proxy != nil { proxyURL = proxy.URL() } } @@ -131,7 +138,7 @@ func (s *OpenAIOAuthService) ExchangeCode(ctx context.Context, input *OpenAIExch // Exchange code for token tokenResp, err := s.oauthClient.ExchangeCode(ctx, input.Code, session.CodeVerifier, redirectURI, proxyURL) if err != nil { - return nil, fmt.Errorf("failed to exchange code: %w", err) + return nil, err } // Parse ID token to get user info @@ -201,12 +208,12 @@ func (s *OpenAIOAuthService) RefreshToken(ctx context.Context, refreshToken stri // RefreshAccountToken refreshes token for an OpenAI account func (s *OpenAIOAuthService) RefreshAccountToken(ctx context.Context, account *Account) (*OpenAITokenInfo, error) { if !account.IsOpenAI() { - return nil, fmt.Errorf("account is not an OpenAI account") + return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_OAUTH_INVALID_ACCOUNT", "account is not an OpenAI account") } refreshToken := account.GetOpenAIRefreshToken() if refreshToken == "" { - return nil, fmt.Errorf("no refresh token available") + return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_OAUTH_NO_REFRESH_TOKEN", "no refresh token available") } var proxyURL string diff --git a/backend/internal/service/ops_account_availability.go b/backend/internal/service/ops_account_availability.go index da66ec4d..9be06c15 100644 --- a/backend/internal/service/ops_account_availability.go +++ b/backend/internal/service/ops_account_availability.go @@ -67,6 +67,8 @@ func (s *OpsService) GetAccountAvailabilityStats(ctx context.Context, platformFi isAvailable := acc.Status == StatusActive && acc.Schedulable && !isRateLimited && !isOverloaded && !isTempUnsched + scopeRateLimits := acc.GetAntigravityScopeRateLimits() + if acc.Platform != "" { if _, ok := platform[acc.Platform]; !ok { platform[acc.Platform] = &PlatformAvailability{ @@ -84,6 +86,14 @@ func (s *OpsService) GetAccountAvailabilityStats(ctx context.Context, platformFi if hasError { p.ErrorCount++ } + if len(scopeRateLimits) > 0 { + if p.ScopeRateLimitCount == nil { + p.ScopeRateLimitCount = make(map[string]int64) + } + for scope := range scopeRateLimits { + p.ScopeRateLimitCount[scope]++ + } + } } for _, grp := range acc.Groups { @@ -108,6 +118,14 @@ func (s *OpsService) GetAccountAvailabilityStats(ctx context.Context, platformFi if hasError { g.ErrorCount++ } + if len(scopeRateLimits) > 0 { + if g.ScopeRateLimitCount == nil { + g.ScopeRateLimitCount = make(map[string]int64) + } + for scope := range scopeRateLimits { + g.ScopeRateLimitCount[scope]++ + } + } } displayGroupID := int64(0) @@ -140,6 +158,9 @@ func (s *OpsService) GetAccountAvailabilityStats(ctx context.Context, platformFi item.RateLimitRemainingSec = &remainingSec } } + if len(scopeRateLimits) > 0 { + item.ScopeRateLimits = scopeRateLimits + } if isOverloaded && acc.OverloadUntil != nil { item.OverloadUntil = acc.OverloadUntil remainingSec := int64(time.Until(*acc.OverloadUntil).Seconds()) diff --git a/backend/internal/service/ops_realtime_models.go b/backend/internal/service/ops_realtime_models.go index f7514a24..c7e5715b 100644 --- a/backend/internal/service/ops_realtime_models.go +++ b/backend/internal/service/ops_realtime_models.go @@ -39,22 +39,24 @@ type AccountConcurrencyInfo struct { // PlatformAvailability aggregates account availability by platform. type PlatformAvailability struct { - Platform string `json:"platform"` - TotalAccounts int64 `json:"total_accounts"` - AvailableCount int64 `json:"available_count"` - RateLimitCount int64 `json:"rate_limit_count"` - ErrorCount int64 `json:"error_count"` + Platform string `json:"platform"` + TotalAccounts int64 `json:"total_accounts"` + AvailableCount int64 `json:"available_count"` + RateLimitCount int64 `json:"rate_limit_count"` + ScopeRateLimitCount map[string]int64 `json:"scope_rate_limit_count,omitempty"` + ErrorCount int64 `json:"error_count"` } // GroupAvailability aggregates account availability by group. type GroupAvailability struct { - GroupID int64 `json:"group_id"` - GroupName string `json:"group_name"` - Platform string `json:"platform"` - TotalAccounts int64 `json:"total_accounts"` - AvailableCount int64 `json:"available_count"` - RateLimitCount int64 `json:"rate_limit_count"` - ErrorCount int64 `json:"error_count"` + GroupID int64 `json:"group_id"` + GroupName string `json:"group_name"` + Platform string `json:"platform"` + TotalAccounts int64 `json:"total_accounts"` + AvailableCount int64 `json:"available_count"` + RateLimitCount int64 `json:"rate_limit_count"` + ScopeRateLimitCount map[string]int64 `json:"scope_rate_limit_count,omitempty"` + ErrorCount int64 `json:"error_count"` } // AccountAvailability represents current availability for a single account. @@ -72,10 +74,11 @@ type AccountAvailability struct { IsOverloaded bool `json:"is_overloaded"` HasError bool `json:"has_error"` - RateLimitResetAt *time.Time `json:"rate_limit_reset_at"` - RateLimitRemainingSec *int64 `json:"rate_limit_remaining_sec"` - OverloadUntil *time.Time `json:"overload_until"` - OverloadRemainingSec *int64 `json:"overload_remaining_sec"` - ErrorMessage string `json:"error_message"` - TempUnschedulableUntil *time.Time `json:"temp_unschedulable_until,omitempty"` + RateLimitResetAt *time.Time `json:"rate_limit_reset_at"` + RateLimitRemainingSec *int64 `json:"rate_limit_remaining_sec"` + ScopeRateLimits map[string]int64 `json:"scope_rate_limits,omitempty"` + OverloadUntil *time.Time `json:"overload_until"` + OverloadRemainingSec *int64 `json:"overload_remaining_sec"` + ErrorMessage string `json:"error_message"` + TempUnschedulableUntil *time.Time `json:"temp_unschedulable_until,omitempty"` } diff --git a/backend/internal/service/ops_settings_models.go b/backend/internal/service/ops_settings_models.go index df06f578..ecc62220 100644 --- a/backend/internal/service/ops_settings_models.go +++ b/backend/internal/service/ops_settings_models.go @@ -83,6 +83,7 @@ type OpsAdvancedSettings struct { IgnoreCountTokensErrors bool `json:"ignore_count_tokens_errors"` IgnoreContextCanceled bool `json:"ignore_context_canceled"` IgnoreNoAvailableAccounts bool `json:"ignore_no_available_accounts"` + IgnoreInvalidApiKeyErrors bool `json:"ignore_invalid_api_key_errors"` AutoRefreshEnabled bool `json:"auto_refresh_enabled"` AutoRefreshIntervalSec int `json:"auto_refresh_interval_seconds"` } diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 41bd253c..6b7ebb07 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -343,9 +343,48 @@ func (s *RateLimitService) handleCustomErrorCode(ctx context.Context, account *A // handle429 处理429限流错误 // 解析响应头获取重置时间,标记账号为限流状态 func (s *RateLimitService) handle429(ctx context.Context, account *Account, headers http.Header, responseBody []byte) { - // 解析重置时间戳 + // 1. OpenAI 平台:优先尝试解析 x-codex-* 响应头(用于 rate_limit_exceeded) + if account.Platform == PlatformOpenAI { + if resetAt := s.calculateOpenAI429ResetTime(headers); resetAt != nil { + if err := s.accountRepo.SetRateLimited(ctx, account.ID, *resetAt); err != nil { + slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err) + return + } + slog.Info("openai_account_rate_limited", "account_id", account.ID, "reset_at", *resetAt) + return + } + } + + // 2. 尝试从响应头解析重置时间(Anthropic) resetTimestamp := headers.Get("anthropic-ratelimit-unified-reset") + + // 3. 如果响应头没有,尝试从响应体解析(OpenAI usage_limit_reached, Gemini) if resetTimestamp == "" { + switch account.Platform { + case PlatformOpenAI: + // 尝试解析 OpenAI 的 usage_limit_reached 错误 + if resetAt := parseOpenAIRateLimitResetTime(responseBody); resetAt != nil { + resetTime := time.Unix(*resetAt, 0) + if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetTime); err != nil { + slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err) + return + } + slog.Info("account_rate_limited", "account_id", account.ID, "platform", account.Platform, "reset_at", resetTime, "reset_in", time.Until(resetTime).Truncate(time.Second)) + return + } + case PlatformGemini, PlatformAntigravity: + // 尝试解析 Gemini 格式(用于其他平台) + if resetAt := ParseGeminiRateLimitResetTime(responseBody); resetAt != nil { + resetTime := time.Unix(*resetAt, 0) + if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetTime); err != nil { + slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err) + return + } + slog.Info("account_rate_limited", "account_id", account.ID, "platform", account.Platform, "reset_at", resetTime, "reset_in", time.Until(resetTime).Truncate(time.Second)) + return + } + } + // 没有重置时间,使用默认5分钟 resetAt := time.Now().Add(5 * time.Minute) if s.shouldScopeClaudeSonnetRateLimit(account, responseBody) { @@ -356,6 +395,7 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head } return } + slog.Warn("rate_limit_no_reset_time", "account_id", account.ID, "platform", account.Platform, "using_default", "5m") if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetAt); err != nil { slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err) } @@ -419,6 +459,108 @@ func (s *RateLimitService) shouldScopeClaudeSonnetRateLimit(account *Account, re return strings.Contains(msg, "sonnet") } +// calculateOpenAI429ResetTime 从 OpenAI 429 响应头计算正确的重置时间 +// 返回 nil 表示无法从响应头中确定重置时间 +func (s *RateLimitService) calculateOpenAI429ResetTime(headers http.Header) *time.Time { + snapshot := ParseCodexRateLimitHeaders(headers) + if snapshot == nil { + return nil + } + + normalized := snapshot.Normalize() + if normalized == nil { + return nil + } + + now := time.Now() + + // 判断哪个限制被触发(used_percent >= 100) + is7dExhausted := normalized.Used7dPercent != nil && *normalized.Used7dPercent >= 100 + is5hExhausted := normalized.Used5hPercent != nil && *normalized.Used5hPercent >= 100 + + // 优先使用被触发限制的重置时间 + if is7dExhausted && normalized.Reset7dSeconds != nil { + resetAt := now.Add(time.Duration(*normalized.Reset7dSeconds) * time.Second) + slog.Info("openai_429_7d_limit_exhausted", "reset_after_seconds", *normalized.Reset7dSeconds, "reset_at", resetAt) + return &resetAt + } + if is5hExhausted && normalized.Reset5hSeconds != nil { + resetAt := now.Add(time.Duration(*normalized.Reset5hSeconds) * time.Second) + slog.Info("openai_429_5h_limit_exhausted", "reset_after_seconds", *normalized.Reset5hSeconds, "reset_at", resetAt) + return &resetAt + } + + // 都未达到100%但收到429,使用较长的重置时间 + var maxResetSecs int + if normalized.Reset7dSeconds != nil && *normalized.Reset7dSeconds > maxResetSecs { + maxResetSecs = *normalized.Reset7dSeconds + } + if normalized.Reset5hSeconds != nil && *normalized.Reset5hSeconds > maxResetSecs { + maxResetSecs = *normalized.Reset5hSeconds + } + if maxResetSecs > 0 { + resetAt := now.Add(time.Duration(maxResetSecs) * time.Second) + slog.Info("openai_429_using_max_reset", "max_reset_seconds", maxResetSecs, "reset_at", resetAt) + return &resetAt + } + + return nil +} + +// parseOpenAIRateLimitResetTime 解析 OpenAI 格式的 429 响应,返回重置时间的 Unix 时间戳 +// OpenAI 的 usage_limit_reached 错误格式: +// +// { +// "error": { +// "message": "The usage limit has been reached", +// "type": "usage_limit_reached", +// "resets_at": 1769404154, +// "resets_in_seconds": 133107 +// } +// } +func parseOpenAIRateLimitResetTime(body []byte) *int64 { + var parsed map[string]any + if err := json.Unmarshal(body, &parsed); err != nil { + return nil + } + + errObj, ok := parsed["error"].(map[string]any) + if !ok { + return nil + } + + // 检查是否为 usage_limit_reached 或 rate_limit_exceeded 类型 + errType, _ := errObj["type"].(string) + if errType != "usage_limit_reached" && errType != "rate_limit_exceeded" { + return nil + } + + // 优先使用 resets_at(Unix 时间戳) + if resetsAt, ok := errObj["resets_at"].(float64); ok { + ts := int64(resetsAt) + return &ts + } + if resetsAt, ok := errObj["resets_at"].(string); ok { + if ts, err := strconv.ParseInt(resetsAt, 10, 64); err == nil { + return &ts + } + } + + // 如果没有 resets_at,尝试使用 resets_in_seconds + if resetsInSeconds, ok := errObj["resets_in_seconds"].(float64); ok { + ts := time.Now().Unix() + int64(resetsInSeconds) + return &ts + } + if resetsInSeconds, ok := errObj["resets_in_seconds"].(string); ok { + if sec, err := strconv.ParseInt(resetsInSeconds, 10, 64); err == nil { + ts := time.Now().Unix() + sec + return &ts + } + } + + return nil +} + // handle529 处理529过载错误 // 根据配置设置过载冷却时间 func (s *RateLimitService) handle529(ctx context.Context, account *Account) { diff --git a/backend/internal/service/ratelimit_service_openai_test.go b/backend/internal/service/ratelimit_service_openai_test.go new file mode 100644 index 00000000..00902068 --- /dev/null +++ b/backend/internal/service/ratelimit_service_openai_test.go @@ -0,0 +1,364 @@ +package service + +import ( + "net/http" + "testing" + "time" +) + +func TestCalculateOpenAI429ResetTime_7dExhausted(t *testing.T) { + svc := &RateLimitService{} + + // Simulate headers when 7d limit is exhausted (100% used) + // Primary = 7d (10080 minutes), Secondary = 5h (300 minutes) + headers := http.Header{} + headers.Set("x-codex-primary-used-percent", "100") + headers.Set("x-codex-primary-reset-after-seconds", "384607") // ~4.5 days + headers.Set("x-codex-primary-window-minutes", "10080") // 7 days + headers.Set("x-codex-secondary-used-percent", "3") + headers.Set("x-codex-secondary-reset-after-seconds", "17369") // ~4.8 hours + headers.Set("x-codex-secondary-window-minutes", "300") // 5 hours + + before := time.Now() + resetAt := svc.calculateOpenAI429ResetTime(headers) + after := time.Now() + + if resetAt == nil { + t.Fatal("expected non-nil resetAt") + } + + // Should be approximately 384607 seconds from now + expectedDuration := 384607 * time.Second + minExpected := before.Add(expectedDuration) + maxExpected := after.Add(expectedDuration) + + if resetAt.Before(minExpected) || resetAt.After(maxExpected) { + t.Errorf("resetAt %v not in expected range [%v, %v]", resetAt, minExpected, maxExpected) + } +} + +func TestCalculateOpenAI429ResetTime_5hExhausted(t *testing.T) { + svc := &RateLimitService{} + + // Simulate headers when 5h limit is exhausted (100% used) + headers := http.Header{} + headers.Set("x-codex-primary-used-percent", "50") + headers.Set("x-codex-primary-reset-after-seconds", "500000") + headers.Set("x-codex-primary-window-minutes", "10080") // 7 days + headers.Set("x-codex-secondary-used-percent", "100") + headers.Set("x-codex-secondary-reset-after-seconds", "3600") // 1 hour + headers.Set("x-codex-secondary-window-minutes", "300") // 5 hours + + before := time.Now() + resetAt := svc.calculateOpenAI429ResetTime(headers) + after := time.Now() + + if resetAt == nil { + t.Fatal("expected non-nil resetAt") + } + + // Should be approximately 3600 seconds from now + expectedDuration := 3600 * time.Second + minExpected := before.Add(expectedDuration) + maxExpected := after.Add(expectedDuration) + + if resetAt.Before(minExpected) || resetAt.After(maxExpected) { + t.Errorf("resetAt %v not in expected range [%v, %v]", resetAt, minExpected, maxExpected) + } +} + +func TestCalculateOpenAI429ResetTime_NeitherExhausted_UsesMax(t *testing.T) { + svc := &RateLimitService{} + + // Neither limit at 100%, should use the longer reset time + headers := http.Header{} + headers.Set("x-codex-primary-used-percent", "80") + headers.Set("x-codex-primary-reset-after-seconds", "100000") + headers.Set("x-codex-primary-window-minutes", "10080") + headers.Set("x-codex-secondary-used-percent", "90") + headers.Set("x-codex-secondary-reset-after-seconds", "5000") + headers.Set("x-codex-secondary-window-minutes", "300") + + before := time.Now() + resetAt := svc.calculateOpenAI429ResetTime(headers) + after := time.Now() + + if resetAt == nil { + t.Fatal("expected non-nil resetAt") + } + + // Should use the max (100000 seconds from 7d window) + expectedDuration := 100000 * time.Second + minExpected := before.Add(expectedDuration) + maxExpected := after.Add(expectedDuration) + + if resetAt.Before(minExpected) || resetAt.After(maxExpected) { + t.Errorf("resetAt %v not in expected range [%v, %v]", resetAt, minExpected, maxExpected) + } +} + +func TestCalculateOpenAI429ResetTime_NoCodexHeaders(t *testing.T) { + svc := &RateLimitService{} + + // No codex headers at all + headers := http.Header{} + headers.Set("content-type", "application/json") + + resetAt := svc.calculateOpenAI429ResetTime(headers) + + if resetAt != nil { + t.Errorf("expected nil resetAt when no codex headers, got %v", resetAt) + } +} + +func TestCalculateOpenAI429ResetTime_ReversedWindowOrder(t *testing.T) { + svc := &RateLimitService{} + + // Test when OpenAI sends primary as 5h and secondary as 7d (reversed) + headers := http.Header{} + headers.Set("x-codex-primary-used-percent", "100") // This is 5h + headers.Set("x-codex-primary-reset-after-seconds", "3600") // 1 hour + headers.Set("x-codex-primary-window-minutes", "300") // 5 hours - smaller! + headers.Set("x-codex-secondary-used-percent", "50") + headers.Set("x-codex-secondary-reset-after-seconds", "500000") + headers.Set("x-codex-secondary-window-minutes", "10080") // 7 days - larger! + + before := time.Now() + resetAt := svc.calculateOpenAI429ResetTime(headers) + after := time.Now() + + if resetAt == nil { + t.Fatal("expected non-nil resetAt") + } + + // Should correctly identify that primary is 5h (smaller window) and use its reset time + expectedDuration := 3600 * time.Second + minExpected := before.Add(expectedDuration) + maxExpected := after.Add(expectedDuration) + + if resetAt.Before(minExpected) || resetAt.After(maxExpected) { + t.Errorf("resetAt %v not in expected range [%v, %v]", resetAt, minExpected, maxExpected) + } +} + +func TestNormalizedCodexLimits(t *testing.T) { + // Test the Normalize() method directly + pUsed := 100.0 + pReset := 384607 + pWindow := 10080 + sUsed := 3.0 + sReset := 17369 + sWindow := 300 + + snapshot := &OpenAICodexUsageSnapshot{ + PrimaryUsedPercent: &pUsed, + PrimaryResetAfterSeconds: &pReset, + PrimaryWindowMinutes: &pWindow, + SecondaryUsedPercent: &sUsed, + SecondaryResetAfterSeconds: &sReset, + SecondaryWindowMinutes: &sWindow, + } + + normalized := snapshot.Normalize() + if normalized == nil { + t.Fatal("expected non-nil normalized") + } + + // Primary has larger window (10080 > 300), so primary should be 7d + if normalized.Used7dPercent == nil || *normalized.Used7dPercent != 100.0 { + t.Errorf("expected Used7dPercent=100, got %v", normalized.Used7dPercent) + } + if normalized.Reset7dSeconds == nil || *normalized.Reset7dSeconds != 384607 { + t.Errorf("expected Reset7dSeconds=384607, got %v", normalized.Reset7dSeconds) + } + if normalized.Used5hPercent == nil || *normalized.Used5hPercent != 3.0 { + t.Errorf("expected Used5hPercent=3, got %v", normalized.Used5hPercent) + } + if normalized.Reset5hSeconds == nil || *normalized.Reset5hSeconds != 17369 { + t.Errorf("expected Reset5hSeconds=17369, got %v", normalized.Reset5hSeconds) + } +} + +func TestNormalizedCodexLimits_OnlyPrimaryData(t *testing.T) { + // Test when only primary has data, no window_minutes + pUsed := 80.0 + pReset := 50000 + + snapshot := &OpenAICodexUsageSnapshot{ + PrimaryUsedPercent: &pUsed, + PrimaryResetAfterSeconds: &pReset, + // No window_minutes, no secondary data + } + + normalized := snapshot.Normalize() + if normalized == nil { + t.Fatal("expected non-nil normalized") + } + + // Legacy assumption: primary=7d, secondary=5h + if normalized.Used7dPercent == nil || *normalized.Used7dPercent != 80.0 { + t.Errorf("expected Used7dPercent=80, got %v", normalized.Used7dPercent) + } + if normalized.Reset7dSeconds == nil || *normalized.Reset7dSeconds != 50000 { + t.Errorf("expected Reset7dSeconds=50000, got %v", normalized.Reset7dSeconds) + } + // Secondary (5h) should be nil + if normalized.Used5hPercent != nil { + t.Errorf("expected Used5hPercent=nil, got %v", *normalized.Used5hPercent) + } + if normalized.Reset5hSeconds != nil { + t.Errorf("expected Reset5hSeconds=nil, got %v", *normalized.Reset5hSeconds) + } +} + +func TestNormalizedCodexLimits_OnlySecondaryData(t *testing.T) { + // Test when only secondary has data, no window_minutes + sUsed := 60.0 + sReset := 3000 + + snapshot := &OpenAICodexUsageSnapshot{ + SecondaryUsedPercent: &sUsed, + SecondaryResetAfterSeconds: &sReset, + // No window_minutes, no primary data + } + + normalized := snapshot.Normalize() + if normalized == nil { + t.Fatal("expected non-nil normalized") + } + + // Legacy assumption: primary=7d, secondary=5h + // So secondary goes to 5h + if normalized.Used5hPercent == nil || *normalized.Used5hPercent != 60.0 { + t.Errorf("expected Used5hPercent=60, got %v", normalized.Used5hPercent) + } + if normalized.Reset5hSeconds == nil || *normalized.Reset5hSeconds != 3000 { + t.Errorf("expected Reset5hSeconds=3000, got %v", normalized.Reset5hSeconds) + } + // Primary (7d) should be nil + if normalized.Used7dPercent != nil { + t.Errorf("expected Used7dPercent=nil, got %v", *normalized.Used7dPercent) + } +} + +func TestNormalizedCodexLimits_BothDataNoWindowMinutes(t *testing.T) { + // Test when both have data but no window_minutes + pUsed := 100.0 + pReset := 400000 + sUsed := 50.0 + sReset := 10000 + + snapshot := &OpenAICodexUsageSnapshot{ + PrimaryUsedPercent: &pUsed, + PrimaryResetAfterSeconds: &pReset, + SecondaryUsedPercent: &sUsed, + SecondaryResetAfterSeconds: &sReset, + // No window_minutes + } + + normalized := snapshot.Normalize() + if normalized == nil { + t.Fatal("expected non-nil normalized") + } + + // Legacy assumption: primary=7d, secondary=5h + if normalized.Used7dPercent == nil || *normalized.Used7dPercent != 100.0 { + t.Errorf("expected Used7dPercent=100, got %v", normalized.Used7dPercent) + } + if normalized.Reset7dSeconds == nil || *normalized.Reset7dSeconds != 400000 { + t.Errorf("expected Reset7dSeconds=400000, got %v", normalized.Reset7dSeconds) + } + if normalized.Used5hPercent == nil || *normalized.Used5hPercent != 50.0 { + t.Errorf("expected Used5hPercent=50, got %v", normalized.Used5hPercent) + } + if normalized.Reset5hSeconds == nil || *normalized.Reset5hSeconds != 10000 { + t.Errorf("expected Reset5hSeconds=10000, got %v", normalized.Reset5hSeconds) + } +} + +func TestHandle429_AnthropicPlatformUnaffected(t *testing.T) { + // Verify that Anthropic platform accounts still use the original logic + // This test ensures we don't break existing Claude account rate limiting + + svc := &RateLimitService{} + + // Simulate Anthropic 429 headers + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-reset", "1737820800") // A future Unix timestamp + + // For Anthropic platform, calculateOpenAI429ResetTime should return nil + // because it only handles OpenAI platform + resetAt := svc.calculateOpenAI429ResetTime(headers) + + // Should return nil since there are no x-codex-* headers + if resetAt != nil { + t.Errorf("expected nil for Anthropic headers, got %v", resetAt) + } +} + +func TestCalculateOpenAI429ResetTime_UserProvidedScenario(t *testing.T) { + // This is the exact scenario from the user: + // codex_7d_used_percent: 100 + // codex_7d_reset_after_seconds: 384607 (约4.5天后重置) + // codex_5h_used_percent: 3 + // codex_5h_reset_after_seconds: 17369 (约4.8小时后重置) + + svc := &RateLimitService{} + + // Simulate headers matching user's data + // Note: We need to map the canonical 5h/7d back to primary/secondary + // Based on typical OpenAI behavior: primary=7d (larger window), secondary=5h (smaller window) + headers := http.Header{} + headers.Set("x-codex-primary-used-percent", "100") + headers.Set("x-codex-primary-reset-after-seconds", "384607") + headers.Set("x-codex-primary-window-minutes", "10080") // 7 days = 10080 minutes + headers.Set("x-codex-secondary-used-percent", "3") + headers.Set("x-codex-secondary-reset-after-seconds", "17369") + headers.Set("x-codex-secondary-window-minutes", "300") // 5 hours = 300 minutes + + before := time.Now() + resetAt := svc.calculateOpenAI429ResetTime(headers) + after := time.Now() + + if resetAt == nil { + t.Fatal("expected non-nil resetAt for user scenario") + } + + // Should use the 7d reset time (384607 seconds) since 7d limit is exhausted (100%) + expectedDuration := 384607 * time.Second + minExpected := before.Add(expectedDuration) + maxExpected := after.Add(expectedDuration) + + if resetAt.Before(minExpected) || resetAt.After(maxExpected) { + t.Errorf("resetAt %v not in expected range [%v, %v]", resetAt, minExpected, maxExpected) + } + + // Verify it's approximately 4.45 days (384607 seconds) + duration := resetAt.Sub(before) + actualDays := duration.Hours() / 24.0 + + // 384607 / 86400 = ~4.45 days + if actualDays < 4.4 || actualDays > 4.5 { + t.Errorf("expected ~4.45 days, got %.2f days", actualDays) + } + + t.Logf("User scenario: reset_at=%v, duration=%.2f days", resetAt, actualDays) +} + +func TestCalculateOpenAI429ResetTime_5MinFallbackWhenNoReset(t *testing.T) { + // Test that we return nil when there's used_percent but no reset_after_seconds + // This should cause the caller to use the default 5-minute fallback + + svc := &RateLimitService{} + + headers := http.Header{} + headers.Set("x-codex-primary-used-percent", "100") + // No reset_after_seconds! + + resetAt := svc.calculateOpenAI429ResetTime(headers) + + // Should return nil since there's no reset time available + if resetAt != nil { + t.Errorf("expected nil when no reset_after_seconds, got %v", resetAt) + } +} diff --git a/backend/internal/service/redeem_service.go b/backend/internal/service/redeem_service.go index ff52dc47..ad277ca0 100644 --- a/backend/internal/service/redeem_service.go +++ b/backend/internal/service/redeem_service.go @@ -49,6 +49,11 @@ type RedeemCodeRepository interface { List(ctx context.Context, params pagination.PaginationParams) ([]RedeemCode, *pagination.PaginationResult, error) ListWithFilters(ctx context.Context, params pagination.PaginationParams, codeType, status, search string) ([]RedeemCode, *pagination.PaginationResult, error) ListByUser(ctx context.Context, userID int64, limit int) ([]RedeemCode, error) + // ListByUserPaginated returns paginated balance/concurrency history for a specific user. + // codeType filter is optional - pass empty string to return all types. + ListByUserPaginated(ctx context.Context, userID int64, params pagination.PaginationParams, codeType string) ([]RedeemCode, *pagination.PaginationResult, error) + // SumPositiveBalanceByUser returns the total recharged amount (sum of positive balance values) for a user. + SumPositiveBalanceByUser(ctx context.Context, userID int64) (float64, error) } // GenerateCodesRequest 生成兑换码请求 @@ -126,7 +131,8 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ return nil, errors.New("count must be greater than 0") } - if req.Value <= 0 { + // 邀请码类型不需要数值,其他类型需要 + if req.Type != RedeemTypeInvitation && req.Value <= 0 { return nil, errors.New("value must be greater than 0") } @@ -139,6 +145,12 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ codeType = RedeemTypeBalance } + // 邀请码类型的 value 设为 0 + value := req.Value + if codeType == RedeemTypeInvitation { + value = 0 + } + codes := make([]RedeemCode, 0, req.Count) for i := 0; i < req.Count; i++ { code, err := s.GenerateRandomCode() @@ -149,7 +161,7 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ codes = append(codes, RedeemCode{ Code: code, Type: codeType, - Value: req.Value, + Value: value, Status: StatusUnused, }) } diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index d77dd30d..f5ba9d71 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -61,6 +61,9 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyRegistrationEnabled, SettingKeyEmailVerifyEnabled, SettingKeyPromoCodeEnabled, + SettingKeyPasswordResetEnabled, + SettingKeyInvitationCodeEnabled, + SettingKeyTotpEnabled, SettingKeyTurnstileEnabled, SettingKeyTurnstileSiteKey, SettingKeySiteName, @@ -71,6 +74,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyDocURL, SettingKeyHomeContent, SettingKeyHideCcsImportButton, + SettingKeyPurchaseSubscriptionEnabled, + SettingKeyPurchaseSubscriptionURL, SettingKeyLinuxDoConnectEnabled, } @@ -86,21 +91,30 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings linuxDoEnabled = s.cfg != nil && s.cfg.LinuxDo.Enabled } + // Password reset requires email verification to be enabled + emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" + passwordResetEnabled := emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true" + return &PublicSettings{ - RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", - EmailVerifyEnabled: settings[SettingKeyEmailVerifyEnabled] == "true", - PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 - TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", - TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], - SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), - SiteLogo: settings[SettingKeySiteLogo], - SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), - APIBaseURL: settings[SettingKeyAPIBaseURL], - ContactInfo: settings[SettingKeyContactInfo], - DocURL: settings[SettingKeyDocURL], - HomeContent: settings[SettingKeyHomeContent], - HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", - LinuxDoOAuthEnabled: linuxDoEnabled, + RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", + EmailVerifyEnabled: emailVerifyEnabled, + PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 + PasswordResetEnabled: passwordResetEnabled, + InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", + TotpEnabled: settings[SettingKeyTotpEnabled] == "true", + TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", + TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], + SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), + SiteLogo: settings[SettingKeySiteLogo], + SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), + APIBaseURL: settings[SettingKeyAPIBaseURL], + ContactInfo: settings[SettingKeyContactInfo], + DocURL: settings[SettingKeyDocURL], + HomeContent: settings[SettingKeyHomeContent], + HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", + PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", + PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), + LinuxDoOAuthEnabled: linuxDoEnabled, }, nil } @@ -125,37 +139,47 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any // Return a struct that matches the frontend's expected format return &struct { - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - PromoCodeEnabled bool `json:"promo_code_enabled"` - TurnstileEnabled bool `json:"turnstile_enabled"` - TurnstileSiteKey string `json:"turnstile_site_key,omitempty"` - SiteName string `json:"site_name"` - SiteLogo string `json:"site_logo,omitempty"` - SiteSubtitle string `json:"site_subtitle,omitempty"` - APIBaseURL string `json:"api_base_url,omitempty"` - ContactInfo string `json:"contact_info,omitempty"` - DocURL string `json:"doc_url,omitempty"` - HomeContent string `json:"home_content,omitempty"` - HideCcsImportButton bool `json:"hide_ccs_import_button"` - LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` - Version string `json:"version,omitempty"` + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` + InvitationCodeEnabled bool `json:"invitation_code_enabled"` + TotpEnabled bool `json:"totp_enabled"` + TurnstileEnabled bool `json:"turnstile_enabled"` + TurnstileSiteKey string `json:"turnstile_site_key,omitempty"` + SiteName string `json:"site_name"` + SiteLogo string `json:"site_logo,omitempty"` + SiteSubtitle string `json:"site_subtitle,omitempty"` + APIBaseURL string `json:"api_base_url,omitempty"` + ContactInfo string `json:"contact_info,omitempty"` + DocURL string `json:"doc_url,omitempty"` + HomeContent string `json:"home_content,omitempty"` + HideCcsImportButton bool `json:"hide_ccs_import_button"` + PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` + PurchaseSubscriptionURL string `json:"purchase_subscription_url,omitempty"` + LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` + Version string `json:"version,omitempty"` }{ - RegistrationEnabled: settings.RegistrationEnabled, - EmailVerifyEnabled: settings.EmailVerifyEnabled, - PromoCodeEnabled: settings.PromoCodeEnabled, - TurnstileEnabled: settings.TurnstileEnabled, - TurnstileSiteKey: settings.TurnstileSiteKey, - SiteName: settings.SiteName, - SiteLogo: settings.SiteLogo, - SiteSubtitle: settings.SiteSubtitle, - APIBaseURL: settings.APIBaseURL, - ContactInfo: settings.ContactInfo, - DocURL: settings.DocURL, - HomeContent: settings.HomeContent, - HideCcsImportButton: settings.HideCcsImportButton, - LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, - Version: s.version, + RegistrationEnabled: settings.RegistrationEnabled, + EmailVerifyEnabled: settings.EmailVerifyEnabled, + PromoCodeEnabled: settings.PromoCodeEnabled, + PasswordResetEnabled: settings.PasswordResetEnabled, + InvitationCodeEnabled: settings.InvitationCodeEnabled, + TotpEnabled: settings.TotpEnabled, + TurnstileEnabled: settings.TurnstileEnabled, + TurnstileSiteKey: settings.TurnstileSiteKey, + SiteName: settings.SiteName, + SiteLogo: settings.SiteLogo, + SiteSubtitle: settings.SiteSubtitle, + APIBaseURL: settings.APIBaseURL, + ContactInfo: settings.ContactInfo, + DocURL: settings.DocURL, + HomeContent: settings.HomeContent, + HideCcsImportButton: settings.HideCcsImportButton, + PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, + PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, + LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, + Version: s.version, }, nil } @@ -167,6 +191,9 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet updates[SettingKeyRegistrationEnabled] = strconv.FormatBool(settings.RegistrationEnabled) updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled) updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled) + updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled) + updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled) + updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled) // 邮件服务设置(只有非空才更新密码) updates[SettingKeySMTPHost] = settings.SMTPHost @@ -203,6 +230,8 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet updates[SettingKeyDocURL] = settings.DocURL updates[SettingKeyHomeContent] = settings.HomeContent updates[SettingKeyHideCcsImportButton] = strconv.FormatBool(settings.HideCcsImportButton) + updates[SettingKeyPurchaseSubscriptionEnabled] = strconv.FormatBool(settings.PurchaseSubscriptionEnabled) + updates[SettingKeyPurchaseSubscriptionURL] = strings.TrimSpace(settings.PurchaseSubscriptionURL) // 默认配置 updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency) @@ -262,6 +291,44 @@ func (s *SettingService) IsPromoCodeEnabled(ctx context.Context) bool { return value != "false" } +// IsInvitationCodeEnabled 检查是否启用邀请码注册功能 +func (s *SettingService) IsInvitationCodeEnabled(ctx context.Context) bool { + value, err := s.settingRepo.GetValue(ctx, SettingKeyInvitationCodeEnabled) + if err != nil { + return false // 默认关闭 + } + return value == "true" +} + +// IsPasswordResetEnabled 检查是否启用密码重置功能 +// 要求:必须同时开启邮件验证 +func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool { + // Password reset requires email verification to be enabled + if !s.IsEmailVerifyEnabled(ctx) { + return false + } + value, err := s.settingRepo.GetValue(ctx, SettingKeyPasswordResetEnabled) + if err != nil { + return false // 默认关闭 + } + return value == "true" +} + +// IsTotpEnabled 检查是否启用 TOTP 双因素认证功能 +func (s *SettingService) IsTotpEnabled(ctx context.Context) bool { + value, err := s.settingRepo.GetValue(ctx, SettingKeyTotpEnabled) + if err != nil { + return false // 默认关闭 + } + return value == "true" +} + +// IsTotpEncryptionKeyConfigured 检查 TOTP 加密密钥是否已手动配置 +// 只有手动配置了密钥才允许在管理后台启用 TOTP 功能 +func (s *SettingService) IsTotpEncryptionKeyConfigured() bool { + return s.cfg.Totp.EncryptionKeyConfigured +} + // GetSiteName 获取网站名称 func (s *SettingService) GetSiteName(ctx context.Context) string { value, err := s.settingRepo.GetValue(ctx, SettingKeySiteName) @@ -309,15 +376,17 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { // 初始化默认设置 defaults := map[string]string{ - SettingKeyRegistrationEnabled: "true", - SettingKeyEmailVerifyEnabled: "false", - SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能 - SettingKeySiteName: "Sub2API", - SettingKeySiteLogo: "", - SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), - SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64), - SettingKeySMTPPort: "587", - SettingKeySMTPUseTLS: "false", + SettingKeyRegistrationEnabled: "true", + SettingKeyEmailVerifyEnabled: "false", + SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能 + SettingKeySiteName: "Sub2API", + SettingKeySiteLogo: "", + SettingKeyPurchaseSubscriptionEnabled: "false", + SettingKeyPurchaseSubscriptionURL: "", + SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), + SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64), + SettingKeySMTPPort: "587", + SettingKeySMTPUseTLS: "false", // Model fallback defaults SettingKeyEnableModelFallback: "false", SettingKeyFallbackModelAnthropic: "claude-3-5-sonnet-20241022", @@ -340,10 +409,14 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { // parseSettings 解析设置到结构体 func (s *SettingService) parseSettings(settings map[string]string) *SystemSettings { + emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" result := &SystemSettings{ RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", - EmailVerifyEnabled: settings[SettingKeyEmailVerifyEnabled] == "true", + EmailVerifyEnabled: emailVerifyEnabled, PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 + PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true", + InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", + TotpEnabled: settings[SettingKeyTotpEnabled] == "true", SMTPHost: settings[SettingKeySMTPHost], SMTPUsername: settings[SettingKeySMTPUsername], SMTPFrom: settings[SettingKeySMTPFrom], @@ -361,6 +434,8 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin DocURL: settings[SettingKeyDocURL], HomeContent: settings[SettingKeyHomeContent], HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", + PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", + PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), } // 解析整数类型 diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 919344e5..0c7bab67 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -1,9 +1,12 @@ package service type SystemSettings struct { - RegistrationEnabled bool - EmailVerifyEnabled bool - PromoCodeEnabled bool + RegistrationEnabled bool + EmailVerifyEnabled bool + PromoCodeEnabled bool + PasswordResetEnabled bool + InvitationCodeEnabled bool + TotpEnabled bool // TOTP 双因素认证 SMTPHost string SMTPPort int @@ -26,14 +29,16 @@ type SystemSettings struct { LinuxDoConnectClientSecretConfigured bool LinuxDoConnectRedirectURL string - SiteName string - SiteLogo string - SiteSubtitle string - APIBaseURL string - ContactInfo string - DocURL string - HomeContent string - HideCcsImportButton bool + SiteName string + SiteLogo string + SiteSubtitle string + APIBaseURL string + ContactInfo string + DocURL string + HomeContent string + HideCcsImportButton bool + PurchaseSubscriptionEnabled bool + PurchaseSubscriptionURL string DefaultConcurrency int DefaultBalance float64 @@ -57,19 +62,26 @@ type SystemSettings struct { } type PublicSettings struct { - RegistrationEnabled bool - EmailVerifyEnabled bool - PromoCodeEnabled bool - TurnstileEnabled bool - TurnstileSiteKey string - SiteName string - SiteLogo string - SiteSubtitle string - APIBaseURL string - ContactInfo string - DocURL string - HomeContent string - HideCcsImportButton bool + RegistrationEnabled bool + EmailVerifyEnabled bool + PromoCodeEnabled bool + PasswordResetEnabled bool + InvitationCodeEnabled bool + TotpEnabled bool // TOTP 双因素认证 + TurnstileEnabled bool + TurnstileSiteKey string + SiteName string + SiteLogo string + SiteSubtitle string + APIBaseURL string + ContactInfo string + DocURL string + HomeContent string + HideCcsImportButton bool + + PurchaseSubscriptionEnabled bool + PurchaseSubscriptionURL string + LinuxDoOAuthEnabled bool Version string } diff --git a/backend/internal/service/subscription_expiry_service.go b/backend/internal/service/subscription_expiry_service.go new file mode 100644 index 00000000..ce6b32b8 --- /dev/null +++ b/backend/internal/service/subscription_expiry_service.go @@ -0,0 +1,71 @@ +package service + +import ( + "context" + "log" + "sync" + "time" +) + +// SubscriptionExpiryService periodically updates expired subscription status. +type SubscriptionExpiryService struct { + userSubRepo UserSubscriptionRepository + interval time.Duration + stopCh chan struct{} + stopOnce sync.Once + wg sync.WaitGroup +} + +func NewSubscriptionExpiryService(userSubRepo UserSubscriptionRepository, interval time.Duration) *SubscriptionExpiryService { + return &SubscriptionExpiryService{ + userSubRepo: userSubRepo, + interval: interval, + stopCh: make(chan struct{}), + } +} + +func (s *SubscriptionExpiryService) Start() { + if s == nil || s.userSubRepo == nil || s.interval <= 0 { + return + } + s.wg.Add(1) + go func() { + defer s.wg.Done() + ticker := time.NewTicker(s.interval) + defer ticker.Stop() + + s.runOnce() + for { + select { + case <-ticker.C: + s.runOnce() + case <-s.stopCh: + return + } + } + }() +} + +func (s *SubscriptionExpiryService) Stop() { + if s == nil { + return + } + s.stopOnce.Do(func() { + close(s.stopCh) + }) + s.wg.Wait() +} + +func (s *SubscriptionExpiryService) runOnce() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + updated, err := s.userSubRepo.BatchUpdateExpiredStatus(ctx) + if err != nil { + log.Printf("[SubscriptionExpiry] Update expired subscriptions failed: %v", err) + return + } + if updated > 0 { + log.Printf("[SubscriptionExpiry] Updated %d expired subscriptions", updated) + } +} diff --git a/backend/internal/service/subscription_service.go b/backend/internal/service/subscription_service.go index c25c58a2..3c42852e 100644 --- a/backend/internal/service/subscription_service.go +++ b/backend/internal/service/subscription_service.go @@ -324,18 +324,31 @@ func (s *SubscriptionService) ExtendSubscription(ctx context.Context, subscripti days = -MaxValidityDays } + now := time.Now() + isExpired := !sub.ExpiresAt.After(now) + + // 如果订阅已过期,不允许负向调整 + if isExpired && days < 0 { + return nil, infraerrors.BadRequest("CANNOT_SHORTEN_EXPIRED", "cannot shorten an expired subscription") + } + // 计算新的过期时间 - newExpiresAt := sub.ExpiresAt.AddDate(0, 0, days) + var newExpiresAt time.Time + if isExpired { + // 已过期:从当前时间开始增加天数 + newExpiresAt = now.AddDate(0, 0, days) + } else { + // 未过期:从原过期时间增加/减少天数 + newExpiresAt = sub.ExpiresAt.AddDate(0, 0, days) + } + if newExpiresAt.After(MaxExpiresAt) { newExpiresAt = MaxExpiresAt } - // 如果是缩短(负数),检查新的过期时间必须大于当前时间 - if days < 0 { - now := time.Now() - if !newExpiresAt.After(now) { - return nil, ErrAdjustWouldExpire - } + // 检查新的过期时间必须大于当前时间 + if !newExpiresAt.After(now) { + return nil, ErrAdjustWouldExpire } if err := s.userSubRepo.ExtendExpiry(ctx, subscriptionID, newExpiresAt); err != nil { @@ -383,6 +396,7 @@ func (s *SubscriptionService) ListUserSubscriptions(ctx context.Context, userID return nil, err } normalizeExpiredWindows(subs) + normalizeSubscriptionStatus(subs) return subs, nil } @@ -404,17 +418,19 @@ func (s *SubscriptionService) ListGroupSubscriptions(ctx context.Context, groupI return nil, nil, err } normalizeExpiredWindows(subs) + normalizeSubscriptionStatus(subs) return subs, pag, nil } -// List 获取所有订阅(分页,支持筛选) -func (s *SubscriptionService) List(ctx context.Context, page, pageSize int, userID, groupID *int64, status string) ([]UserSubscription, *pagination.PaginationResult, error) { +// List 获取所有订阅(分页,支持筛选和排序) +func (s *SubscriptionService) List(ctx context.Context, page, pageSize int, userID, groupID *int64, status, sortBy, sortOrder string) ([]UserSubscription, *pagination.PaginationResult, error) { params := pagination.PaginationParams{Page: page, PageSize: pageSize} - subs, pag, err := s.userSubRepo.List(ctx, params, userID, groupID, status) + subs, pag, err := s.userSubRepo.List(ctx, params, userID, groupID, status, sortBy, sortOrder) if err != nil { return nil, nil, err } normalizeExpiredWindows(subs) + normalizeSubscriptionStatus(subs) return subs, pag, nil } @@ -441,6 +457,18 @@ func normalizeExpiredWindows(subs []UserSubscription) { } } +// normalizeSubscriptionStatus 根据实际过期时间修正状态(仅影响返回数据,不影响数据库) +// 这确保前端显示正确的状态,即使定时任务尚未更新数据库 +func normalizeSubscriptionStatus(subs []UserSubscription) { + now := time.Now() + for i := range subs { + sub := &subs[i] + if sub.Status == SubscriptionStatusActive && !sub.ExpiresAt.After(now) { + sub.Status = SubscriptionStatusExpired + } + } +} + // startOfDay 返回给定时间所在日期的零点(保持原时区) func startOfDay(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) @@ -659,11 +687,6 @@ func (s *SubscriptionService) GetUserSubscriptionsWithProgress(ctx context.Conte return progresses, nil } -// UpdateExpiredSubscriptions 更新过期订阅状态(定时任务调用) -func (s *SubscriptionService) UpdateExpiredSubscriptions(ctx context.Context) (int64, error) { - return s.userSubRepo.BatchUpdateExpiredStatus(ctx) -} - // ValidateSubscription 验证订阅是否有效 func (s *SubscriptionService) ValidateSubscription(ctx context.Context, sub *UserSubscription) error { if sub.Status == SubscriptionStatusExpired { diff --git a/backend/internal/service/token_refresh_service.go b/backend/internal/service/token_refresh_service.go index 7dccf393..ce7afc4a 100644 --- a/backend/internal/service/token_refresh_service.go +++ b/backend/internal/service/token_refresh_service.go @@ -18,6 +18,7 @@ type TokenRefreshService struct { refreshers []TokenRefresher cfg *config.TokenRefreshConfig cacheInvalidator TokenCacheInvalidator + schedulerCache SchedulerCache // 用于同步更新调度器缓存,解决 token 刷新后缓存不一致问题 stopCh chan struct{} wg sync.WaitGroup @@ -31,12 +32,14 @@ func NewTokenRefreshService( geminiOAuthService *GeminiOAuthService, antigravityOAuthService *AntigravityOAuthService, cacheInvalidator TokenCacheInvalidator, + schedulerCache SchedulerCache, cfg *config.Config, ) *TokenRefreshService { s := &TokenRefreshService{ accountRepo: accountRepo, cfg: &cfg.TokenRefresh, cacheInvalidator: cacheInvalidator, + schedulerCache: schedulerCache, stopCh: make(chan struct{}), } @@ -210,6 +213,15 @@ func (s *TokenRefreshService) refreshWithRetry(ctx context.Context, account *Acc log.Printf("[TokenRefresh] Token cache invalidated for account %d", account.ID) } } + // 同步更新调度器缓存,确保调度获取的 Account 对象包含最新的 credentials + // 这解决了 token 刷新后调度器缓存数据不一致的问题(#445) + if s.schedulerCache != nil { + if err := s.schedulerCache.SetAccount(ctx, account); err != nil { + log.Printf("[TokenRefresh] Failed to sync scheduler cache for account %d: %v", account.ID, err) + } else { + log.Printf("[TokenRefresh] Scheduler cache synced for account %d", account.ID) + } + } return nil } @@ -249,7 +261,8 @@ func (s *TokenRefreshService) refreshWithRetry(ctx context.Context, account *Acc } // isNonRetryableRefreshError 判断是否为不可重试的刷新错误 -// 这些错误通常表示凭证已失效,需要用户重新授权 +// 这些错误通常表示凭证已失效或配置确实缺失,需要用户重新授权 +// 注意:missing_project_id 错误只在真正缺失(从未获取过)时返回,临时获取失败不会返回此错误 func isNonRetryableRefreshError(err error) bool { if err == nil { return false diff --git a/backend/internal/service/token_refresh_service_test.go b/backend/internal/service/token_refresh_service_test.go index d23a0bb6..8e16c6f5 100644 --- a/backend/internal/service/token_refresh_service_test.go +++ b/backend/internal/service/token_refresh_service_test.go @@ -70,7 +70,7 @@ func TestTokenRefreshService_RefreshWithRetry_InvalidatesCache(t *testing.T) { RetryBackoffSeconds: 0, }, } - service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, cfg) + service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, nil, cfg) account := &Account{ ID: 5, Platform: PlatformGemini, @@ -98,7 +98,7 @@ func TestTokenRefreshService_RefreshWithRetry_InvalidatorErrorIgnored(t *testing RetryBackoffSeconds: 0, }, } - service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, cfg) + service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, nil, cfg) account := &Account{ ID: 6, Platform: PlatformGemini, @@ -124,7 +124,7 @@ func TestTokenRefreshService_RefreshWithRetry_NilInvalidator(t *testing.T) { RetryBackoffSeconds: 0, }, } - service := NewTokenRefreshService(repo, nil, nil, nil, nil, nil, cfg) + service := NewTokenRefreshService(repo, nil, nil, nil, nil, nil, nil, cfg) account := &Account{ ID: 7, Platform: PlatformGemini, @@ -151,7 +151,7 @@ func TestTokenRefreshService_RefreshWithRetry_Antigravity(t *testing.T) { RetryBackoffSeconds: 0, }, } - service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, cfg) + service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, nil, cfg) account := &Account{ ID: 8, Platform: PlatformAntigravity, @@ -179,7 +179,7 @@ func TestTokenRefreshService_RefreshWithRetry_NonOAuthAccount(t *testing.T) { RetryBackoffSeconds: 0, }, } - service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, cfg) + service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, nil, cfg) account := &Account{ ID: 9, Platform: PlatformGemini, @@ -207,7 +207,7 @@ func TestTokenRefreshService_RefreshWithRetry_OtherPlatformOAuth(t *testing.T) { RetryBackoffSeconds: 0, }, } - service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, cfg) + service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, nil, cfg) account := &Account{ ID: 10, Platform: PlatformOpenAI, // OpenAI OAuth 账户 @@ -235,7 +235,7 @@ func TestTokenRefreshService_RefreshWithRetry_UpdateFailed(t *testing.T) { RetryBackoffSeconds: 0, }, } - service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, cfg) + service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, nil, cfg) account := &Account{ ID: 11, Platform: PlatformGemini, @@ -264,7 +264,7 @@ func TestTokenRefreshService_RefreshWithRetry_RefreshFailed(t *testing.T) { RetryBackoffSeconds: 0, }, } - service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, cfg) + service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, nil, cfg) account := &Account{ ID: 12, Platform: PlatformGemini, @@ -291,7 +291,7 @@ func TestTokenRefreshService_RefreshWithRetry_AntigravityRefreshFailed(t *testin RetryBackoffSeconds: 0, }, } - service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, cfg) + service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, nil, cfg) account := &Account{ ID: 13, Platform: PlatformAntigravity, @@ -318,7 +318,7 @@ func TestTokenRefreshService_RefreshWithRetry_AntigravityNonRetryableError(t *te RetryBackoffSeconds: 0, }, } - service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, cfg) + service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, nil, cfg) account := &Account{ ID: 14, Platform: PlatformAntigravity, diff --git a/backend/internal/service/totp_service.go b/backend/internal/service/totp_service.go new file mode 100644 index 00000000..5192fe3d --- /dev/null +++ b/backend/internal/service/totp_service.go @@ -0,0 +1,506 @@ +package service + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "fmt" + "log/slog" + "time" + + "github.com/pquerna/otp/totp" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +var ( + ErrTotpNotEnabled = infraerrors.BadRequest("TOTP_NOT_ENABLED", "totp feature is not enabled") + ErrTotpAlreadyEnabled = infraerrors.BadRequest("TOTP_ALREADY_ENABLED", "totp is already enabled for this account") + ErrTotpNotSetup = infraerrors.BadRequest("TOTP_NOT_SETUP", "totp is not set up for this account") + ErrTotpInvalidCode = infraerrors.BadRequest("TOTP_INVALID_CODE", "invalid totp code") + ErrTotpSetupExpired = infraerrors.BadRequest("TOTP_SETUP_EXPIRED", "totp setup session expired") + ErrTotpTooManyAttempts = infraerrors.TooManyRequests("TOTP_TOO_MANY_ATTEMPTS", "too many verification attempts, please try again later") + ErrVerifyCodeRequired = infraerrors.BadRequest("VERIFY_CODE_REQUIRED", "email verification code is required") + ErrPasswordRequired = infraerrors.BadRequest("PASSWORD_REQUIRED", "password is required") +) + +// TotpCache defines cache operations for TOTP service +type TotpCache interface { + // Setup session methods + GetSetupSession(ctx context.Context, userID int64) (*TotpSetupSession, error) + SetSetupSession(ctx context.Context, userID int64, session *TotpSetupSession, ttl time.Duration) error + DeleteSetupSession(ctx context.Context, userID int64) error + + // Login session methods (for 2FA login flow) + GetLoginSession(ctx context.Context, tempToken string) (*TotpLoginSession, error) + SetLoginSession(ctx context.Context, tempToken string, session *TotpLoginSession, ttl time.Duration) error + DeleteLoginSession(ctx context.Context, tempToken string) error + + // Rate limiting + IncrementVerifyAttempts(ctx context.Context, userID int64) (int, error) + GetVerifyAttempts(ctx context.Context, userID int64) (int, error) + ClearVerifyAttempts(ctx context.Context, userID int64) error +} + +// SecretEncryptor defines encryption operations for TOTP secrets +type SecretEncryptor interface { + Encrypt(plaintext string) (string, error) + Decrypt(ciphertext string) (string, error) +} + +// TotpSetupSession represents a TOTP setup session +type TotpSetupSession struct { + Secret string // Plain text TOTP secret (not encrypted yet) + SetupToken string // Random token to verify setup request + CreatedAt time.Time +} + +// TotpLoginSession represents a pending 2FA login session +type TotpLoginSession struct { + UserID int64 + Email string + TokenExpiry time.Time +} + +// TotpStatus represents the TOTP status for a user +type TotpStatus struct { + Enabled bool `json:"enabled"` + EnabledAt *time.Time `json:"enabled_at,omitempty"` + FeatureEnabled bool `json:"feature_enabled"` +} + +// TotpSetupResponse represents the response for initiating TOTP setup +type TotpSetupResponse struct { + Secret string `json:"secret"` + QRCodeURL string `json:"qr_code_url"` + SetupToken string `json:"setup_token"` + Countdown int `json:"countdown"` // seconds until setup expires +} + +const ( + totpSetupTTL = 5 * time.Minute + totpLoginTTL = 5 * time.Minute + totpAttemptsTTL = 15 * time.Minute + maxTotpAttempts = 5 + totpIssuer = "Sub2API" +) + +// TotpService handles TOTP operations +type TotpService struct { + userRepo UserRepository + encryptor SecretEncryptor + cache TotpCache + settingService *SettingService + emailService *EmailService + emailQueueService *EmailQueueService +} + +// NewTotpService creates a new TOTP service +func NewTotpService( + userRepo UserRepository, + encryptor SecretEncryptor, + cache TotpCache, + settingService *SettingService, + emailService *EmailService, + emailQueueService *EmailQueueService, +) *TotpService { + return &TotpService{ + userRepo: userRepo, + encryptor: encryptor, + cache: cache, + settingService: settingService, + emailService: emailService, + emailQueueService: emailQueueService, + } +} + +// GetStatus returns the TOTP status for a user +func (s *TotpService) GetStatus(ctx context.Context, userID int64) (*TotpStatus, error) { + featureEnabled := s.settingService.IsTotpEnabled(ctx) + + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + + return &TotpStatus{ + Enabled: user.TotpEnabled, + EnabledAt: user.TotpEnabledAt, + FeatureEnabled: featureEnabled, + }, nil +} + +// InitiateSetup starts the TOTP setup process +// If email verification is enabled, emailCode is required; otherwise password is required +func (s *TotpService) InitiateSetup(ctx context.Context, userID int64, emailCode, password string) (*TotpSetupResponse, error) { + // Check if TOTP feature is enabled globally + if !s.settingService.IsTotpEnabled(ctx) { + return nil, ErrTotpNotEnabled + } + + // Get user and check if TOTP is already enabled + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + + if user.TotpEnabled { + return nil, ErrTotpAlreadyEnabled + } + + // Verify identity based on email verification setting + if s.settingService.IsEmailVerifyEnabled(ctx) { + // Email verification enabled - verify email code + if emailCode == "" { + return nil, ErrVerifyCodeRequired + } + if err := s.emailService.VerifyCode(ctx, user.Email, emailCode); err != nil { + return nil, err + } + } else { + // Email verification disabled - verify password + if password == "" { + return nil, ErrPasswordRequired + } + if !user.CheckPassword(password) { + return nil, ErrPasswordIncorrect + } + } + + // Generate a new TOTP key + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: totpIssuer, + AccountName: user.Email, + }) + if err != nil { + return nil, fmt.Errorf("generate totp key: %w", err) + } + + // Generate a random setup token + setupToken, err := generateRandomToken(32) + if err != nil { + return nil, fmt.Errorf("generate setup token: %w", err) + } + + // Store the setup session in cache + session := &TotpSetupSession{ + Secret: key.Secret(), + SetupToken: setupToken, + CreatedAt: time.Now(), + } + + if err := s.cache.SetSetupSession(ctx, userID, session, totpSetupTTL); err != nil { + return nil, fmt.Errorf("store setup session: %w", err) + } + + return &TotpSetupResponse{ + Secret: key.Secret(), + QRCodeURL: key.URL(), + SetupToken: setupToken, + Countdown: int(totpSetupTTL.Seconds()), + }, nil +} + +// CompleteSetup completes the TOTP setup by verifying the code +func (s *TotpService) CompleteSetup(ctx context.Context, userID int64, totpCode, setupToken string) error { + // Check if TOTP feature is enabled globally + if !s.settingService.IsTotpEnabled(ctx) { + return ErrTotpNotEnabled + } + + // Get the setup session + session, err := s.cache.GetSetupSession(ctx, userID) + if err != nil { + return ErrTotpSetupExpired + } + + if session == nil { + return ErrTotpSetupExpired + } + + // Verify the setup token (constant-time comparison) + if subtle.ConstantTimeCompare([]byte(session.SetupToken), []byte(setupToken)) != 1 { + return ErrTotpSetupExpired + } + + // Verify the TOTP code + if !totp.Validate(totpCode, session.Secret) { + return ErrTotpInvalidCode + } + + setupSecretPrefix := "N/A" + if len(session.Secret) >= 4 { + setupSecretPrefix = session.Secret[:4] + } + slog.Debug("totp_complete_setup_before_encrypt", + "user_id", userID, + "secret_len", len(session.Secret), + "secret_prefix", setupSecretPrefix) + + // Encrypt the secret + encryptedSecret, err := s.encryptor.Encrypt(session.Secret) + if err != nil { + return fmt.Errorf("encrypt totp secret: %w", err) + } + + slog.Debug("totp_complete_setup_encrypted", + "user_id", userID, + "encrypted_len", len(encryptedSecret)) + + // Verify encryption by decrypting + decrypted, decErr := s.encryptor.Decrypt(encryptedSecret) + if decErr != nil { + slog.Debug("totp_complete_setup_verify_failed", + "user_id", userID, + "error", decErr) + } else { + decryptedPrefix := "N/A" + if len(decrypted) >= 4 { + decryptedPrefix = decrypted[:4] + } + slog.Debug("totp_complete_setup_verified", + "user_id", userID, + "original_len", len(session.Secret), + "decrypted_len", len(decrypted), + "match", session.Secret == decrypted, + "decrypted_prefix", decryptedPrefix) + } + + // Update user with encrypted TOTP secret + if err := s.userRepo.UpdateTotpSecret(ctx, userID, &encryptedSecret); err != nil { + return fmt.Errorf("update totp secret: %w", err) + } + + // Enable TOTP for the user + if err := s.userRepo.EnableTotp(ctx, userID); err != nil { + return fmt.Errorf("enable totp: %w", err) + } + + // Clean up the setup session + _ = s.cache.DeleteSetupSession(ctx, userID) + + return nil +} + +// Disable disables TOTP for a user +// If email verification is enabled, emailCode is required; otherwise password is required +func (s *TotpService) Disable(ctx context.Context, userID int64, emailCode, password string) error { + // Get user + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + return fmt.Errorf("get user: %w", err) + } + + if !user.TotpEnabled { + return ErrTotpNotSetup + } + + // Verify identity based on email verification setting + if s.settingService.IsEmailVerifyEnabled(ctx) { + // Email verification enabled - verify email code + if emailCode == "" { + return ErrVerifyCodeRequired + } + if err := s.emailService.VerifyCode(ctx, user.Email, emailCode); err != nil { + return err + } + } else { + // Email verification disabled - verify password + if password == "" { + return ErrPasswordRequired + } + if !user.CheckPassword(password) { + return ErrPasswordIncorrect + } + } + + // Disable TOTP + if err := s.userRepo.DisableTotp(ctx, userID); err != nil { + return fmt.Errorf("disable totp: %w", err) + } + + return nil +} + +// VerifyCode verifies a TOTP code for a user +func (s *TotpService) VerifyCode(ctx context.Context, userID int64, code string) error { + slog.Debug("totp_verify_code_called", + "user_id", userID, + "code_len", len(code)) + + // Check rate limiting + attempts, err := s.cache.GetVerifyAttempts(ctx, userID) + if err == nil && attempts >= maxTotpAttempts { + return ErrTotpTooManyAttempts + } + + // Get user + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + slog.Debug("totp_verify_get_user_failed", + "user_id", userID, + "error", err) + return infraerrors.InternalServer("TOTP_VERIFY_ERROR", "failed to verify totp code") + } + + if !user.TotpEnabled || user.TotpSecretEncrypted == nil { + slog.Debug("totp_verify_not_setup", + "user_id", userID, + "enabled", user.TotpEnabled, + "has_secret", user.TotpSecretEncrypted != nil) + return ErrTotpNotSetup + } + + slog.Debug("totp_verify_encrypted_secret", + "user_id", userID, + "encrypted_len", len(*user.TotpSecretEncrypted)) + + // Decrypt the secret + secret, err := s.encryptor.Decrypt(*user.TotpSecretEncrypted) + if err != nil { + slog.Debug("totp_verify_decrypt_failed", + "user_id", userID, + "error", err) + return infraerrors.InternalServer("TOTP_VERIFY_ERROR", "failed to verify totp code") + } + + secretPrefix := "N/A" + if len(secret) >= 4 { + secretPrefix = secret[:4] + } + slog.Debug("totp_verify_decrypted", + "user_id", userID, + "secret_len", len(secret), + "secret_prefix", secretPrefix) + + // Verify the code + valid := totp.Validate(code, secret) + slog.Debug("totp_verify_result", + "user_id", userID, + "valid", valid, + "secret_len", len(secret), + "secret_prefix", secretPrefix, + "server_time", time.Now().UTC().Format(time.RFC3339)) + + if !valid { + // Increment failed attempts + _, _ = s.cache.IncrementVerifyAttempts(ctx, userID) + return ErrTotpInvalidCode + } + + // Clear attempt counter on success + _ = s.cache.ClearVerifyAttempts(ctx, userID) + + return nil +} + +// CreateLoginSession creates a temporary login session for 2FA +func (s *TotpService) CreateLoginSession(ctx context.Context, userID int64, email string) (string, error) { + // Generate a random temp token + tempToken, err := generateRandomToken(32) + if err != nil { + return "", fmt.Errorf("generate temp token: %w", err) + } + + session := &TotpLoginSession{ + UserID: userID, + Email: email, + TokenExpiry: time.Now().Add(totpLoginTTL), + } + + if err := s.cache.SetLoginSession(ctx, tempToken, session, totpLoginTTL); err != nil { + return "", fmt.Errorf("store login session: %w", err) + } + + return tempToken, nil +} + +// GetLoginSession retrieves a login session +func (s *TotpService) GetLoginSession(ctx context.Context, tempToken string) (*TotpLoginSession, error) { + return s.cache.GetLoginSession(ctx, tempToken) +} + +// DeleteLoginSession deletes a login session +func (s *TotpService) DeleteLoginSession(ctx context.Context, tempToken string) error { + return s.cache.DeleteLoginSession(ctx, tempToken) +} + +// IsTotpEnabledForUser checks if TOTP is enabled for a specific user +func (s *TotpService) IsTotpEnabledForUser(ctx context.Context, userID int64) (bool, error) { + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + return false, fmt.Errorf("get user: %w", err) + } + return user.TotpEnabled, nil +} + +// MaskEmail masks an email address for display +func MaskEmail(email string) string { + if len(email) < 3 { + return "***" + } + + atIdx := -1 + for i, c := range email { + if c == '@' { + atIdx = i + break + } + } + + if atIdx == -1 || atIdx < 1 { + return email[:1] + "***" + } + + localPart := email[:atIdx] + domain := email[atIdx:] + + if len(localPart) <= 2 { + return localPart[:1] + "***" + domain + } + + return localPart[:1] + "***" + localPart[len(localPart)-1:] + domain +} + +// generateRandomToken generates a random hex-encoded token +func generateRandomToken(byteLength int) (string, error) { + b := make([]byte, byteLength) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// VerificationMethod represents the method required for TOTP operations +type VerificationMethod struct { + Method string `json:"method"` // "email" or "password" +} + +// GetVerificationMethod returns the verification method for TOTP operations +func (s *TotpService) GetVerificationMethod(ctx context.Context) *VerificationMethod { + if s.settingService.IsEmailVerifyEnabled(ctx) { + return &VerificationMethod{Method: "email"} + } + return &VerificationMethod{Method: "password"} +} + +// SendVerifyCode sends an email verification code for TOTP operations +func (s *TotpService) SendVerifyCode(ctx context.Context, userID int64) error { + // Check if email verification is enabled + if !s.settingService.IsEmailVerifyEnabled(ctx) { + return infraerrors.BadRequest("EMAIL_VERIFY_NOT_ENABLED", "email verification is not enabled") + } + + // Get user email + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + return fmt.Errorf("get user: %w", err) + } + + // Get site name for email + siteName := s.settingService.GetSiteName(ctx) + + // Send verification code via queue + return s.emailQueueService.EnqueueVerifyCode(user.Email, siteName) +} diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go index 4be35501..b8808e13 100644 --- a/backend/internal/service/usage_log.go +++ b/backend/internal/service/usage_log.go @@ -14,6 +14,9 @@ type UsageLog struct { AccountID int64 RequestID string Model string + // ReasoningEffort is the request's reasoning effort level (OpenAI Responses API), + // e.g. "low" / "medium" / "high" / "xhigh". Nil means not provided / not applicable. + ReasoningEffort *string GroupID *int64 SubscriptionID *int64 diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go index c565607e..0f589eb3 100644 --- a/backend/internal/service/user.go +++ b/backend/internal/service/user.go @@ -21,6 +21,11 @@ type User struct { CreatedAt time.Time UpdatedAt time.Time + // TOTP 双因素认证字段 + TotpSecretEncrypted *string // AES-256-GCM 加密的 TOTP 密钥 + TotpEnabled bool // 是否启用 TOTP + TotpEnabledAt *time.Time // TOTP 启用时间 + APIKeys []APIKey Subscriptions []UserSubscription } diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 1734914a..99bf7fd0 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -38,6 +38,11 @@ type UserRepository interface { UpdateConcurrency(ctx context.Context, id int64, amount int) error ExistsByEmail(ctx context.Context, email string) (bool, error) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) + + // TOTP 相关方法 + UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error + EnableTotp(ctx context.Context, userID int64) error + DisableTotp(ctx context.Context, userID int64) error } // UpdateProfileRequest 更新用户资料请求 diff --git a/backend/internal/service/user_subscription_port.go b/backend/internal/service/user_subscription_port.go index abf4dffd..2dfc8d02 100644 --- a/backend/internal/service/user_subscription_port.go +++ b/backend/internal/service/user_subscription_port.go @@ -18,7 +18,7 @@ type UserSubscriptionRepository interface { ListByUserID(ctx context.Context, userID int64) ([]UserSubscription, error) ListActiveByUserID(ctx context.Context, userID int64) ([]UserSubscription, error) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]UserSubscription, *pagination.PaginationResult, error) - List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status string) ([]UserSubscription, *pagination.PaginationResult, error) + List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, sortBy, sortOrder string) ([]UserSubscription, *pagination.PaginationResult, error) ExistsByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) ExtendExpiry(ctx context.Context, subscriptionID int64, newExpiresAt time.Time) error diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 9c13be93..aed327ee 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -45,9 +45,10 @@ func ProvideTokenRefreshService( geminiOAuthService *GeminiOAuthService, antigravityOAuthService *AntigravityOAuthService, cacheInvalidator TokenCacheInvalidator, + schedulerCache SchedulerCache, cfg *config.Config, ) *TokenRefreshService { - svc := NewTokenRefreshService(accountRepo, oauthService, openaiOAuthService, geminiOAuthService, antigravityOAuthService, cacheInvalidator, cfg) + svc := NewTokenRefreshService(accountRepo, oauthService, openaiOAuthService, geminiOAuthService, antigravityOAuthService, cacheInvalidator, schedulerCache, cfg) // 注入 Sora 账号扩展表仓储,用于 OpenAI Token 刷新时同步 sora_accounts 表 svc.SetSoraAccountRepo(soraAccountRepo) svc.Start() @@ -75,6 +76,13 @@ func ProvideAccountExpiryService(accountRepo AccountRepository) *AccountExpirySe return svc } +// ProvideSubscriptionExpiryService creates and starts SubscriptionExpiryService. +func ProvideSubscriptionExpiryService(userSubRepo UserSubscriptionRepository) *SubscriptionExpiryService { + svc := NewSubscriptionExpiryService(userSubRepo, time.Minute) + svc.Start() + return svc +} + // ProvideTimingWheelService creates and starts TimingWheelService func ProvideTimingWheelService() (*TimingWheelService, error) { svc, err := NewTimingWheelService() @@ -234,6 +242,7 @@ var ProviderSet = wire.NewSet( ProvidePricingService, NewBillingService, NewBillingCacheService, + NewAnnouncementService, NewAdminService, NewGatewayService, ProvideSoraMediaStorage, @@ -276,6 +285,7 @@ var ProviderSet = wire.NewSet( ProvideUpdateService, ProvideTokenRefreshService, ProvideAccountExpiryService, + ProvideSubscriptionExpiryService, ProvideTimingWheelService, ProvideDashboardAggregationService, ProvideUsageCleanupService, @@ -283,4 +293,5 @@ var ProviderSet = wire.NewSet( NewAntigravityQuotaFetcher, NewUserAttributeService, NewUsageCache, + NewTotpService, ) diff --git a/backend/internal/setup/cli.go b/backend/internal/setup/cli.go index 03ac3f66..2b323acf 100644 --- a/backend/internal/setup/cli.go +++ b/backend/internal/setup/cli.go @@ -149,6 +149,8 @@ func RunCLI() error { fmt.Println(" Invalid Redis DB. Must be between 0 and 15.") } + cfg.Redis.EnableTLS = promptConfirm(reader, "Enable Redis TLS?") + fmt.Println() fmt.Print("Testing Redis connection... ") if err := TestRedisConnection(&cfg.Redis); err != nil { @@ -205,6 +207,7 @@ func RunCLI() error { fmt.Println("── Configuration Summary ──") fmt.Printf("Database: %s@%s:%d/%s\n", cfg.Database.User, cfg.Database.Host, cfg.Database.Port, cfg.Database.DBName) fmt.Printf("Redis: %s:%d\n", cfg.Redis.Host, cfg.Redis.Port) + fmt.Printf("Redis TLS: %s\n", map[bool]string{true: "enabled", false: "disabled"}[cfg.Redis.EnableTLS]) fmt.Printf("Admin: %s\n", cfg.Admin.Email) fmt.Printf("Server: :%d\n", cfg.Server.Port) fmt.Println() diff --git a/backend/internal/setup/handler.go b/backend/internal/setup/handler.go index 1c613dfd..1531c97b 100644 --- a/backend/internal/setup/handler.go +++ b/backend/internal/setup/handler.go @@ -176,10 +176,11 @@ func testDatabase(c *gin.Context) { // TestRedisRequest represents Redis test request type TestRedisRequest struct { - Host string `json:"host" binding:"required"` - Port int `json:"port" binding:"required"` - Password string `json:"password"` - DB int `json:"db"` + Host string `json:"host" binding:"required"` + Port int `json:"port" binding:"required"` + Password string `json:"password"` + DB int `json:"db"` + EnableTLS bool `json:"enable_tls"` } // testRedis tests Redis connection @@ -205,10 +206,11 @@ func testRedis(c *gin.Context) { } cfg := &RedisConfig{ - Host: req.Host, - Port: req.Port, - Password: req.Password, - DB: req.DB, + Host: req.Host, + Port: req.Port, + Password: req.Password, + DB: req.DB, + EnableTLS: req.EnableTLS, } if err := TestRedisConnection(cfg); err != nil { diff --git a/backend/internal/setup/setup.go b/backend/internal/setup/setup.go index 65118161..f81f75cf 100644 --- a/backend/internal/setup/setup.go +++ b/backend/internal/setup/setup.go @@ -3,6 +3,7 @@ package setup import ( "context" "crypto/rand" + "crypto/tls" "database/sql" "encoding/hex" "fmt" @@ -79,10 +80,11 @@ type DatabaseConfig struct { } type RedisConfig struct { - Host string `json:"host" yaml:"host"` - Port int `json:"port" yaml:"port"` - Password string `json:"password" yaml:"password"` - DB int `json:"db" yaml:"db"` + Host string `json:"host" yaml:"host"` + Port int `json:"port" yaml:"port"` + Password string `json:"password" yaml:"password"` + DB int `json:"db" yaml:"db"` + EnableTLS bool `json:"enable_tls" yaml:"enable_tls"` } type AdminConfig struct { @@ -199,11 +201,20 @@ func TestDatabaseConnection(cfg *DatabaseConfig) error { // TestRedisConnection tests the Redis connection func TestRedisConnection(cfg *RedisConfig) error { - rdb := redis.NewClient(&redis.Options{ + opts := &redis.Options{ Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port), Password: cfg.Password, DB: cfg.DB, - }) + } + + if cfg.EnableTLS { + opts.TLSConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + ServerName: cfg.Host, + } + } + + rdb := redis.NewClient(opts) defer func() { if err := rdb.Close(); err != nil { log.Printf("failed to close redis client: %v", err) @@ -485,10 +496,11 @@ func AutoSetupFromEnv() error { SSLMode: getEnvOrDefault("DATABASE_SSLMODE", "disable"), }, Redis: RedisConfig{ - Host: getEnvOrDefault("REDIS_HOST", "localhost"), - Port: getEnvIntOrDefault("REDIS_PORT", 6379), - Password: getEnvOrDefault("REDIS_PASSWORD", ""), - DB: getEnvIntOrDefault("REDIS_DB", 0), + Host: getEnvOrDefault("REDIS_HOST", "localhost"), + Port: getEnvIntOrDefault("REDIS_PORT", 6379), + Password: getEnvOrDefault("REDIS_PASSWORD", ""), + DB: getEnvIntOrDefault("REDIS_DB", 0), + EnableTLS: getEnvOrDefault("REDIS_ENABLE_TLS", "false") == "true", }, Admin: AdminConfig{ Email: getEnvOrDefault("ADMIN_EMAIL", "admin@sub2api.local"), diff --git a/backend/internal/util/urlvalidator/validator.go b/backend/internal/util/urlvalidator/validator.go index 56a888b9..49df015b 100644 --- a/backend/internal/util/urlvalidator/validator.go +++ b/backend/internal/util/urlvalidator/validator.go @@ -46,7 +46,7 @@ func ValidateURLFormat(raw string, allowInsecureHTTP bool) (string, error) { } } - return trimmed, nil + return strings.TrimRight(trimmed, "/"), nil } func ValidateHTTPSURL(raw string, opts ValidationOptions) (string, error) { diff --git a/backend/internal/util/urlvalidator/validator_test.go b/backend/internal/util/urlvalidator/validator_test.go index b7f9ffed..f9745da3 100644 --- a/backend/internal/util/urlvalidator/validator_test.go +++ b/backend/internal/util/urlvalidator/validator_test.go @@ -21,4 +21,31 @@ func TestValidateURLFormat(t *testing.T) { if _, err := ValidateURLFormat("https://example.com:bad", true); err == nil { t.Fatalf("expected invalid port to fail") } + + // 验证末尾斜杠被移除 + normalized, err := ValidateURLFormat("https://example.com/", false) + if err != nil { + t.Fatalf("expected trailing slash url to pass, got %v", err) + } + if normalized != "https://example.com" { + t.Fatalf("expected trailing slash to be removed, got %s", normalized) + } + + // 验证多个末尾斜杠被移除 + normalized, err = ValidateURLFormat("https://example.com///", false) + if err != nil { + t.Fatalf("expected multiple trailing slashes to pass, got %v", err) + } + if normalized != "https://example.com" { + t.Fatalf("expected all trailing slashes to be removed, got %s", normalized) + } + + // 验证带路径的 URL 末尾斜杠被移除 + normalized, err = ValidateURLFormat("https://example.com/api/v1/", false) + if err != nil { + t.Fatalf("expected trailing slash url with path to pass, got %v", err) + } + if normalized != "https://example.com/api/v1" { + t.Fatalf("expected trailing slash to be removed from path, got %s", normalized) + } } diff --git a/backend/migrations/044_add_user_totp.sql b/backend/migrations/044_add_user_totp.sql new file mode 100644 index 00000000..6e157a68 --- /dev/null +++ b/backend/migrations/044_add_user_totp.sql @@ -0,0 +1,12 @@ +-- 为 users 表添加 TOTP 双因素认证字段 +ALTER TABLE users + ADD COLUMN IF NOT EXISTS totp_secret_encrypted TEXT DEFAULT NULL, + ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS totp_enabled_at TIMESTAMPTZ DEFAULT NULL; + +COMMENT ON COLUMN users.totp_secret_encrypted IS 'AES-256-GCM 加密的 TOTP 密钥'; +COMMENT ON COLUMN users.totp_enabled IS '是否启用 TOTP 双因素认证'; +COMMENT ON COLUMN users.totp_enabled_at IS 'TOTP 启用时间'; + +-- 创建索引以支持快速查询启用 2FA 的用户 +CREATE INDEX IF NOT EXISTS idx_users_totp_enabled ON users(totp_enabled) WHERE deleted_at IS NULL AND totp_enabled = true; diff --git a/backend/migrations/045_add_announcements.sql b/backend/migrations/045_add_announcements.sql new file mode 100644 index 00000000..cfb9b4b5 --- /dev/null +++ b/backend/migrations/045_add_announcements.sql @@ -0,0 +1,44 @@ +-- 创建公告表 +CREATE TABLE IF NOT EXISTS announcements ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(200) NOT NULL, + content TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'draft', + targeting JSONB NOT NULL DEFAULT '{}'::jsonb, + starts_at TIMESTAMPTZ DEFAULT NULL, + ends_at TIMESTAMPTZ DEFAULT NULL, + created_by BIGINT DEFAULT NULL REFERENCES users(id) ON DELETE SET NULL, + updated_by BIGINT DEFAULT NULL REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 公告已读表 +CREATE TABLE IF NOT EXISTS announcement_reads ( + id BIGSERIAL PRIMARY KEY, + announcement_id BIGINT NOT NULL REFERENCES announcements(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + read_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(announcement_id, user_id) +); + +-- 索引 +CREATE INDEX IF NOT EXISTS idx_announcements_status ON announcements(status); +CREATE INDEX IF NOT EXISTS idx_announcements_starts_at ON announcements(starts_at); +CREATE INDEX IF NOT EXISTS idx_announcements_ends_at ON announcements(ends_at); +CREATE INDEX IF NOT EXISTS idx_announcements_created_at ON announcements(created_at); + +CREATE INDEX IF NOT EXISTS idx_announcement_reads_announcement_id ON announcement_reads(announcement_id); +CREATE INDEX IF NOT EXISTS idx_announcement_reads_user_id ON announcement_reads(user_id); +CREATE INDEX IF NOT EXISTS idx_announcement_reads_read_at ON announcement_reads(read_at); + +COMMENT ON TABLE announcements IS '系统公告'; +COMMENT ON COLUMN announcements.status IS '状态: draft, active, archived'; +COMMENT ON COLUMN announcements.targeting IS '展示条件(JSON 规则)'; +COMMENT ON COLUMN announcements.starts_at IS '开始展示时间(为空表示立即生效)'; +COMMENT ON COLUMN announcements.ends_at IS '结束展示时间(为空表示永久生效)'; + +COMMENT ON TABLE announcement_reads IS '公告已读记录'; +COMMENT ON COLUMN announcement_reads.read_at IS '用户首次已读时间'; + diff --git a/backend/migrations/046_add_usage_log_reasoning_effort.sql b/backend/migrations/046_add_usage_log_reasoning_effort.sql new file mode 100644 index 00000000..f6572d1d --- /dev/null +++ b/backend/migrations/046_add_usage_log_reasoning_effort.sql @@ -0,0 +1,4 @@ +-- Add reasoning_effort field to usage_logs for OpenAI/Codex requests. +-- This stores the request's reasoning effort level (e.g. low/medium/high/xhigh). +ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS reasoning_effort VARCHAR(20); + diff --git a/deploy/.env.example b/deploy/.env.example index f21a3c62..25096c3d 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -40,6 +40,7 @@ POSTGRES_DB=sub2api # Leave empty for no password (default for local development) REDIS_PASSWORD= REDIS_DB=0 +REDIS_ENABLE_TLS=false # ----------------------------------------------------------------------------- # Admin Account @@ -61,6 +62,18 @@ ADMIN_PASSWORD= JWT_SECRET= JWT_EXPIRE_HOUR=24 +# ----------------------------------------------------------------------------- +# TOTP (2FA) Configuration +# TOTP(双因素认证)配置 +# ----------------------------------------------------------------------------- +# IMPORTANT: Set a fixed encryption key for TOTP secrets. If left empty, a +# random key will be generated on each startup, causing all existing TOTP +# configurations to become invalid (users won't be able to login with 2FA). +# Generate a secure key: openssl rand -hex 32 +# 重要:设置固定的 TOTP 加密密钥。如果留空,每次启动将生成随机密钥, +# 导致现有的 TOTP 配置失效(用户无法使用双因素认证登录)。 +TOTP_ENCRYPTION_KEY= + # ----------------------------------------------------------------------------- # Configuration File (Optional) # ----------------------------------------------------------------------------- diff --git a/deploy/.gitignore b/deploy/.gitignore new file mode 100644 index 00000000..29a15135 --- /dev/null +++ b/deploy/.gitignore @@ -0,0 +1,19 @@ +# ============================================================================= +# Sub2API Deploy Directory - Git Ignore +# ============================================================================= + +# Data directories (generated at runtime when using docker-compose.local.yml) +data/ +postgres_data/ +redis_data/ + +# Environment configuration (contains sensitive information) +.env + +# Backup files +*.backup +*.bak + +# Temporary files +*.tmp +*.log diff --git a/deploy/README.md b/deploy/README.md index ed4ea721..091d8ad7 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -13,7 +13,9 @@ This directory contains files for deploying Sub2API on Linux servers. | File | Description | |------|-------------| -| `docker-compose.yml` | Docker Compose configuration | +| `docker-compose.yml` | Docker Compose configuration (named volumes) | +| `docker-compose.local.yml` | Docker Compose configuration (local directories, easy migration) | +| `docker-deploy.sh` | **One-click Docker deployment script (recommended)** | | `.env.example` | Docker environment variables template | | `DOCKER.md` | Docker Hub documentation | | `install.sh` | One-click binary installation script | @@ -24,7 +26,45 @@ This directory contains files for deploying Sub2API on Linux servers. ## Docker Deployment (Recommended) -### Quick Start +### Method 1: One-Click Deployment (Recommended) + +Use the automated preparation script for the easiest setup: + +```bash +# Download and run the preparation script +curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/docker-deploy.sh | bash + +# Or download first, then run +curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/docker-deploy.sh -o docker-deploy.sh +chmod +x docker-deploy.sh +./docker-deploy.sh +``` + +**What the script does:** +- Downloads `docker-compose.local.yml` and `.env.example` +- Automatically generates secure secrets (JWT_SECRET, TOTP_ENCRYPTION_KEY, POSTGRES_PASSWORD) +- Creates `.env` file with generated secrets +- Creates necessary data directories (data/, postgres_data/, redis_data/) +- **Displays generated credentials** (POSTGRES_PASSWORD, JWT_SECRET, etc.) + +**After running the script:** +```bash +# Start services +docker-compose -f docker-compose.local.yml up -d + +# View logs +docker-compose -f docker-compose.local.yml logs -f sub2api + +# If admin password was auto-generated, find it in logs: +docker-compose -f docker-compose.local.yml logs sub2api | grep "admin password" + +# Access Web UI +# http://localhost:8080 +``` + +### Method 2: Manual Deployment + +If you prefer manual control: ```bash # Clone repository @@ -33,18 +73,36 @@ cd sub2api/deploy # Configure environment cp .env.example .env -nano .env # Set POSTGRES_PASSWORD (required) +nano .env # Set POSTGRES_PASSWORD and other required variables -# Start all services -docker-compose up -d +# Generate secure secrets (recommended) +JWT_SECRET=$(openssl rand -hex 32) +TOTP_ENCRYPTION_KEY=$(openssl rand -hex 32) +echo "JWT_SECRET=${JWT_SECRET}" >> .env +echo "TOTP_ENCRYPTION_KEY=${TOTP_ENCRYPTION_KEY}" >> .env + +# Create data directories +mkdir -p data postgres_data redis_data + +# Start all services using local directory version +docker-compose -f docker-compose.local.yml up -d # View logs (check for auto-generated admin password) -docker-compose logs -f sub2api +docker-compose -f docker-compose.local.yml logs -f sub2api # Access Web UI # http://localhost:8080 ``` +### Deployment Version Comparison + +| Version | Data Storage | Migration | Best For | +|---------|-------------|-----------|----------| +| **docker-compose.local.yml** | Local directories (./data, ./postgres_data, ./redis_data) | ✅ Easy (tar entire directory) | Production, need frequent backups/migration | +| **docker-compose.yml** | Named volumes (/var/lib/docker/volumes/) | ⚠️ Requires docker commands | Simple setup, don't need migration | + +**Recommendation:** Use `docker-compose.local.yml` (deployed by `docker-deploy.sh`) for easier data management and migration. + ### How Auto-Setup Works When using Docker Compose with `AUTO_SETUP=true`: @@ -89,6 +147,32 @@ SELECT ### Commands +For **local directory version** (docker-compose.local.yml): + +```bash +# Start services +docker-compose -f docker-compose.local.yml up -d + +# Stop services +docker-compose -f docker-compose.local.yml down + +# View logs +docker-compose -f docker-compose.local.yml logs -f sub2api + +# Restart Sub2API only +docker-compose -f docker-compose.local.yml restart sub2api + +# Update to latest version +docker-compose -f docker-compose.local.yml pull +docker-compose -f docker-compose.local.yml up -d + +# Remove all data (caution!) +docker-compose -f docker-compose.local.yml down +rm -rf data/ postgres_data/ redis_data/ +``` + +For **named volumes version** (docker-compose.yml): + ```bash # Start services docker-compose up -d @@ -115,10 +199,11 @@ docker-compose down -v | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `POSTGRES_PASSWORD` | **Yes** | - | PostgreSQL password | +| `JWT_SECRET` | **Recommended** | *(auto-generated)* | JWT secret (fixed for persistent sessions) | +| `TOTP_ENCRYPTION_KEY` | **Recommended** | *(auto-generated)* | TOTP encryption key (fixed for persistent 2FA) | | `SERVER_PORT` | No | `8080` | Server port | | `ADMIN_EMAIL` | No | `admin@sub2api.local` | Admin email | | `ADMIN_PASSWORD` | No | *(auto-generated)* | Admin password | -| `JWT_SECRET` | No | *(auto-generated)* | JWT secret | | `TZ` | No | `Asia/Shanghai` | Timezone | | `GEMINI_OAUTH_CLIENT_ID` | No | *(builtin)* | Google OAuth client ID (Gemini OAuth). Leave empty to use the built-in Gemini CLI client. | | `GEMINI_OAUTH_CLIENT_SECRET` | No | *(builtin)* | Google OAuth client secret (Gemini OAuth). Leave empty to use the built-in Gemini CLI client. | @@ -127,6 +212,30 @@ docker-compose down -v See `.env.example` for all available options. +> **Note:** The `docker-deploy.sh` script automatically generates `JWT_SECRET`, `TOTP_ENCRYPTION_KEY`, and `POSTGRES_PASSWORD` for you. + +### Easy Migration (Local Directory Version) + +When using `docker-compose.local.yml`, all data is stored in local directories, making migration simple: + +```bash +# On source server: Stop services and create archive +cd /path/to/deployment +docker-compose -f docker-compose.local.yml down +cd .. +tar czf sub2api-complete.tar.gz deployment/ + +# Transfer to new server +scp sub2api-complete.tar.gz user@new-server:/path/to/destination/ + +# On new server: Extract and start +tar xzf sub2api-complete.tar.gz +cd deployment/ +docker-compose -f docker-compose.local.yml up -d +``` + +Your entire deployment (configuration + data) is migrated! + --- ## Gemini OAuth Configuration @@ -359,6 +468,30 @@ The main config file is at `/etc/sub2api/config.yaml` (created by Setup Wizard). ### Docker +For **local directory version**: + +```bash +# Check container status +docker-compose -f docker-compose.local.yml ps + +# View detailed logs +docker-compose -f docker-compose.local.yml logs --tail=100 sub2api + +# Check database connection +docker-compose -f docker-compose.local.yml exec postgres pg_isready + +# Check Redis connection +docker-compose -f docker-compose.local.yml exec redis redis-cli ping + +# Restart all services +docker-compose -f docker-compose.local.yml restart + +# Check data directories +ls -la data/ postgres_data/ redis_data/ +``` + +For **named volumes version**: + ```bash # Check container status docker-compose ps diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 9ef3ee5c..5b90b6c1 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -472,6 +472,9 @@ redis: # Database number (0-15) # 数据库编号(0-15) db: 0 + # Enable TLS/SSL connection + # 是否启用 TLS/SSL 连接 + enable_tls: false # ============================================================================= # Ops Monitoring (Optional) @@ -499,6 +502,21 @@ jwt: # 令牌过期时间(小时,最大 24) expire_hour: 24 +# ============================================================================= +# TOTP (2FA) Configuration +# TOTP 双因素认证配置 +# ============================================================================= +totp: + # IMPORTANT: Set a fixed encryption key for TOTP secrets. + # 重要:设置固定的 TOTP 加密密钥。 + # If left empty, a random key will be generated on each startup, causing all + # existing TOTP configurations to become invalid (users won't be able to + # login with 2FA). + # 如果留空,每次启动将生成随机密钥,导致现有的 TOTP 配置失效(用户无法使用 + # 双因素认证登录)。 + # Generate with / 生成命令: openssl rand -hex 32 + encryption_key: "" + # ============================================================================= # LinuxDo Connect OAuth Login (SSO) # LinuxDo Connect OAuth 登录(用于 Sub2API 用户登录) diff --git a/deploy/docker-compose.local.yml b/deploy/docker-compose.local.yml new file mode 100644 index 00000000..05ce129a --- /dev/null +++ b/deploy/docker-compose.local.yml @@ -0,0 +1,222 @@ +# ============================================================================= +# Sub2API Docker Compose - Local Directory Version +# ============================================================================= +# This configuration uses local directories for data storage instead of named +# volumes, making it easy to migrate the entire deployment by simply copying +# the deploy directory. +# +# Quick Start: +# 1. Copy .env.example to .env and configure +# 2. mkdir -p data postgres_data redis_data +# 3. docker-compose -f docker-compose.local.yml up -d +# 4. Check logs: docker-compose -f docker-compose.local.yml logs -f sub2api +# 5. Access: http://localhost:8080 +# +# Migration to New Server: +# 1. docker-compose -f docker-compose.local.yml down +# 2. tar czf sub2api-deploy.tar.gz deploy/ +# 3. Transfer to new server and extract +# 4. docker-compose -f docker-compose.local.yml up -d +# ============================================================================= + +services: + # =========================================================================== + # Sub2API Application + # =========================================================================== + sub2api: + image: weishaw/sub2api:latest + container_name: sub2api + restart: unless-stopped + ulimits: + nofile: + soft: 100000 + hard: 100000 + ports: + - "${BIND_HOST:-0.0.0.0}:${SERVER_PORT:-8080}:8080" + volumes: + # Local directory mapping for easy migration + - ./data:/app/data + # Optional: Mount custom config.yaml (uncomment and create the file first) + # Copy config.example.yaml to config.yaml, modify it, then uncomment: + # - ./config.yaml:/app/data/config.yaml:ro + environment: + # ======================================================================= + # Auto Setup (REQUIRED for Docker deployment) + # ======================================================================= + - AUTO_SETUP=true + + # ======================================================================= + # Server Configuration + # ======================================================================= + - SERVER_HOST=0.0.0.0 + - SERVER_PORT=8080 + - SERVER_MODE=${SERVER_MODE:-release} + - RUN_MODE=${RUN_MODE:-standard} + + # ======================================================================= + # Database Configuration (PostgreSQL) + # ======================================================================= + - DATABASE_HOST=postgres + - DATABASE_PORT=5432 + - DATABASE_USER=${POSTGRES_USER:-sub2api} + - DATABASE_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + - DATABASE_DBNAME=${POSTGRES_DB:-sub2api} + - DATABASE_SSLMODE=disable + + # ======================================================================= + # Redis Configuration + # ======================================================================= + - REDIS_HOST=redis + - REDIS_PORT=6379 + - REDIS_PASSWORD=${REDIS_PASSWORD:-} + - REDIS_DB=${REDIS_DB:-0} + - REDIS_ENABLE_TLS=${REDIS_ENABLE_TLS:-false} + + # ======================================================================= + # Admin Account (auto-created on first run) + # ======================================================================= + - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@sub2api.local} + - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} + + # ======================================================================= + # JWT Configuration + # ======================================================================= + # IMPORTANT: Set a fixed JWT_SECRET to prevent login sessions from being + # invalidated after container restarts. If left empty, a random secret + # will be generated on each startup. + # Generate a secure secret: openssl rand -hex 32 + - JWT_SECRET=${JWT_SECRET:-} + - JWT_EXPIRE_HOUR=${JWT_EXPIRE_HOUR:-24} + + # ======================================================================= + # TOTP (2FA) Configuration + # ======================================================================= + # IMPORTANT: Set a fixed encryption key for TOTP secrets. If left empty, + # a random key will be generated on each startup, causing all existing + # TOTP configurations to become invalid (users won't be able to login + # with 2FA). + # Generate a secure key: openssl rand -hex 32 + - TOTP_ENCRYPTION_KEY=${TOTP_ENCRYPTION_KEY:-} + + # ======================================================================= + # Timezone Configuration + # This affects ALL time operations in the application: + # - Database timestamps + # - Usage statistics "today" boundary + # - Subscription expiry times + # - Log timestamps + # Common values: Asia/Shanghai, America/New_York, Europe/London, UTC + # ======================================================================= + - TZ=${TZ:-Asia/Shanghai} + + # ======================================================================= + # Gemini OAuth Configuration (for Gemini accounts) + # ======================================================================= + - GEMINI_OAUTH_CLIENT_ID=${GEMINI_OAUTH_CLIENT_ID:-} + - GEMINI_OAUTH_CLIENT_SECRET=${GEMINI_OAUTH_CLIENT_SECRET:-} + - GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-} + - GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-} + + # ======================================================================= + # Security Configuration (URL Allowlist) + # ======================================================================= + # Enable URL allowlist validation (false to skip allowlist checks) + - SECURITY_URL_ALLOWLIST_ENABLED=${SECURITY_URL_ALLOWLIST_ENABLED:-false} + # Allow insecure HTTP URLs when allowlist is disabled (default: false, requires https) + - SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=${SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP:-false} + # Allow private IP addresses for upstream/pricing/CRS (for internal deployments) + - SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=${SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS:-false} + # Upstream hosts whitelist (comma-separated, only used when enabled=true) + - SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS=${SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS:-} + + # ======================================================================= + # Update Configuration (在线更新配置) + # ======================================================================= + # Proxy for accessing GitHub (online updates + pricing data) + # Examples: http://host:port, socks5://host:port + - UPDATE_PROXY_URL=${UPDATE_PROXY_URL:-} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - sub2api-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + # =========================================================================== + # PostgreSQL Database + # =========================================================================== + postgres: + image: postgres:18-alpine + container_name: sub2api-postgres + restart: unless-stopped + ulimits: + nofile: + soft: 100000 + hard: 100000 + volumes: + # Local directory mapping for easy migration + - ./postgres_data:/var/lib/postgresql/data + environment: + - POSTGRES_USER=${POSTGRES_USER:-sub2api} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + - POSTGRES_DB=${POSTGRES_DB:-sub2api} + - PGDATA=/var/lib/postgresql/data + - TZ=${TZ:-Asia/Shanghai} + networks: + - sub2api-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-sub2api} -d ${POSTGRES_DB:-sub2api}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + # 注意:不暴露端口到宿主机,应用通过内部网络连接 + # 如需调试,可临时添加:ports: ["127.0.0.1:5433:5432"] + + # =========================================================================== + # Redis Cache + # =========================================================================== + redis: + image: redis:8-alpine + container_name: sub2api-redis + restart: unless-stopped + ulimits: + nofile: + soft: 100000 + hard: 100000 + volumes: + # Local directory mapping for easy migration + - ./redis_data:/data + command: > + sh -c ' + redis-server + --save 60 1 + --appendonly yes + --appendfsync everysec + ${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}' + environment: + - TZ=${TZ:-Asia/Shanghai} + # REDISCLI_AUTH is used by redis-cli for authentication (safer than -a flag) + - REDISCLI_AUTH=${REDIS_PASSWORD:-} + networks: + - sub2api-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + +# ============================================================================= +# Networks +# ============================================================================= +networks: + sub2api-network: + driver: bridge diff --git a/deploy/docker-compose.standalone.yml b/deploy/docker-compose.standalone.yml index 1bf247c7..97903bc5 100644 --- a/deploy/docker-compose.standalone.yml +++ b/deploy/docker-compose.standalone.yml @@ -56,6 +56,7 @@ services: - REDIS_PORT=${REDIS_PORT:-6379} - REDIS_PASSWORD=${REDIS_PASSWORD:-} - REDIS_DB=${REDIS_DB:-0} + - REDIS_ENABLE_TLS=${REDIS_ENABLE_TLS:-false} # ======================================================================= # Admin Account (auto-created on first run) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 484df3a8..033731ac 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -62,6 +62,7 @@ services: - REDIS_PORT=6379 - REDIS_PASSWORD=${REDIS_PASSWORD:-} - REDIS_DB=${REDIS_DB:-0} + - REDIS_ENABLE_TLS=${REDIS_ENABLE_TLS:-false} # ======================================================================= # Admin Account (auto-created on first run) @@ -79,6 +80,16 @@ services: - JWT_SECRET=${JWT_SECRET:-} - JWT_EXPIRE_HOUR=${JWT_EXPIRE_HOUR:-24} + # ======================================================================= + # TOTP (2FA) Configuration + # ======================================================================= + # IMPORTANT: Set a fixed encryption key for TOTP secrets. If left empty, + # a random key will be generated on each startup, causing all existing + # TOTP configurations to become invalid (users won't be able to login + # with 2FA). + # Generate a secure key: openssl rand -hex 32 + - TOTP_ENCRYPTION_KEY=${TOTP_ENCRYPTION_KEY:-} + # ======================================================================= # Timezone Configuration # This affects ALL time operations in the application: diff --git a/deploy/docker-deploy.sh b/deploy/docker-deploy.sh new file mode 100644 index 00000000..1e4ce81f --- /dev/null +++ b/deploy/docker-deploy.sh @@ -0,0 +1,171 @@ +#!/bin/bash +# ============================================================================= +# Sub2API Docker Deployment Preparation Script +# ============================================================================= +# This script prepares deployment files for Sub2API: +# - Downloads docker-compose.local.yml and .env.example +# - Generates secure secrets (JWT_SECRET, TOTP_ENCRYPTION_KEY, POSTGRES_PASSWORD) +# - Creates necessary data directories +# +# After running this script, you can start services with: +# docker-compose -f docker-compose.local.yml up -d +# ============================================================================= + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# GitHub raw content base URL +GITHUB_RAW_URL="https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy" + +# Print colored message +print_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Generate random secret +generate_secret() { + openssl rand -hex 32 +} + +# Check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Main installation function +main() { + echo "" + echo "==========================================" + echo " Sub2API Deployment Preparation" + echo "==========================================" + echo "" + + # Check if openssl is available + if ! command_exists openssl; then + print_error "openssl is not installed. Please install openssl first." + exit 1 + fi + + # Check if deployment already exists + if [ -f "docker-compose.local.yml" ] && [ -f ".env" ]; then + print_warning "Deployment files already exist in current directory." + read -p "Overwrite existing files? (y/N): " -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + print_info "Cancelled." + exit 0 + fi + fi + + # Download docker-compose.local.yml + print_info "Downloading docker-compose.local.yml..." + if command_exists curl; then + curl -sSL "${GITHUB_RAW_URL}/docker-compose.local.yml" -o docker-compose.local.yml + elif command_exists wget; then + wget -q "${GITHUB_RAW_URL}/docker-compose.local.yml" -O docker-compose.local.yml + else + print_error "Neither curl nor wget is installed. Please install one of them." + exit 1 + fi + print_success "Downloaded docker-compose.local.yml" + + # Download .env.example + print_info "Downloading .env.example..." + if command_exists curl; then + curl -sSL "${GITHUB_RAW_URL}/.env.example" -o .env.example + else + wget -q "${GITHUB_RAW_URL}/.env.example" -O .env.example + fi + print_success "Downloaded .env.example" + + # Generate .env file with auto-generated secrets + print_info "Generating secure secrets..." + echo "" + + # Generate secrets + JWT_SECRET=$(generate_secret) + TOTP_ENCRYPTION_KEY=$(generate_secret) + POSTGRES_PASSWORD=$(generate_secret) + + # Create .env from .env.example + cp .env.example .env + + # Update .env with generated secrets (cross-platform compatible) + if sed --version >/dev/null 2>&1; then + # GNU sed (Linux) + sed -i "s/^JWT_SECRET=.*/JWT_SECRET=${JWT_SECRET}/" .env + sed -i "s/^TOTP_ENCRYPTION_KEY=.*/TOTP_ENCRYPTION_KEY=${TOTP_ENCRYPTION_KEY}/" .env + sed -i "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=${POSTGRES_PASSWORD}/" .env + else + # BSD sed (macOS) + sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=${JWT_SECRET}/" .env + sed -i '' "s/^TOTP_ENCRYPTION_KEY=.*/TOTP_ENCRYPTION_KEY=${TOTP_ENCRYPTION_KEY}/" .env + sed -i '' "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=${POSTGRES_PASSWORD}/" .env + fi + + # Create data directories + print_info "Creating data directories..." + mkdir -p data postgres_data redis_data + print_success "Created data directories" + + # Set secure permissions for .env file (readable/writable only by owner) + chmod 600 .env + echo "" + + # Display completion message + echo "==========================================" + echo " Preparation Complete!" + echo "==========================================" + echo "" + echo "Generated secure credentials:" + echo " POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}" + echo " JWT_SECRET: ${JWT_SECRET}" + echo " TOTP_ENCRYPTION_KEY: ${TOTP_ENCRYPTION_KEY}" + echo "" + print_warning "These credentials have been saved to .env file." + print_warning "Please keep them secure and do not share publicly!" + echo "" + echo "Directory structure:" + echo " docker-compose.local.yml - Docker Compose configuration" + echo " .env - Environment variables (generated secrets)" + echo " .env.example - Example template (for reference)" + echo " data/ - Application data (will be created on first run)" + echo " postgres_data/ - PostgreSQL data" + echo " redis_data/ - Redis data" + echo "" + echo "Next steps:" + echo " 1. (Optional) Edit .env to customize configuration" + echo " 2. Start services:" + echo " docker-compose -f docker-compose.local.yml up -d" + echo "" + echo " 3. View logs:" + echo " docker-compose -f docker-compose.local.yml logs -f sub2api" + echo "" + echo " 4. Access Web UI:" + echo " http://localhost:8080" + echo "" + print_info "If admin password is not set in .env, it will be auto-generated." + print_info "Check logs for the generated admin password on first startup." + echo "" +} + +# Run main function +main "$@" diff --git a/frontend/package-lock.json b/frontend/package-lock.json deleted file mode 100644 index e6c6144e..00000000 --- a/frontend/package-lock.json +++ /dev/null @@ -1,6954 +0,0 @@ -{ - "name": "sub2api-frontend", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "sub2api-frontend", - "version": "1.0.0", - "dependencies": { - "@lobehub/icons": "^4.0.2", - "@vueuse/core": "^10.7.0", - "axios": "^1.6.2", - "chart.js": "^4.4.1", - "driver.js": "^1.4.0", - "file-saver": "^2.0.5", - "pinia": "^2.1.7", - "vue": "^3.4.0", - "vue-chartjs": "^5.3.0", - "vue-i18n": "^9.14.5", - "vue-router": "^4.2.5", - "xlsx": "^0.18.5" - }, - "devDependencies": { - "@types/file-saver": "^2.0.7", - "@types/mdx": "^2.0.13", - "@types/node": "^20.10.5", - "@typescript-eslint/eslint-plugin": "^7.18.0", - "@typescript-eslint/parser": "^7.18.0", - "@vitejs/plugin-vue": "^5.2.3", - "@vitest/coverage-v8": "^2.1.9", - "@vue/test-utils": "^2.4.6", - "autoprefixer": "^10.4.16", - "eslint": "^8.57.0", - "eslint-plugin-vue": "^9.25.0", - "jsdom": "^24.1.3", - "postcss": "^8.4.32", - "tailwindcss": "^3.4.0", - "typescript": "~5.6.0", - "vite": "^5.0.10", - "vite-plugin-checker": "^0.9.1", - "vitest": "^2.1.9", - "vue-tsc": "^2.2.0" - } - }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@ant-design/cssinjs": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-2.0.2.tgz", - "integrity": "sha512-7KDVIigtqlamOLtJ0hbjECX/sDGDaJXsM/KHala8I/1E4lpl9RAO585kbVvh/k1rIrFAV6JeGkXmdWyYj9XvuA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.11.1", - "@emotion/hash": "^0.8.0", - "@emotion/unitless": "^0.7.5", - "@rc-component/util": "^1.4.0", - "clsx": "^2.1.1", - "csstype": "^3.1.3", - "stylis": "^4.3.4" - }, - "peerDependencies": { - "react": ">=16.0.0", - "react-dom": ">=16.0.0" - } - }, - "node_modules/@asamuzakjp/css-color": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", - "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "lru-cache": "^10.4.3" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.5" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@emotion/babel-plugin": { - "version": "11.13.5", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", - "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.16.7", - "@babel/runtime": "^7.18.3", - "@emotion/hash": "^0.9.2", - "@emotion/memoize": "^0.9.0", - "@emotion/serialize": "^1.3.3", - "babel-plugin-macros": "^3.1.0", - "convert-source-map": "^1.5.0", - "escape-string-regexp": "^4.0.0", - "find-root": "^1.1.0", - "source-map": "^0.5.7", - "stylis": "4.2.0" - } - }, - "node_modules/@emotion/babel-plugin/node_modules/@emotion/hash": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", - "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", - "license": "MIT" - }, - "node_modules/@emotion/babel-plugin/node_modules/stylis": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", - "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", - "license": "MIT" - }, - "node_modules/@emotion/cache": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", - "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", - "license": "MIT", - "dependencies": { - "@emotion/memoize": "^0.9.0", - "@emotion/sheet": "^1.4.0", - "@emotion/utils": "^1.4.2", - "@emotion/weak-memoize": "^0.4.0", - "stylis": "4.2.0" - } - }, - "node_modules/@emotion/cache/node_modules/stylis": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", - "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", - "license": "MIT" - }, - "node_modules/@emotion/css": { - "version": "11.13.5", - "resolved": "https://registry.npmjs.org/@emotion/css/-/css-11.13.5.tgz", - "integrity": "sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w==", - "license": "MIT", - "dependencies": { - "@emotion/babel-plugin": "^11.13.5", - "@emotion/cache": "^11.13.5", - "@emotion/serialize": "^1.3.3", - "@emotion/sheet": "^1.4.0", - "@emotion/utils": "^1.4.2" - } - }, - "node_modules/@emotion/hash": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", - "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", - "license": "MIT" - }, - "node_modules/@emotion/memoize": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", - "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", - "license": "MIT" - }, - "node_modules/@emotion/react": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", - "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.13.5", - "@emotion/cache": "^11.14.0", - "@emotion/serialize": "^1.3.3", - "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", - "@emotion/utils": "^1.4.2", - "@emotion/weak-memoize": "^0.4.0", - "hoist-non-react-statics": "^3.3.1" - }, - "peerDependencies": { - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@emotion/serialize": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", - "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", - "license": "MIT", - "dependencies": { - "@emotion/hash": "^0.9.2", - "@emotion/memoize": "^0.9.0", - "@emotion/unitless": "^0.10.0", - "@emotion/utils": "^1.4.2", - "csstype": "^3.0.2" - } - }, - "node_modules/@emotion/serialize/node_modules/@emotion/hash": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", - "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", - "license": "MIT" - }, - "node_modules/@emotion/serialize/node_modules/@emotion/unitless": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", - "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", - "license": "MIT" - }, - "node_modules/@emotion/sheet": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", - "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", - "license": "MIT" - }, - "node_modules/@emotion/unitless": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", - "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", - "license": "MIT" - }, - "node_modules/@emotion/use-insertion-effect-with-fallbacks": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", - "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", - "license": "MIT", - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@emotion/utils": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", - "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", - "license": "MIT" - }, - "node_modules/@emotion/weak-memoize": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", - "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", - "license": "MIT" - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/js": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", - "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", - "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", - "deprecated": "Use @eslint/config-array instead", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.3", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "deprecated": "Use @eslint/object-schema instead", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@intlify/core-base": { - "version": "9.14.5", - "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.5.tgz", - "integrity": "sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==", - "license": "MIT", - "dependencies": { - "@intlify/message-compiler": "9.14.5", - "@intlify/shared": "9.14.5" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/kazupon" - } - }, - "node_modules/@intlify/message-compiler": { - "version": "9.14.5", - "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.5.tgz", - "integrity": "sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==", - "license": "MIT", - "dependencies": { - "@intlify/shared": "9.14.5", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/kazupon" - } - }, - "node_modules/@intlify/shared": { - "version": "9.14.5", - "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.5.tgz", - "integrity": "sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/kazupon" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@kurkle/color": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", - "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", - "license": "MIT" - }, - "node_modules/@lobehub/icons": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@lobehub/icons/-/icons-4.0.2.tgz", - "integrity": "sha512-mYFEXXt7Z8iY8yLP5cDVctUPqlZUHWi5qzQCJiC646p7uiXhtpn93sRab/5pey+CYDh6BbRU6lhwiURu/SU5IA==", - "license": "MIT", - "workspaces": [ - "packages/*" - ], - "dependencies": { - "antd-style": "^4.1.0", - "lucide-react": "^0.469.0", - "polished": "^4.3.1" - }, - "peerDependencies": { - "@lobehub/ui": "^4.3.3", - "antd": "^6.1.1", - "react": "^19.0.0", - "react-dom": "^19.0.0" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@one-ini/wasm": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", - "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@rc-component/util": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.7.0.tgz", - "integrity": "sha512-tIvIGj4Vl6fsZFvWSkYw9sAfiCKUXMyhVz6kpKyZbwyZyRPqv2vxYZROdaO1VB4gqTNvUZFXh6i3APUiterw5g==", - "license": "MIT", - "dependencies": { - "is-mobile": "^5.0.0", - "react-is": "^18.2.0" - }, - "peerDependencies": { - "react": ">=18.0.0", - "react-dom": ">=18.0.0" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", - "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", - "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", - "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", - "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", - "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", - "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", - "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", - "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", - "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", - "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", - "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", - "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", - "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", - "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", - "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", - "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", - "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", - "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", - "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", - "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", - "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", - "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", - "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", - "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", - "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/file-saver": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", - "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/mdx": { - "version": "2.0.13", - "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", - "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "20.19.27", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", - "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/parse-json": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", - "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", - "license": "MIT" - }, - "node_modules/@types/web-bluetooth": { - "version": "0.0.20", - "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", - "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", - "license": "MIT" - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", - "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/type-utils": "7.18.0", - "@typescript-eslint/utils": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", - "natural-compare": "^1.4.0", - "ts-api-utils": "^1.3.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^7.0.0", - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", - "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/typescript-estree": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", - "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", - "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/typescript-estree": "7.18.0", - "@typescript-eslint/utils": "7.18.0", - "debug": "^4.3.4", - "ts-api-utils": "^1.3.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/types": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", - "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", - "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^1.3.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", - "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/typescript-estree": "7.18.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.56.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", - "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "7.18.0", - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, - "license": "ISC" - }, - "node_modules/@vitejs/plugin-vue": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", - "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0", - "vue": "^3.2.25" - } - }, - "node_modules/@vitest/coverage-v8": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", - "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@bcoe/v8-coverage": "^0.2.3", - "debug": "^4.3.7", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.12", - "magicast": "^0.3.5", - "std-env": "^3.8.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "2.1.9", - "vitest": "2.1.9" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/mocker/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@volar/language-core": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", - "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/source-map": "2.4.15" - } - }, - "node_modules/@volar/source-map": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", - "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@volar/typescript": { - "version": "2.4.15", - "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", - "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/language-core": "2.4.15", - "path-browserify": "^1.0.1", - "vscode-uri": "^3.0.8" - } - }, - "node_modules/@vue/compiler-core": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz", - "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@vue/shared": "3.5.26", - "entities": "^7.0.0", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-dom": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz", - "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==", - "license": "MIT", - "dependencies": { - "@vue/compiler-core": "3.5.26", - "@vue/shared": "3.5.26" - } - }, - "node_modules/@vue/compiler-sfc": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz", - "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@vue/compiler-core": "3.5.26", - "@vue/compiler-dom": "3.5.26", - "@vue/compiler-ssr": "3.5.26", - "@vue/shared": "3.5.26", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.21", - "postcss": "^8.5.6", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-ssr": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz", - "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==", - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.26", - "@vue/shared": "3.5.26" - } - }, - "node_modules/@vue/compiler-vue2": { - "version": "2.7.16", - "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", - "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", - "dev": true, - "license": "MIT", - "dependencies": { - "de-indent": "^1.0.2", - "he": "^1.2.0" - } - }, - "node_modules/@vue/devtools-api": { - "version": "6.6.4", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", - "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", - "license": "MIT" - }, - "node_modules/@vue/language-core": { - "version": "2.2.12", - "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", - "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/language-core": "2.4.15", - "@vue/compiler-dom": "^3.5.0", - "@vue/compiler-vue2": "^2.7.16", - "@vue/shared": "^3.5.0", - "alien-signals": "^1.0.3", - "minimatch": "^9.0.3", - "muggle-string": "^0.4.1", - "path-browserify": "^1.0.1" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@vue/reactivity": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz", - "integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==", - "license": "MIT", - "dependencies": { - "@vue/shared": "3.5.26" - } - }, - "node_modules/@vue/runtime-core": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz", - "integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.26", - "@vue/shared": "3.5.26" - } - }, - "node_modules/@vue/runtime-dom": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz", - "integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.26", - "@vue/runtime-core": "3.5.26", - "@vue/shared": "3.5.26", - "csstype": "^3.2.3" - } - }, - "node_modules/@vue/server-renderer": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz", - "integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==", - "license": "MIT", - "dependencies": { - "@vue/compiler-ssr": "3.5.26", - "@vue/shared": "3.5.26" - }, - "peerDependencies": { - "vue": "3.5.26" - } - }, - "node_modules/@vue/shared": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz", - "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", - "license": "MIT" - }, - "node_modules/@vue/test-utils": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", - "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-beautify": "^1.14.9", - "vue-component-type-helpers": "^2.0.0" - } - }, - "node_modules/@vueuse/core": { - "version": "10.11.1", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz", - "integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==", - "license": "MIT", - "dependencies": { - "@types/web-bluetooth": "^0.0.20", - "@vueuse/metadata": "10.11.1", - "@vueuse/shared": "10.11.1", - "vue-demi": ">=0.14.8" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@vueuse/metadata": { - "version": "10.11.1", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz", - "integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@vueuse/shared": { - "version": "10.11.1", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz", - "integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==", - "license": "MIT", - "dependencies": { - "vue-demi": ">=0.14.8" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/abbrev": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", - "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/adler-32": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", - "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/alien-signals": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", - "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/antd-style": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/antd-style/-/antd-style-4.1.0.tgz", - "integrity": "sha512-vnPBGg0OVlSz90KRYZhxd89aZiOImTiesF+9MQqN8jsLGZUQTjbP04X9jTdEfsztKUuMbBWg/RmB/wHTakbtMQ==", - "license": "MIT", - "dependencies": { - "@ant-design/cssinjs": "^2.0.0", - "@babel/runtime": "^7.24.1", - "@emotion/cache": "^11.11.0", - "@emotion/css": "^11.11.2", - "@emotion/react": "^11.11.4", - "@emotion/serialize": "^1.1.3", - "@emotion/utils": "^1.2.1", - "use-merge-value": "^1.2.0" - }, - "peerDependencies": { - "antd": ">=6.0.0", - "react": ">=18" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, - "license": "MIT" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/autoprefixer": { - "version": "10.4.23", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", - "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001760", - "fraction.js": "^5.3.4", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/babel-plugin-macros": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", - "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5", - "cosmiconfig": "^7.0.0", - "resolve": "^1.19.0" - }, - "engines": { - "node": ">=10", - "npm": ">=6" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.14", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", - "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true, - "license": "ISC" - }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001763", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz", - "integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/cfb": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", - "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", - "license": "Apache-2.0", - "dependencies": { - "adler-32": "~1.3.0", - "crc-32": "~1.2.0" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chart.js": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", - "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", - "license": "MIT", - "dependencies": { - "@kurkle/color": "^0.3.0" - }, - "engines": { - "pnpm": ">=8" - } - }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/codepage": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", - "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/config-chain": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", - "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ini": "^1.3.4", - "proto-list": "~1.2.1" - } - }, - "node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "license": "MIT" - }, - "node_modules/cosmiconfig": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", - "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", - "license": "MIT", - "dependencies": { - "@types/parse-json": "^4.0.0", - "import-fresh": "^3.2.1", - "parse-json": "^5.0.0", - "path-type": "^4.0.0", - "yaml": "^1.10.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/crc-32": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", - "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", - "license": "Apache-2.0", - "bin": { - "crc32": "bin/crc32.njs" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/cssstyle": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", - "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^3.2.0", - "rrweb-cssom": "^0.8.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/cssstyle/node_modules/rrweb-cssom": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "dev": true, - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" - }, - "node_modules/data-urls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/de-indent": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", - "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT" - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, - "license": "MIT" - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/driver.js": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.4.0.tgz", - "integrity": "sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==", - "license": "MIT" - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/editorconfig": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz", - "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@one-ini/wasm": "0.1.1", - "commander": "^10.0.0", - "minimatch": "9.0.1", - "semver": "^7.5.3" - }, - "bin": { - "editorconfig": "bin/editorconfig" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/editorconfig/node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/editorconfig/node_modules/minimatch": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz", - "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", - "dev": true, - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/entities": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz", - "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", - "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-plugin-vue": { - "version": "9.33.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.33.0.tgz", - "integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "globals": "^13.24.0", - "natural-compare": "^1.4.0", - "nth-check": "^2.1.1", - "postcss-selector-parser": "^6.0.15", - "semver": "^7.6.3", - "vue-eslint-parser": "^9.4.3", - "xml-name-validator": "^4.0.0" - }, - "engines": { - "node": "^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" - } - }, - "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "license": "MIT" - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/file-saver": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", - "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", - "license": "MIT" - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-root": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", - "license": "MIT" - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/frac": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", - "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, - "license": "MIT", - "bin": { - "he": "bin/he" - } - }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "license": "BSD-3-Clause", - "dependencies": { - "react-is": "^16.7.0" - } - }, - "node_modules/hoist-non-react-statics/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, - "node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-encoding": "^3.1.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, - "license": "ISC" - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "license": "MIT" - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-mobile": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz", - "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", - "license": "MIT" - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/js-beautify": { - "version": "1.15.4", - "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", - "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", - "dev": true, - "license": "MIT", - "dependencies": { - "config-chain": "^1.1.13", - "editorconfig": "^1.0.4", - "glob": "^10.4.2", - "js-cookie": "^3.0.5", - "nopt": "^7.2.1" - }, - "bin": { - "css-beautify": "js/bin/css-beautify.js", - "html-beautify": "js/bin/html-beautify.js", - "js-beautify": "js/bin/js-beautify.js" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/js-beautify/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/js-cookie": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", - "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsdom": { - "version": "24.1.3", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", - "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssstyle": "^4.0.1", - "data-urls": "^5.0.0", - "decimal.js": "^10.4.3", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.5", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.12", - "parse5": "^7.1.2", - "rrweb-cssom": "^0.7.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.4", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0", - "ws": "^8.18.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "canvas": "^2.11.2" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jsdom/node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/lucide-react": { - "version": "0.469.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz", - "integrity": "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/muggle-string": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", - "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nopt": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", - "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", - "dev": true, - "license": "ISC", - "dependencies": { - "abbrev": "^2.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/npm-run-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", - "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, - "node_modules/nwsapi": { - "version": "2.2.23", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", - "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/path-browserify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT" - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pinia": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", - "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", - "license": "MIT", - "dependencies": { - "@vue/devtools-api": "^6.6.3", - "vue-demi": "^0.14.10" - }, - "funding": { - "url": "https://github.com/sponsors/posva" - }, - "peerDependencies": { - "typescript": ">=4.4.4", - "vue": "^2.7.0 || ^3.5.11" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/polished": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", - "integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.17.8" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", - "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/proto-list": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", - "dev": true, - "license": "ISC" - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, - "node_modules/psl": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", - "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "funding": { - "url": "https://github.com/sponsors/lupomontero" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rollup": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", - "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.55.1", - "@rollup/rollup-android-arm64": "4.55.1", - "@rollup/rollup-darwin-arm64": "4.55.1", - "@rollup/rollup-darwin-x64": "4.55.1", - "@rollup/rollup-freebsd-arm64": "4.55.1", - "@rollup/rollup-freebsd-x64": "4.55.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", - "@rollup/rollup-linux-arm-musleabihf": "4.55.1", - "@rollup/rollup-linux-arm64-gnu": "4.55.1", - "@rollup/rollup-linux-arm64-musl": "4.55.1", - "@rollup/rollup-linux-loong64-gnu": "4.55.1", - "@rollup/rollup-linux-loong64-musl": "4.55.1", - "@rollup/rollup-linux-ppc64-gnu": "4.55.1", - "@rollup/rollup-linux-ppc64-musl": "4.55.1", - "@rollup/rollup-linux-riscv64-gnu": "4.55.1", - "@rollup/rollup-linux-riscv64-musl": "4.55.1", - "@rollup/rollup-linux-s390x-gnu": "4.55.1", - "@rollup/rollup-linux-x64-gnu": "4.55.1", - "@rollup/rollup-linux-x64-musl": "4.55.1", - "@rollup/rollup-openbsd-x64": "4.55.1", - "@rollup/rollup-openharmony-arm64": "4.55.1", - "@rollup/rollup-win32-arm64-msvc": "4.55.1", - "@rollup/rollup-win32-ia32-msvc": "4.55.1", - "@rollup/rollup-win32-x64-gnu": "4.55.1", - "@rollup/rollup-win32-x64-msvc": "4.55.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/rrweb-cssom": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", - "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", - "dev": true, - "license": "MIT" - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ssf": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", - "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", - "license": "Apache-2.0", - "dependencies": { - "frac": "~1.1.2" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/stylis": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", - "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", - "license": "MIT" - }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tailwindcss": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", - "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.7", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/test-exclude": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", - "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^9.0.4" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/test-exclude/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/ts-api-utils": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", - "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "typescript": ">=4.2.0" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typescript": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", - "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, - "node_modules/use-merge-value": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/use-merge-value/-/use-merge-value-1.2.0.tgz", - "integrity": "sha512-DXgG0kkgJN45TcyoXL49vJnn55LehnrmoHc7MbKi+QDBvr8dsesqws8UlyIWGHMR+JXgxc1nvY+jDGMlycsUcw==", - "license": "MIT", - "peerDependencies": { - "react": ">= 16.x" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite-plugin-checker": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.9.3.tgz", - "integrity": "sha512-Tf7QBjeBtG7q11zG0lvoF38/2AVUzzhMNu+Wk+mcsJ00Rk/FpJ4rmUviVJpzWkagbU13cGXvKpt7CMiqtxVTbQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "chokidar": "^4.0.3", - "npm-run-path": "^6.0.0", - "picocolors": "^1.1.1", - "picomatch": "^4.0.2", - "strip-ansi": "^7.1.0", - "tiny-invariant": "^1.3.3", - "tinyglobby": "^0.2.13", - "vscode-uri": "^3.1.0" - }, - "engines": { - "node": ">=14.16" - }, - "peerDependencies": { - "@biomejs/biome": ">=1.7", - "eslint": ">=7", - "meow": "^13.2.0", - "optionator": "^0.9.4", - "stylelint": ">=16", - "typescript": "*", - "vite": ">=2.0.0", - "vls": "*", - "vti": "*", - "vue-tsc": "~2.2.10" - }, - "peerDependenciesMeta": { - "@biomejs/biome": { - "optional": true - }, - "eslint": { - "optional": true - }, - "meow": { - "optional": true - }, - "optionator": { - "optional": true - }, - "stylelint": { - "optional": true - }, - "typescript": { - "optional": true - }, - "vls": { - "optional": true - }, - "vti": { - "optional": true - }, - "vue-tsc": { - "optional": true - } - } - }, - "node_modules/vite-plugin-checker/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/vite-plugin-checker/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/vite-plugin-checker/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vite-plugin-checker/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/vite-plugin-checker/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@vitest/pretty-format": "^2.1.9", - "@vitest/runner": "2.1.9", - "@vitest/snapshot": "2.1.9", - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.9", - "@vitest/ui": "2.1.9", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/vscode-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", - "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vue": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", - "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.26", - "@vue/compiler-sfc": "3.5.26", - "@vue/runtime-dom": "3.5.26", - "@vue/server-renderer": "3.5.26", - "@vue/shared": "3.5.26" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/vue-chartjs": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz", - "integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==", - "license": "MIT", - "peerDependencies": { - "chart.js": "^4.1.1", - "vue": "^3.0.0-0 || ^2.7.0" - } - }, - "node_modules/vue-component-type-helpers": { - "version": "2.2.12", - "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz", - "integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/vue-demi": { - "version": "0.14.10", - "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", - "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "vue-demi-fix": "bin/vue-demi-fix.js", - "vue-demi-switch": "bin/vue-demi-switch.js" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "@vue/composition-api": "^1.0.0-rc.1", - "vue": "^3.0.0-0 || ^2.6.0" - }, - "peerDependenciesMeta": { - "@vue/composition-api": { - "optional": true - } - } - }, - "node_modules/vue-eslint-parser": { - "version": "9.4.3", - "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", - "integrity": "sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4", - "eslint-scope": "^7.1.1", - "eslint-visitor-keys": "^3.3.0", - "espree": "^9.3.1", - "esquery": "^1.4.0", - "lodash": "^4.17.21", - "semver": "^7.3.6" - }, - "engines": { - "node": "^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - }, - "peerDependencies": { - "eslint": ">=6.0.0" - } - }, - "node_modules/vue-i18n": { - "version": "9.14.5", - "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.5.tgz", - "integrity": "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==", - "deprecated": "v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html", - "license": "MIT", - "dependencies": { - "@intlify/core-base": "9.14.5", - "@intlify/shared": "9.14.5", - "@vue/devtools-api": "^6.5.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/kazupon" - }, - "peerDependencies": { - "vue": "^3.0.0" - } - }, - "node_modules/vue-router": { - "version": "4.6.4", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", - "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", - "license": "MIT", - "dependencies": { - "@vue/devtools-api": "^6.6.4" - }, - "funding": { - "url": "https://github.com/sponsors/posva" - }, - "peerDependencies": { - "vue": "^3.5.0" - } - }, - "node_modules/vue-tsc": { - "version": "2.2.12", - "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", - "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/typescript": "2.4.15", - "@vue/language-core": "2.2.12" - }, - "bin": { - "vue-tsc": "bin/vue-tsc.js" - }, - "peerDependencies": { - "typescript": ">=5.0.0" - } - }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/w3c-xmlserializer/node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", - "dev": true, - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wmf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", - "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/word": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", - "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xlsx": { - "version": "0.18.5", - "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", - "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", - "license": "Apache-2.0", - "dependencies": { - "adler-32": "~1.3.0", - "cfb": "~1.2.1", - "codepage": "~1.15.0", - "crc-32": "~1.2.1", - "ssf": "~0.11.2", - "wmf": "~1.0.1", - "word": "~0.3.0" - }, - "bin": { - "xlsx": "bin/xlsx.njs" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT" - }, - "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/frontend/package.json b/frontend/package.json index c984cd96..38b92708 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,9 +19,12 @@ "@vueuse/core": "^10.7.0", "axios": "^1.6.2", "chart.js": "^4.4.1", + "dompurify": "^3.3.1", "driver.js": "^1.4.0", "file-saver": "^2.0.5", + "marked": "^17.0.1", "pinia": "^2.1.7", + "qrcode": "^1.5.4", "vue": "^3.4.0", "vue-chartjs": "^5.3.0", "vue-i18n": "^9.14.5", @@ -29,9 +32,11 @@ "xlsx": "^0.18.5" }, "devDependencies": { + "@types/dompurify": "^3.0.5", "@types/file-saver": "^2.0.7", "@types/mdx": "^2.0.13", "@types/node": "^20.10.5", + "@types/qrcode": "^1.5.6", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "@vitejs/plugin-vue": "^5.2.3", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 1a808176..7dc73325 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -20,15 +20,24 @@ importers: chart.js: specifier: ^4.4.1 version: 4.5.1 + dompurify: + specifier: ^3.3.1 + version: 3.3.1 driver.js: specifier: ^1.4.0 version: 1.4.0 file-saver: specifier: ^2.0.5 version: 2.0.5 + marked: + specifier: ^17.0.1 + version: 17.0.1 pinia: specifier: ^2.1.7 version: 2.3.1(typescript@5.6.3)(vue@3.5.26(typescript@5.6.3)) + qrcode: + specifier: ^1.5.4 + version: 1.5.4 vue: specifier: ^3.4.0 version: 3.5.26(typescript@5.6.3) @@ -45,6 +54,9 @@ importers: specifier: ^0.18.5 version: 0.18.5 devDependencies: + '@types/dompurify': + specifier: ^3.0.5 + version: 3.2.0 '@types/file-saver': specifier: ^2.0.7 version: 2.0.7 @@ -54,6 +66,9 @@ importers: '@types/node': specifier: ^20.10.5 version: 20.19.27 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@typescript-eslint/eslint-plugin': specifier: ^7.18.0 version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.3))(eslint@8.57.1)(typescript@5.6.3) @@ -1239,56 +1254,67 @@ packages: resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.54.0': resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.54.0': resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.54.0': resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.54.0': resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.54.0': resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.54.0': resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.54.0': resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.54.0': resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.54.0': resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.54.0': resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.54.0': resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} @@ -1443,6 +1469,10 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/dompurify@3.2.0': + resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==} + deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed. + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -1479,6 +1509,9 @@ packages: '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react@19.2.7': resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} @@ -1832,6 +1865,10 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001761: resolution: {integrity: sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==} @@ -1895,6 +1932,9 @@ packages: classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + clsx@1.2.1: resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} engines: {node: '>=6'} @@ -2164,6 +2204,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -2198,6 +2242,9 @@ packages: didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -2424,6 +2471,10 @@ packages: find-root@1.1.0: resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2488,6 +2539,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.4.0: resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} engines: {node: '>=18'} @@ -2856,6 +2911,10 @@ packages: lit@3.3.2: resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -3239,14 +3298,26 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -3341,6 +3412,10 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -3421,6 +3496,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + query-string@9.3.1: resolution: {integrity: sha512-5fBfMOcDi5SA9qj5jZhWAcTtDfKF5WFdd2uD9nVNlbxVv1baq65aALy6qofpNEGELHvisjjasxQp7BlM9gvMzw==} engines: {node: '>=18'} @@ -3664,6 +3744,13 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} @@ -3739,6 +3826,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-value@2.0.1: resolution: {integrity: sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==} engines: {node: '>=0.10.0'} @@ -4263,6 +4353,9 @@ packages: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -4285,6 +4378,10 @@ packages: resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} engines: {node: '>=0.8'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -4324,10 +4421,21 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + yaml@1.10.2: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -5806,6 +5914,10 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/dompurify@3.2.0': + dependencies: + dompurify: 3.3.1 + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -5838,6 +5950,10 @@ snapshots: '@types/parse-json@4.0.2': {} + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 20.19.27 + '@types/react@19.2.7': dependencies: csstype: 3.2.3 @@ -6321,6 +6437,8 @@ snapshots: camelcase-css@2.0.1: {} + camelcase@5.3.1: {} + caniuse-lite@1.0.30001761: {} ccount@2.0.1: {} @@ -6395,6 +6513,12 @@ snapshots: classnames@2.5.1: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + clsx@1.2.1: {} clsx@2.1.1: {} @@ -6668,6 +6792,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decimal.js@10.6.0: {} decode-named-character-reference@1.2.0: @@ -6694,6 +6820,8 @@ snapshots: didyoumean@1.2.2: {} + dijkstrajs@1.0.3: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -6978,6 +7106,11 @@ snapshots: find-root@1.1.0: {} + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -7029,6 +7162,8 @@ snapshots: function-bind@1.1.2: {} + get-caller-file@2.0.5: {} + get-east-asian-width@1.4.0: {} get-intrinsic@1.3.0: @@ -7521,6 +7656,10 @@ snapshots: lit-element: 4.2.2 lit-html: 3.3.2 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -8194,14 +8333,24 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} package-manager-detector@1.6.0: {} @@ -8284,6 +8433,8 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 + pngjs@5.0.0: {} + points-on-curve@0.2.0: {} points-on-path@0.2.1: @@ -8352,6 +8503,12 @@ snapshots: punycode@2.3.1: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + query-string@9.3.1: dependencies: decode-uri-component: 0.4.1 @@ -8703,6 +8860,10 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + require-directory@2.1.1: {} + + require-main-filename@2.0.0: {} + requires-port@1.0.0: {} reselect@5.1.1: {} @@ -8788,6 +8949,8 @@ snapshots: semver@7.7.3: {} + set-blocking@2.0.0: {} + set-value@2.0.1: dependencies: extend-shallow: 2.0.1 @@ -9298,6 +9461,8 @@ snapshots: tr46: 5.1.1 webidl-conversions: 7.0.0 + which-module@2.0.1: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -9313,6 +9478,12 @@ snapshots: word@0.3.0: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -9345,8 +9516,29 @@ snapshots: xmlchars@2.2.0: {} + y18n@4.0.3: {} + yaml@1.10.2: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yocto-queue@0.1.0: {} zustand@3.7.2(react@19.2.3): diff --git a/frontend/src/api/admin/announcements.ts b/frontend/src/api/admin/announcements.ts new file mode 100644 index 00000000..d02fdda7 --- /dev/null +++ b/frontend/src/api/admin/announcements.ts @@ -0,0 +1,71 @@ +/** + * Admin Announcements API endpoints + */ + +import { apiClient } from '../client' +import type { + Announcement, + AnnouncementUserReadStatus, + BasePaginationResponse, + CreateAnnouncementRequest, + UpdateAnnouncementRequest +} from '@/types' + +export async function list( + page: number = 1, + pageSize: number = 20, + filters?: { + status?: string + search?: string + } +): Promise> { + const { data } = await apiClient.get>('/admin/announcements', { + params: { page, page_size: pageSize, ...filters } + }) + return data +} + +export async function getById(id: number): Promise { + const { data } = await apiClient.get(`/admin/announcements/${id}`) + return data +} + +export async function create(request: CreateAnnouncementRequest): Promise { + const { data } = await apiClient.post('/admin/announcements', request) + return data +} + +export async function update(id: number, request: UpdateAnnouncementRequest): Promise { + const { data } = await apiClient.put(`/admin/announcements/${id}`, request) + return data +} + +export async function deleteAnnouncement(id: number): Promise<{ message: string }> { + const { data } = await apiClient.delete<{ message: string }>(`/admin/announcements/${id}`) + return data +} + +export async function getReadStatus( + id: number, + page: number = 1, + pageSize: number = 20, + search: string = '' +): Promise> { + const { data } = await apiClient.get>( + `/admin/announcements/${id}/read-status`, + { params: { page, page_size: pageSize, search } } + ) + return data +} + +const announcementsAPI = { + list, + getById, + create, + update, + delete: deleteAnnouncement, + getReadStatus +} + +export default announcementsAPI + diff --git a/frontend/src/api/admin/index.ts b/frontend/src/api/admin/index.ts index e86f6348..9a8a4195 100644 --- a/frontend/src/api/admin/index.ts +++ b/frontend/src/api/admin/index.ts @@ -10,6 +10,7 @@ import accountsAPI from './accounts' import proxiesAPI from './proxies' import redeemAPI from './redeem' import promoAPI from './promo' +import announcementsAPI from './announcements' import settingsAPI from './settings' import systemAPI from './system' import subscriptionsAPI from './subscriptions' @@ -30,6 +31,7 @@ export const adminAPI = { proxies: proxiesAPI, redeem: redeemAPI, promo: promoAPI, + announcements: announcementsAPI, settings: settingsAPI, system: systemAPI, subscriptions: subscriptionsAPI, @@ -48,6 +50,7 @@ export { proxiesAPI, redeemAPI, promoAPI, + announcementsAPI, settingsAPI, systemAPI, subscriptionsAPI, @@ -59,3 +62,6 @@ export { } export default adminAPI + +// Re-export types used by components +export type { BalanceHistoryItem } from './users' diff --git a/frontend/src/api/admin/ops.ts b/frontend/src/api/admin/ops.ts index 6e048436..bf2c246c 100644 --- a/frontend/src/api/admin/ops.ts +++ b/frontend/src/api/admin/ops.ts @@ -353,6 +353,7 @@ export interface PlatformAvailability { total_accounts: number available_count: number rate_limit_count: number + scope_rate_limit_count?: Record error_count: number } @@ -363,6 +364,7 @@ export interface GroupAvailability { total_accounts: number available_count: number rate_limit_count: number + scope_rate_limit_count?: Record error_count: number } @@ -377,6 +379,7 @@ export interface AccountAvailability { is_rate_limited: boolean rate_limit_reset_at?: string rate_limit_remaining_sec?: number + scope_rate_limits?: Record is_overloaded: boolean overload_until?: string overload_remaining_sec?: number @@ -776,6 +779,7 @@ export interface OpsAdvancedSettings { ignore_count_tokens_errors: boolean ignore_context_canceled: boolean ignore_no_available_accounts: boolean + ignore_invalid_api_key_errors: boolean auto_refresh_enabled: boolean auto_refresh_interval_seconds: number } diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 6e2ade00..3dc76fe7 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -13,6 +13,10 @@ export interface SystemSettings { registration_enabled: boolean email_verify_enabled: boolean promo_code_enabled: boolean + password_reset_enabled: boolean + invitation_code_enabled: boolean + totp_enabled: boolean // TOTP 双因素认证 + totp_encryption_key_configured: boolean // TOTP 加密密钥是否已配置 // Default settings default_balance: number default_concurrency: number @@ -25,6 +29,8 @@ export interface SystemSettings { doc_url: string home_content: string hide_ccs_import_button: boolean + purchase_subscription_enabled: boolean + purchase_subscription_url: string // SMTP settings smtp_host: string smtp_port: number @@ -66,6 +72,9 @@ export interface UpdateSettingsRequest { registration_enabled?: boolean email_verify_enabled?: boolean promo_code_enabled?: boolean + password_reset_enabled?: boolean + invitation_code_enabled?: boolean + totp_enabled?: boolean // TOTP 双因素认证 default_balance?: number default_concurrency?: number site_name?: string @@ -76,6 +85,8 @@ export interface UpdateSettingsRequest { doc_url?: string home_content?: string hide_ccs_import_button?: boolean + purchase_subscription_enabled?: boolean + purchase_subscription_url?: string smtp_host?: string smtp_port?: number smtp_username?: string diff --git a/frontend/src/api/admin/subscriptions.ts b/frontend/src/api/admin/subscriptions.ts index 54b448e2..9f21056f 100644 --- a/frontend/src/api/admin/subscriptions.ts +++ b/frontend/src/api/admin/subscriptions.ts @@ -17,7 +17,7 @@ import type { * List all subscriptions with pagination * @param page - Page number (default: 1) * @param pageSize - Items per page (default: 20) - * @param filters - Optional filters (status, user_id, group_id) + * @param filters - Optional filters (status, user_id, group_id, sort_by, sort_order) * @returns Paginated list of subscriptions */ export async function list( @@ -27,6 +27,8 @@ export async function list( status?: 'active' | 'expired' | 'revoked' user_id?: number group_id?: number + sort_by?: string + sort_order?: 'asc' | 'desc' }, options?: { signal?: AbortSignal diff --git a/frontend/src/api/admin/users.ts b/frontend/src/api/admin/users.ts index 734e3ac7..287aef96 100644 --- a/frontend/src/api/admin/users.ts +++ b/frontend/src/api/admin/users.ts @@ -174,6 +174,53 @@ export async function getUserUsageStats( return data } +/** + * Balance history item returned from the API + */ +export interface BalanceHistoryItem { + id: number + code: string + type: string + value: number + status: string + used_by: number | null + used_at: string | null + created_at: string + group_id: number | null + validity_days: number + notes: string + user?: { id: number; email: string } | null + group?: { id: number; name: string } | null +} + +// Balance history response extends pagination with total_recharged summary +export interface BalanceHistoryResponse extends PaginatedResponse { + total_recharged: number +} + +/** + * Get user's balance/concurrency change history + * @param id - User ID + * @param page - Page number + * @param pageSize - Items per page + * @param type - Optional type filter (balance, admin_balance, concurrency, admin_concurrency, subscription) + * @returns Paginated balance history with total_recharged + */ +export async function getUserBalanceHistory( + id: number, + page: number = 1, + pageSize: number = 20, + type?: string +): Promise { + const params: Record = { page, page_size: pageSize } + if (type) params.type = type + const { data } = await apiClient.get( + `/admin/users/${id}/balance-history`, + { params } + ) + return data +} + export const usersAPI = { list, getById, @@ -184,7 +231,8 @@ export const usersAPI = { updateConcurrency, toggleStatus, getUserApiKeys, - getUserUsageStats + getUserUsageStats, + getUserBalanceHistory } export default usersAPI diff --git a/frontend/src/api/announcements.ts b/frontend/src/api/announcements.ts new file mode 100644 index 00000000..a9034e2a --- /dev/null +++ b/frontend/src/api/announcements.ts @@ -0,0 +1,26 @@ +/** + * User Announcements API endpoints + */ + +import { apiClient } from './client' +import type { UserAnnouncement } from '@/types' + +export async function list(unreadOnly: boolean = false): Promise { + const { data } = await apiClient.get('/announcements', { + params: unreadOnly ? { unread_only: 1 } : {} + }) + return data +} + +export async function markRead(id: number): Promise<{ message: string }> { + const { data } = await apiClient.post<{ message: string }>(`/announcements/${id}/read`) + return data +} + +const announcementsAPI = { + list, + markRead +} + +export default announcementsAPI + diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index fddc23ef..40c9c5a4 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -11,9 +11,23 @@ import type { CurrentUserResponse, SendVerifyCodeRequest, SendVerifyCodeResponse, - PublicSettings + PublicSettings, + TotpLoginResponse, + TotpLogin2FARequest } from '@/types' +/** + * Login response type - can be either full auth or 2FA required + */ +export type LoginResponse = AuthResponse | TotpLoginResponse + +/** + * Type guard to check if login response requires 2FA + */ +export function isTotp2FARequired(response: LoginResponse): response is TotpLoginResponse { + return 'requires_2fa' in response && response.requires_2fa === true +} + /** * Store authentication token in localStorage */ @@ -38,11 +52,28 @@ export function clearAuthToken(): void { /** * User login - * @param credentials - Username and password + * @param credentials - Email and password + * @returns Authentication response with token and user data, or 2FA required response + */ +export async function login(credentials: LoginRequest): Promise { + const { data } = await apiClient.post('/auth/login', credentials) + + // Only store token if 2FA is not required + if (!isTotp2FARequired(data)) { + setAuthToken(data.access_token) + localStorage.setItem('auth_user', JSON.stringify(data.user)) + } + + return data +} + +/** + * Complete login with 2FA code + * @param request - Temp token and TOTP code * @returns Authentication response with token and user data */ -export async function login(credentials: LoginRequest): Promise { - const { data } = await apiClient.post('/auth/login', credentials) +export async function login2FA(request: TotpLogin2FARequest): Promise { + const { data } = await apiClient.post('/auth/login/2fa', request) // Store token and user data setAuthToken(data.access_token) @@ -133,8 +164,79 @@ export async function validatePromoCode(code: string): Promise { + const { data } = await apiClient.post('/auth/validate-invitation-code', { code }) + return data +} + +/** + * Forgot password request + */ +export interface ForgotPasswordRequest { + email: string + turnstile_token?: string +} + +/** + * Forgot password response + */ +export interface ForgotPasswordResponse { + message: string +} + +/** + * Request password reset link + * @param request - Email and optional Turnstile token + * @returns Response with message + */ +export async function forgotPassword(request: ForgotPasswordRequest): Promise { + const { data } = await apiClient.post('/auth/forgot-password', request) + return data +} + +/** + * Reset password request + */ +export interface ResetPasswordRequest { + email: string + token: string + new_password: string +} + +/** + * Reset password response + */ +export interface ResetPasswordResponse { + message: string +} + +/** + * Reset password with token + * @param request - Email, token, and new password + * @returns Response with message + */ +export async function resetPassword(request: ResetPasswordRequest): Promise { + const { data } = await apiClient.post('/auth/reset-password', request) + return data +} + export const authAPI = { login, + login2FA, + isTotp2FARequired, register, getCurrentUser, logout, @@ -144,7 +246,10 @@ export const authAPI = { clearAuthToken, getPublicSettings, sendVerifyCode, - validatePromoCode + validatePromoCode, + validateInvitationCode, + forgotPassword, + resetPassword } export default authAPI diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 50b14c4c..070ce648 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -7,7 +7,7 @@ export { apiClient } from './client' // Auth API -export { authAPI } from './auth' +export { authAPI, isTotp2FARequired, type LoginResponse } from './auth' // User APIs export { keysAPI } from './keys' @@ -15,6 +15,8 @@ export { usageAPI } from './usage' export { userAPI } from './user' export { redeemAPI, type RedeemHistoryItem } from './redeem' export { userGroupsAPI } from './groups' +export { totpAPI } from './totp' +export { default as announcementsAPI } from './announcements' // Admin APIs export { adminAPI } from './admin' diff --git a/frontend/src/api/redeem.ts b/frontend/src/api/redeem.ts index 9e1c7d94..22abf4d8 100644 --- a/frontend/src/api/redeem.ts +++ b/frontend/src/api/redeem.ts @@ -14,7 +14,9 @@ export interface RedeemHistoryItem { status: string used_at: string created_at: string - // 订阅类型专用字段 + // Notes from admin for admin_balance/admin_concurrency types + notes?: string + // Subscription-specific fields group_id?: number validity_days?: number group?: { diff --git a/frontend/src/api/setup.ts b/frontend/src/api/setup.ts index 8b744590..1097c95b 100644 --- a/frontend/src/api/setup.ts +++ b/frontend/src/api/setup.ts @@ -31,6 +31,7 @@ export interface RedisConfig { port: number password: string db: number + enable_tls: boolean } export interface AdminConfig { diff --git a/frontend/src/api/totp.ts b/frontend/src/api/totp.ts new file mode 100644 index 00000000..cd658acb --- /dev/null +++ b/frontend/src/api/totp.ts @@ -0,0 +1,83 @@ +/** + * TOTP (2FA) API endpoints + * Handles Two-Factor Authentication with Google Authenticator + */ + +import { apiClient } from './client' +import type { + TotpStatus, + TotpSetupRequest, + TotpSetupResponse, + TotpEnableRequest, + TotpEnableResponse, + TotpDisableRequest, + TotpVerificationMethod +} from '@/types' + +/** + * Get TOTP status for current user + * @returns TOTP status including enabled state and feature availability + */ +export async function getStatus(): Promise { + const { data } = await apiClient.get('/user/totp/status') + return data +} + +/** + * Get verification method for TOTP operations + * @returns Method ('email' or 'password') required for setup/disable + */ +export async function getVerificationMethod(): Promise { + const { data } = await apiClient.get('/user/totp/verification-method') + return data +} + +/** + * Send email verification code for TOTP operations + * @returns Success response + */ +export async function sendVerifyCode(): Promise<{ success: boolean }> { + const { data } = await apiClient.post<{ success: boolean }>('/user/totp/send-code') + return data +} + +/** + * Initiate TOTP setup - generates secret and QR code + * @param request - Email code or password depending on verification method + * @returns Setup response with secret, QR code URL, and setup token + */ +export async function initiateSetup(request?: TotpSetupRequest): Promise { + const { data } = await apiClient.post('/user/totp/setup', request || {}) + return data +} + +/** + * Complete TOTP setup by verifying the code + * @param request - TOTP code and setup token + * @returns Enable response with success status and enabled timestamp + */ +export async function enable(request: TotpEnableRequest): Promise { + const { data } = await apiClient.post('/user/totp/enable', request) + return data +} + +/** + * Disable TOTP for current user + * @param request - Email code or password depending on verification method + * @returns Success response + */ +export async function disable(request: TotpDisableRequest): Promise<{ success: boolean }> { + const { data } = await apiClient.post<{ success: boolean }>('/user/totp/disable', request) + return data +} + +export const totpAPI = { + getStatus, + getVerificationMethod, + sendVerifyCode, + initiateSetup, + enable, + disable +} + +export default totpAPI diff --git a/frontend/src/components/account/AccountStatusIndicator.vue b/frontend/src/components/account/AccountStatusIndicator.vue index 02c962f1..8e525fa3 100644 --- a/frontend/src/components/account/AccountStatusIndicator.vue +++ b/frontend/src/components/account/AccountStatusIndicator.vue @@ -56,6 +56,65 @@ > + + +
+ + + 429 + + +
+ {{ t('admin.accounts.status.rateLimitedUntil', { time: formatTime(account.rate_limit_reset_at) }) }} +
+
+
+ + + + + +
+ + + 529 + + +
+ {{ t('admin.accounts.status.overloadedUntil', { time: formatTime(account.overload_until) }) }} +
+
+
@@ -63,7 +122,7 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import type { Account } from '@/types' -import { formatCountdownWithSuffix } from '@/utils/format' +import { formatCountdownWithSuffix, formatTime } from '@/utils/format' const { t } = useI18n() @@ -81,6 +140,25 @@ const isRateLimited = computed(() => { return new Date(props.account.rate_limit_reset_at) > new Date() }) +// Computed: active scope rate limits (Antigravity) +const activeScopeRateLimits = computed(() => { + const scopeLimits = props.account.scope_rate_limits + if (!scopeLimits) return [] + const now = new Date() + return Object.entries(scopeLimits) + .filter(([, info]) => new Date(info.reset_at) > now) + .map(([scope, info]) => ({ scope, reset_at: info.reset_at })) +}) + +const formatScopeName = (scope: string): string => { + const names: Record = { + claude: 'Claude', + gemini_text: 'Gemini', + gemini_image: 'Image' + } + return names[scope] || scope +} + // Computed: is overloaded (529) const isOverloaded = computed(() => { if (!props.account.overload_until) return false diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 30ec9e63..1ab2938c 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -1825,6 +1825,18 @@ + + + diff --git a/frontend/src/components/admin/announcements/AnnouncementReadStatusDialog.vue b/frontend/src/components/admin/announcements/AnnouncementReadStatusDialog.vue new file mode 100644 index 00000000..e7d991a8 --- /dev/null +++ b/frontend/src/components/admin/announcements/AnnouncementReadStatusDialog.vue @@ -0,0 +1,186 @@ + + + diff --git a/frontend/src/components/admin/announcements/AnnouncementTargetingEditor.vue b/frontend/src/components/admin/announcements/AnnouncementTargetingEditor.vue new file mode 100644 index 00000000..355f5399 --- /dev/null +++ b/frontend/src/components/admin/announcements/AnnouncementTargetingEditor.vue @@ -0,0 +1,408 @@ + + + diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index f6d1b1be..5edbd3b6 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -21,6 +21,12 @@ {{ value }} + + - diff --git a/frontend/src/components/auth/TotpLoginModal.vue b/frontend/src/components/auth/TotpLoginModal.vue new file mode 100644 index 00000000..03fa718d --- /dev/null +++ b/frontend/src/components/auth/TotpLoginModal.vue @@ -0,0 +1,176 @@ + + + diff --git a/frontend/src/components/common/AnnouncementBell.vue b/frontend/src/components/common/AnnouncementBell.vue new file mode 100644 index 00000000..9d00f9be --- /dev/null +++ b/frontend/src/components/common/AnnouncementBell.vue @@ -0,0 +1,626 @@ + + + + + + + diff --git a/frontend/src/components/common/BaseDialog.vue b/frontend/src/components/common/BaseDialog.vue index 3d38b568..93e4ba36 100644 --- a/frontend/src/components/common/BaseDialog.vue +++ b/frontend/src/components/common/BaseDialog.vue @@ -4,6 +4,7 @@ - +
+ + + + h( + 'svg', + { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, + [ + h('path', { + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + d: 'M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75V9a6 6 0 10-12 0v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0' + }) + ] + ) +} + const TicketIcon = { render: () => h( @@ -421,6 +436,16 @@ const userNavItems = computed(() => { { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true }, { path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true }, + ...(appStore.cachedPublicSettings?.purchase_subscription_enabled + ? [ + { + path: '/purchase', + label: t('nav.buySubscription'), + icon: CreditCardIcon, + hideInSimpleMode: true + } + ] + : []), { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true }, { path: '/profile', label: t('nav.profile'), icon: UserIcon } ] @@ -433,6 +458,16 @@ const personalNavItems = computed(() => { { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true }, { path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true }, + ...(appStore.cachedPublicSettings?.purchase_subscription_enabled + ? [ + { + path: '/purchase', + label: t('nav.buySubscription'), + icon: CreditCardIcon, + hideInSimpleMode: true + } + ] + : []), { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true }, { path: '/profile', label: t('nav.profile'), icon: UserIcon } ] @@ -450,6 +485,7 @@ const adminNavItems = computed(() => { { path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true }, { path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true }, { path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon }, + { path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon }, { path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon }, { path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true }, { path: '/admin/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true }, diff --git a/frontend/src/components/user/profile/ProfileTotpCard.vue b/frontend/src/components/user/profile/ProfileTotpCard.vue new file mode 100644 index 00000000..77413e52 --- /dev/null +++ b/frontend/src/components/user/profile/ProfileTotpCard.vue @@ -0,0 +1,154 @@ + + + diff --git a/frontend/src/components/user/profile/TotpDisableDialog.vue b/frontend/src/components/user/profile/TotpDisableDialog.vue new file mode 100644 index 00000000..daca4067 --- /dev/null +++ b/frontend/src/components/user/profile/TotpDisableDialog.vue @@ -0,0 +1,179 @@ + + + diff --git a/frontend/src/components/user/profile/TotpSetupModal.vue b/frontend/src/components/user/profile/TotpSetupModal.vue new file mode 100644 index 00000000..3d9b79ec --- /dev/null +++ b/frontend/src/components/user/profile/TotpSetupModal.vue @@ -0,0 +1,400 @@ + + + diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 6c655dc6..7936e20f 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -69,7 +69,9 @@ export default { port: 'Port', password: 'Password (optional)', database: 'Database', - passwordPlaceholder: 'Password' + passwordPlaceholder: 'Password', + enableTls: 'Enable TLS', + enableTlsHint: 'Use TLS when connecting to Redis (public CA certs)' }, admin: { title: 'Admin Account', @@ -146,7 +148,10 @@ export default { balance: 'Balance', available: 'Available', copiedToClipboard: 'Copied to clipboard', + copied: 'Copied', copyFailed: 'Failed to copy', + verifying: 'Verifying...', + processing: 'Processing...', contactSupport: 'Contact Support', add: 'Add', invalidEmail: 'Please enter a valid email address', @@ -182,6 +187,7 @@ export default { // Navigation nav: { dashboard: 'Dashboard', + announcements: 'Announcements', apiKeys: 'API Keys', usage: 'Usage', redeem: 'Redeem', @@ -203,6 +209,7 @@ export default { logout: 'Logout', github: 'GitHub', mySubscriptions: 'My Subscriptions', + buySubscription: 'Purchase Subscription', docs: 'Docs' }, @@ -258,6 +265,13 @@ export default { promoCodeAlreadyUsed: 'You have already used this promo code', promoCodeValidating: 'Promo code is being validated, please wait', promoCodeInvalidCannotRegister: 'Invalid promo code. Please check and try again or clear the promo code field', + invitationCodeLabel: 'Invitation Code', + invitationCodePlaceholder: 'Enter invitation code', + invitationCodeRequired: 'Invitation code is required', + invitationCodeValid: 'Invitation code is valid', + invitationCodeInvalid: 'Invalid or used invitation code', + invitationCodeValidating: 'Validating invitation code...', + invitationCodeInvalidCannotRegister: 'Invalid invitation code. Please check and try again', linuxdo: { signIn: 'Continue with Linux.do', orContinue: 'or continue with email', @@ -271,7 +285,36 @@ export default { code: 'Code', state: 'State', fullUrl: 'Full URL' - } + }, + // Forgot password + forgotPassword: 'Forgot password?', + forgotPasswordTitle: 'Reset Your Password', + forgotPasswordHint: 'Enter your email address and we will send you a link to reset your password.', + sendResetLink: 'Send Reset Link', + sendingResetLink: 'Sending...', + sendResetLinkFailed: 'Failed to send reset link. Please try again.', + resetEmailSent: 'Reset Link Sent', + resetEmailSentHint: 'If an account exists with this email, you will receive a password reset link shortly. Please check your inbox and spam folder.', + backToLogin: 'Back to Login', + rememberedPassword: 'Remembered your password?', + // Reset password + resetPasswordTitle: 'Set New Password', + resetPasswordHint: 'Enter your new password below.', + newPassword: 'New Password', + newPasswordPlaceholder: 'Enter your new password', + confirmPassword: 'Confirm Password', + confirmPasswordPlaceholder: 'Confirm your new password', + confirmPasswordRequired: 'Please confirm your password', + passwordsDoNotMatch: 'Passwords do not match', + resetPassword: 'Reset Password', + resettingPassword: 'Resetting...', + resetPasswordFailed: 'Failed to reset password. Please try again.', + passwordResetSuccess: 'Password Reset Successful', + passwordResetSuccessHint: 'Your password has been reset. You can now sign in with your new password.', + invalidResetLink: 'Invalid Reset Link', + invalidResetLinkHint: 'This password reset link is invalid or has expired. Please request a new one.', + requestNewResetLink: 'Request New Reset Link', + invalidOrExpiredToken: 'The password reset link is invalid or has expired. Please request a new one.' }, // Dashboard @@ -459,6 +502,7 @@ export default { exporting: 'Exporting...', preparingExport: 'Preparing export...', model: 'Model', + reasoningEffort: 'Reasoning Effort', type: 'Type', tokens: 'Tokens', cost: 'Cost', @@ -554,7 +598,46 @@ export default { passwordsNotMatch: 'New passwords do not match', passwordTooShort: 'Password must be at least 8 characters long', passwordChangeSuccess: 'Password changed successfully', - passwordChangeFailed: 'Failed to change password' + passwordChangeFailed: 'Failed to change password', + // TOTP 2FA + totp: { + title: 'Two-Factor Authentication (2FA)', + description: 'Enhance account security with Google Authenticator or similar apps', + enabled: 'Enabled', + enabledAt: 'Enabled at', + notEnabled: 'Not Enabled', + notEnabledHint: 'Enable two-factor authentication to enhance account security', + enable: 'Enable', + disable: 'Disable', + featureDisabled: 'Feature Unavailable', + featureDisabledHint: 'Two-factor authentication has not been enabled by the administrator', + setupTitle: 'Set Up Two-Factor Authentication', + setupStep1: 'Scan the QR code below with your authenticator app', + setupStep2: 'Enter the 6-digit code from your app', + manualEntry: "Can't scan? Enter the key manually:", + enterCode: 'Enter 6-digit code', + verify: 'Verify', + setupFailed: 'Failed to get setup information', + verifyFailed: 'Invalid code, please try again', + enableSuccess: 'Two-factor authentication enabled', + disableTitle: 'Disable Two-Factor Authentication', + disableWarning: 'After disabling, you will no longer need a verification code to log in. This may reduce your account security.', + enterPassword: 'Enter your current password to confirm', + confirmDisable: 'Confirm Disable', + disableSuccess: 'Two-factor authentication disabled', + disableFailed: 'Failed to disable, please check your password', + loginTitle: 'Two-Factor Authentication', + loginHint: 'Enter the 6-digit code from your authenticator app', + loginFailed: 'Verification failed, please try again', + // New translations for email verification + verifyEmailFirst: 'Please verify your email first', + verifyPasswordFirst: 'Please verify your identity first', + emailCode: 'Email Verification Code', + enterEmailCode: 'Enter 6-digit code', + sendCode: 'Send Code', + codeSent: 'Verification code sent to your email', + sendCodeFailed: 'Failed to send verification code' + } }, // Empty States @@ -760,6 +843,20 @@ export default { failedToDeposit: 'Failed to deposit', failedToWithdraw: 'Failed to withdraw', useDepositWithdrawButtons: 'Please use deposit/withdraw buttons to adjust balance', + // Balance History + balanceHistory: 'Recharge History', + balanceHistoryTip: 'Click to open recharge history', + balanceHistoryTitle: 'User Recharge & Concurrency History', + noBalanceHistory: 'No records found for this user', + allTypes: 'All Types', + typeBalance: 'Balance (Redeem)', + typeAdminBalance: 'Balance (Admin)', + typeConcurrency: 'Concurrency (Redeem)', + typeAdminConcurrency: 'Concurrency (Admin)', + typeSubscription: 'Subscription', + failedToLoadBalanceHistory: 'Failed to load balance history', + createdAt: 'Created', + totalRecharged: 'Total Recharged', roles: { admin: 'Admin', user: 'User' @@ -938,6 +1035,14 @@ export default { fallbackHint: 'Non-Claude Code requests will use this group. Leave empty to reject directly.', noFallback: 'No Fallback (Reject)' }, + copyAccounts: { + title: 'Copy Accounts from Groups', + tooltip: 'Select one or more groups of the same platform. After creation, all accounts from these groups will be automatically bound to the new group (deduplicated).', + tooltipEdit: 'Select one or more groups of the same platform. After saving, current group accounts will be replaced with accounts from these groups (deduplicated).', + selectPlaceholder: 'Select groups to copy accounts from...', + hint: 'Multiple groups can be selected, accounts will be deduplicated', + hintEdit: '⚠️ Warning: This will replace all existing account bindings' + }, modelRouting: { title: 'Model Routing', tooltip: 'Configure specific model requests to be routed to designated accounts. Supports wildcard matching, e.g., claude-opus-* matches all opus models.', @@ -1110,6 +1215,7 @@ export default { overloaded: 'Overloaded', tempUnschedulable: 'Temp Unschedulable', rateLimitedUntil: 'Rate limited until {time}', + scopeRateLimitedUntil: '{scope} rate limited until {time}', overloadedUntil: 'Overloaded until {time}', viewTempUnschedDetails: 'View temp unschedulable details' }, @@ -1357,6 +1463,8 @@ export default { accountUpdated: 'Account updated successfully', failedToCreate: 'Failed to create account', failedToUpdate: 'Failed to update account', + mixedChannelWarningTitle: 'Mixed Channel Warning', + mixedChannelWarning: 'Warning: Group "{groupName}" contains both {currentPlatform} and {otherPlatform} accounts. Mixing different channels may cause thinking block signature validation issues, which will fallback to non-thinking mode. Are you sure you want to continue?', pleaseEnterAccountName: 'Please enter account name', pleaseEnterApiKey: 'Please enter API Key', apiKeyIsRequired: 'API Key is required', @@ -1833,6 +1941,8 @@ export default { balance: 'Balance', concurrency: 'Concurrency', subscription: 'Subscription', + invitation: 'Invitation', + invitationHint: 'Invitation codes are used to restrict user registration. They are automatically marked as used after use.', unused: 'Unused', used: 'Used', columns: { @@ -1879,6 +1989,7 @@ export default { balance: 'Balance', concurrency: 'Concurrency', subscription: 'Subscription', + invitation: 'Invitation', // Admin adjustment types (created when admin modifies user balance/concurrency) admin_balance: 'Balance (Admin)', admin_concurrency: 'Concurrency (Admin)' @@ -1896,6 +2007,73 @@ export default { } }, + // Announcements + announcements: { + title: 'Announcements', + description: 'Create announcements and target by conditions', + createAnnouncement: 'Create Announcement', + editAnnouncement: 'Edit Announcement', + deleteAnnouncement: 'Delete Announcement', + searchAnnouncements: 'Search announcements...', + status: 'Status', + allStatus: 'All Status', + columns: { + title: 'Title', + status: 'Status', + targeting: 'Targeting', + timeRange: 'Schedule', + createdAt: 'Created At', + actions: 'Actions' + }, + statusLabels: { + draft: 'Draft', + active: 'Active', + archived: 'Archived' + }, + form: { + title: 'Title', + content: 'Content (Markdown supported)', + status: 'Status', + startsAt: 'Starts At', + endsAt: 'Ends At', + startsAtHint: 'Leave empty to start immediately', + endsAtHint: 'Leave empty to never expire', + targetingMode: 'Targeting', + targetingAll: 'All users', + targetingCustom: 'Custom rules', + addOrGroup: 'Add OR group', + addAndCondition: 'Add AND condition', + conditionType: 'Condition type', + conditionSubscription: 'Subscription', + conditionBalance: 'Balance', + operator: 'Operator', + balanceValue: 'Balance threshold', + selectPackages: 'Select packages' + }, + operators: { + gt: '>', + gte: '≥', + lt: '<', + lte: '≤', + eq: '=' + }, + targetingSummaryAll: 'All users', + targetingSummaryCustom: 'Custom ({groups} groups)', + timeImmediate: 'Immediate', + timeNever: 'Never', + readStatus: 'Read Status', + eligible: 'Eligible', + readAt: 'Read at', + unread: 'Unread', + searchUsers: 'Search users...', + failedToLoad: 'Failed to load announcements', + failedToCreate: 'Failed to create announcement', + failedToUpdate: 'Failed to update announcement', + failedToDelete: 'Failed to delete announcement', + failedToLoadReadStatus: 'Failed to load read status', + deleteConfirm: 'Are you sure you want to delete this announcement? This action cannot be undone.' + }, + // Promo Codes promo: { title: 'Promo Code Management', @@ -2667,6 +2845,8 @@ export default { ignoreContextCanceledHint: 'When enabled, client disconnect (context canceled) errors will not be written to the error log.', ignoreNoAvailableAccounts: 'Ignore no available accounts errors', ignoreNoAvailableAccountsHint: 'When enabled, "No available accounts" errors will not be written to the error log (not recommended; usually a config issue).', + ignoreInvalidApiKeyErrors: 'Ignore invalid API key errors', + ignoreInvalidApiKeyErrorsHint: 'When enabled, invalid or missing API key errors (INVALID_API_KEY, API_KEY_REQUIRED) will not be written to the error log.', autoRefresh: 'Auto Refresh', enableAutoRefresh: 'Enable auto refresh', enableAutoRefreshHint: 'Automatically refresh dashboard data at a fixed interval.', @@ -2694,6 +2874,7 @@ export default { empty: 'No data', queued: 'Queue {count}', rateLimited: 'Rate-limited {count}', + scopeRateLimitedTooltip: '{scope} rate-limited ({count} accounts)', errorAccounts: 'Errors {count}', loadFailed: 'Failed to load concurrency data' }, @@ -2760,7 +2941,15 @@ export default { emailVerification: 'Email Verification', emailVerificationHint: 'Require email verification for new registrations', promoCode: 'Promo Code', - promoCodeHint: 'Allow users to use promo codes during registration' + promoCodeHint: 'Allow users to use promo codes during registration', + invitationCode: 'Invitation Code Registration', + invitationCodeHint: 'When enabled, users must enter a valid invitation code to register', + passwordReset: 'Password Reset', + passwordResetHint: 'Allow users to reset their password via email', + totp: 'Two-Factor Authentication (2FA)', + totpHint: 'Allow users to use authenticator apps like Google Authenticator', + totpKeyNotConfigured: + 'Please configure TOTP_ENCRYPTION_KEY in environment variables first. Generate a key with: openssl rand -hex 32' }, turnstile: { title: 'Cloudflare Turnstile', @@ -2834,6 +3023,17 @@ export default { hideCcsImportButton: 'Hide CCS Import Button', hideCcsImportButtonHint: 'When enabled, the "Import to CCS" button will be hidden on the API Keys page' }, + purchase: { + title: 'Purchase Page', + description: 'Show a "Purchase Subscription" entry in the sidebar and open the configured URL in an iframe', + enabled: 'Show Purchase Entry', + enabledHint: 'Only shown in standard mode (not simple mode)', + url: 'Purchase URL', + urlPlaceholder: 'https://example.com/purchase', + urlHint: 'Must be an absolute http(s) URL', + iframeWarning: + '⚠️ iframe note: Some websites block embedding via X-Frame-Options or CSP (frame-ancestors). If the page is blank, provide an "Open in new tab" alternative.' + }, smtp: { title: 'SMTP Settings', description: 'Configure email sending for verification codes', @@ -2979,6 +3179,42 @@ export default { retry: 'Retry' }, + // Purchase Subscription Page + purchase: { + title: 'Purchase Subscription', + description: 'Purchase a subscription via the embedded page', + openInNewTab: 'Open in new tab', + notEnabledTitle: 'Feature not enabled', + notEnabledDesc: 'The administrator has not enabled the purchase page. Please contact admin.', + notConfiguredTitle: 'Purchase URL not configured', + notConfiguredDesc: + 'The administrator enabled the entry but has not configured a purchase URL. Please contact admin.' + }, + + // Announcements Page + announcements: { + title: 'Announcements', + description: 'View system announcements', + unreadOnly: 'Show unread only', + markRead: 'Mark as read', + markAllRead: 'Mark all as read', + viewAll: 'View all announcements', + markedAsRead: 'Marked as read', + allMarkedAsRead: 'All announcements marked as read', + newCount: '{count} new announcement | {count} new announcements', + readAt: 'Read at', + read: 'Read', + unread: 'Unread', + startsAt: 'Starts at', + endsAt: 'Ends at', + empty: 'No announcements', + emptyUnread: 'No unread announcements', + total: 'announcements', + emptyDescription: 'There are no system announcements at this time', + readStatus: 'You have read this announcement', + markReadHint: 'Click "Mark as read" to mark this announcement' + }, + // User Subscriptions Page userSubscriptions: { title: 'My Subscriptions', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 42f11b1e..a4cce89b 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -66,7 +66,9 @@ export default { port: '端口', password: '密码(可选)', database: '数据库', - passwordPlaceholder: '密码' + passwordPlaceholder: '密码', + enableTls: '启用 TLS', + enableTlsHint: '连接 Redis 时使用 TLS(公共 CA 证书)' }, admin: { title: '管理员账户', @@ -143,7 +145,10 @@ export default { balance: '余额', available: '可用', copiedToClipboard: '已复制到剪贴板', + copied: '已复制', copyFailed: '复制失败', + verifying: '验证中...', + processing: '处理中...', contactSupport: '联系客服', add: '添加', invalidEmail: '请输入有效的邮箱地址', @@ -179,6 +184,7 @@ export default { // Navigation nav: { dashboard: '仪表盘', + announcements: '公告', apiKeys: 'API 密钥', usage: '使用记录', redeem: '兑换', @@ -200,6 +206,7 @@ export default { logout: '退出登录', github: 'GitHub', mySubscriptions: '我的订阅', + buySubscription: '购买订阅', docs: '文档' }, @@ -255,6 +262,13 @@ export default { promoCodeAlreadyUsed: '您已使用过此优惠码', promoCodeValidating: '优惠码正在验证中,请稍候', promoCodeInvalidCannotRegister: '优惠码无效,请检查后重试或清空优惠码', + invitationCodeLabel: '邀请码', + invitationCodePlaceholder: '请输入邀请码', + invitationCodeRequired: '请输入邀请码', + invitationCodeValid: '邀请码有效', + invitationCodeInvalid: '邀请码无效或已被使用', + invitationCodeValidating: '正在验证邀请码...', + invitationCodeInvalidCannotRegister: '邀请码无效,请检查后重试', linuxdo: { signIn: '使用 Linux.do 登录', orContinue: '或使用邮箱密码继续', @@ -268,7 +282,36 @@ export default { code: '授权码', state: '状态', fullUrl: '完整URL' - } + }, + // 忘记密码 + forgotPassword: '忘记密码?', + forgotPasswordTitle: '重置密码', + forgotPasswordHint: '输入您的邮箱地址,我们将向您发送密码重置链接。', + sendResetLink: '发送重置链接', + sendingResetLink: '发送中...', + sendResetLinkFailed: '发送重置链接失败,请重试。', + resetEmailSent: '重置链接已发送', + resetEmailSentHint: '如果该邮箱已注册,您将很快收到密码重置链接。请检查您的收件箱和垃圾邮件文件夹。', + backToLogin: '返回登录', + rememberedPassword: '想起密码了?', + // 重置密码 + resetPasswordTitle: '设置新密码', + resetPasswordHint: '请在下方输入您的新密码。', + newPassword: '新密码', + newPasswordPlaceholder: '输入新密码', + confirmPassword: '确认密码', + confirmPasswordPlaceholder: '再次输入新密码', + confirmPasswordRequired: '请确认您的密码', + passwordsDoNotMatch: '两次输入的密码不一致', + resetPassword: '重置密码', + resettingPassword: '重置中...', + resetPasswordFailed: '重置密码失败,请重试。', + passwordResetSuccess: '密码重置成功', + passwordResetSuccessHint: '您的密码已重置。现在可以使用新密码登录。', + invalidResetLink: '无效的重置链接', + invalidResetLinkHint: '此密码重置链接无效或已过期。请重新请求一个新链接。', + requestNewResetLink: '请求新的重置链接', + invalidOrExpiredToken: '密码重置链接无效或已过期。请重新请求一个新链接。' }, // Dashboard @@ -455,6 +498,7 @@ export default { exporting: '导出中...', preparingExport: '正在准备导出...', model: '模型', + reasoningEffort: '推理强度', type: '类型', tokens: 'Token', cost: '费用', @@ -550,7 +594,46 @@ export default { passwordsNotMatch: '两次输入的密码不一致', passwordTooShort: '密码至少需要 8 个字符', passwordChangeSuccess: '密码修改成功', - passwordChangeFailed: '密码修改失败' + passwordChangeFailed: '密码修改失败', + // TOTP 2FA + totp: { + title: '双因素认证 (2FA)', + description: '使用 Google Authenticator 等应用增强账户安全', + enabled: '已启用', + enabledAt: '启用时间', + notEnabled: '未启用', + notEnabledHint: '启用双因素认证可以增强账户安全性', + enable: '启用', + disable: '禁用', + featureDisabled: '功能未开放', + featureDisabledHint: '管理员尚未开放双因素认证功能', + setupTitle: '设置双因素认证', + setupStep1: '使用认证器应用扫描下方二维码', + setupStep2: '输入应用显示的 6 位验证码', + manualEntry: '无法扫码?手动输入密钥:', + enterCode: '输入 6 位验证码', + verify: '验证', + setupFailed: '获取设置信息失败', + verifyFailed: '验证码错误,请重试', + enableSuccess: '双因素认证已启用', + disableTitle: '禁用双因素认证', + disableWarning: '禁用后,登录时将不再需要验证码。这可能会降低您的账户安全性。', + enterPassword: '请输入当前密码确认', + confirmDisable: '确认禁用', + disableSuccess: '双因素认证已禁用', + disableFailed: '禁用失败,请检查密码是否正确', + loginTitle: '双因素认证', + loginHint: '请输入您认证器应用显示的 6 位验证码', + loginFailed: '验证失败,请重试', + // New translations for email verification + verifyEmailFirst: '请先验证您的邮箱', + verifyPasswordFirst: '请先验证您的身份', + emailCode: '邮箱验证码', + enterEmailCode: '请输入 6 位验证码', + sendCode: '发送验证码', + codeSent: '验证码已发送到您的邮箱', + sendCodeFailed: '发送验证码失败' + } }, // Empty States @@ -811,6 +894,20 @@ export default { failedToDeposit: '充值失败', failedToWithdraw: '退款失败', useDepositWithdrawButtons: '请使用充值/退款按钮调整余额', + // 余额变动记录 + balanceHistory: '充值记录', + balanceHistoryTip: '点击查看充值记录', + balanceHistoryTitle: '用户充值和并发变动记录', + noBalanceHistory: '暂无变动记录', + allTypes: '全部类型', + typeBalance: '余额(兑换码)', + typeAdminBalance: '余额(管理员调整)', + typeConcurrency: '并发(兑换码)', + typeAdminConcurrency: '并发(管理员调整)', + typeSubscription: '订阅', + failedToLoadBalanceHistory: '加载余额记录失败', + createdAt: '创建时间', + totalRecharged: '总充值', // Settings Dropdowns filterSettings: '筛选设置', columnSettings: '列设置', @@ -1013,6 +1110,14 @@ export default { fallbackHint: '非 Claude Code 请求将使用此分组,留空则直接拒绝', noFallback: '不降级(直接拒绝)' }, + copyAccounts: { + title: '从分组复制账号', + tooltip: '选择一个或多个相同平台的分组,创建后会自动将这些分组的所有账号绑定到新分组(去重)。', + tooltipEdit: '选择一个或多个相同平台的分组,保存后当前分组的账号会被替换为这些分组的账号(去重)。', + selectPlaceholder: '选择分组以复制其账号...', + hint: '可选多个分组,账号会自动去重', + hintEdit: '⚠️ 注意:这会替换当前分组的所有账号绑定' + }, modelRouting: { title: '模型路由配置', tooltip: '配置特定模型请求优先路由到指定账号。支持通配符匹配,如 claude-opus-* 匹配所有 opus 模型。', @@ -1232,6 +1337,7 @@ export default { overloaded: '过载中', tempUnschedulable: '临时不可调度', rateLimitedUntil: '限流中,重置时间:{time}', + scopeRateLimitedUntil: '{scope} 限流中,重置时间:{time}', overloadedUntil: '负载过重,重置时间:{time}', viewTempUnschedDetails: '查看临时不可调度详情' }, @@ -1489,6 +1595,8 @@ export default { accountUpdated: '账号更新成功', failedToCreate: '创建账号失败', failedToUpdate: '更新账号失败', + mixedChannelWarningTitle: '混合渠道警告', + mixedChannelWarning: '警告:分组 "{groupName}" 中同时包含 {currentPlatform} 和 {otherPlatform} 账号。混合使用不同渠道可能导致 thinking block 签名验证问题,会自动回退到非 thinking 模式。确定要继续吗?', pleaseEnterAccountName: '请输入账号名称', pleaseEnterApiKey: '请输入 API Key', apiKeyIsRequired: 'API Key 是必需的', @@ -1956,6 +2064,7 @@ export default { balance: '余额', concurrency: '并发数', subscription: '订阅', + invitation: '邀请码', // 管理员在用户管理页面调整余额/并发时产生的记录 admin_balance: '余额(管理员)', admin_concurrency: '并发数(管理员)' @@ -1964,6 +2073,8 @@ export default { balance: '余额', concurrency: '并发数', subscription: '订阅', + invitation: '邀请码', + invitationHint: '邀请码用于限制用户注册,使用后自动标记为已使用。', allTypes: '全部类型', allStatus: '全部状态', unused: '未使用', @@ -2043,6 +2154,73 @@ export default { failedToDelete: '删除兑换码失败' }, + // Announcements + announcements: { + title: '公告管理', + description: '创建公告并按条件投放', + createAnnouncement: '创建公告', + editAnnouncement: '编辑公告', + deleteAnnouncement: '删除公告', + searchAnnouncements: '搜索公告...', + status: '状态', + allStatus: '全部状态', + columns: { + title: '标题', + status: '状态', + targeting: '展示条件', + timeRange: '有效期', + createdAt: '创建时间', + actions: '操作' + }, + statusLabels: { + draft: '草稿', + active: '展示中', + archived: '已归档' + }, + form: { + title: '标题', + content: '内容(支持 Markdown)', + status: '状态', + startsAt: '开始时间', + endsAt: '结束时间', + startsAtHint: '留空表示立即生效', + endsAtHint: '留空表示永久生效', + targetingMode: '展示条件', + targetingAll: '所有用户', + targetingCustom: '按条件', + addOrGroup: '添加 OR 条件组', + addAndCondition: '添加 AND 条件', + conditionType: '条件类型', + conditionSubscription: '订阅套餐', + conditionBalance: '余额', + operator: '运算符', + balanceValue: '余额阈值', + selectPackages: '选择套餐' + }, + operators: { + gt: '>', + gte: '≥', + lt: '<', + lte: '≤', + eq: '=' + }, + targetingSummaryAll: '全部用户', + targetingSummaryCustom: '自定义({groups} 组)', + timeImmediate: '立即', + timeNever: '永久', + readStatus: '已读情况', + eligible: '符合条件', + readAt: '已读时间', + unread: '未读', + searchUsers: '搜索用户...', + failedToLoad: '加载公告失败', + failedToCreate: '创建公告失败', + failedToUpdate: '更新公告失败', + failedToDelete: '删除公告失败', + failedToLoadReadStatus: '加载已读情况失败', + deleteConfirm: '确定要删除该公告吗?此操作无法撤销。' + }, + // Promo Codes promo: { title: '优惠码管理', @@ -2819,7 +2997,9 @@ export default { ignoreContextCanceled: '忽略客户端断连错误', ignoreContextCanceledHint: '启用后,客户端主动断开连接(context canceled)的错误将不会写入错误日志。', ignoreNoAvailableAccounts: '忽略无可用账号错误', - ignoreNoAvailableAccountsHint: '启用后,“No available accounts” 错误将不会写入错误日志(不推荐,这通常是配置问题)。', + ignoreNoAvailableAccountsHint: '启用后,"No available accounts" 错误将不会写入错误日志(不推荐,这通常是配置问题)。', + ignoreInvalidApiKeyErrors: '忽略无效 API Key 错误', + ignoreInvalidApiKeyErrorsHint: '启用后,无效或缺失 API Key 的错误(INVALID_API_KEY、API_KEY_REQUIRED)将不会写入错误日志。', autoRefresh: '自动刷新', enableAutoRefresh: '启用自动刷新', enableAutoRefreshHint: '自动刷新仪表板数据,启用后会定期拉取最新数据。', @@ -2847,6 +3027,7 @@ export default { empty: '暂无数据', queued: '队列 {count}', rateLimited: '限流 {count}', + scopeRateLimitedTooltip: '{scope} 限流中 ({count} 个账号)', errorAccounts: '异常 {count}', loadFailed: '加载并发数据失败' }, @@ -2913,7 +3094,15 @@ export default { emailVerification: '邮箱验证', emailVerificationHint: '新用户注册时需要验证邮箱', promoCode: '优惠码', - promoCodeHint: '允许用户在注册时使用优惠码' + promoCodeHint: '允许用户在注册时使用优惠码', + invitationCode: '邀请码注册', + invitationCodeHint: '开启后,用户注册时需要填写有效的邀请码', + passwordReset: '忘记密码', + passwordResetHint: '允许用户通过邮箱重置密码', + totp: '双因素认证 (2FA)', + totpHint: '允许用户使用 Google Authenticator 等应用进行二次验证', + totpKeyNotConfigured: + '请先在环境变量中配置 TOTP_ENCRYPTION_KEY。使用命令 openssl rand -hex 32 生成密钥。' }, turnstile: { title: 'Cloudflare Turnstile', @@ -2985,6 +3174,17 @@ export default { hideCcsImportButton: '隐藏 CCS 导入按钮', hideCcsImportButtonHint: '启用后将在 API Keys 页面隐藏"导入 CCS"按钮' }, + purchase: { + title: '购买订阅页面', + description: '在侧边栏展示“购买订阅”入口,并在页面内通过 iframe 打开指定链接', + enabled: '显示购买订阅入口', + enabledHint: '仅在标准模式(非简单模式)下展示', + url: '购买页面 URL', + urlPlaceholder: 'https://example.com/purchase', + urlHint: '必须是完整的 http(s) 链接', + iframeWarning: + '⚠️ iframe 提示:部分网站会通过 X-Frame-Options 或 CSP(frame-ancestors)禁止被 iframe 嵌入,出现空白时可引导用户使用“新窗口打开”。' + }, smtp: { title: 'SMTP 设置', description: '配置用于发送验证码的邮件服务', @@ -3129,6 +3329,41 @@ export default { retry: '重试' }, + // Purchase Subscription Page + purchase: { + title: '购买订阅', + description: '通过内嵌页面完成订阅购买', + openInNewTab: '新窗口打开', + notEnabledTitle: '该功能未开启', + notEnabledDesc: '管理员暂未开启购买订阅入口,请联系管理员。', + notConfiguredTitle: '购买链接未配置', + notConfiguredDesc: '管理员已开启入口,但尚未配置购买订阅链接,请联系管理员。' + }, + + // Announcements Page + announcements: { + title: '公告', + description: '查看系统公告', + unreadOnly: '仅显示未读', + markRead: '标记已读', + markAllRead: '全部已读', + viewAll: '查看全部公告', + markedAsRead: '已标记为已读', + allMarkedAsRead: '所有公告已标记为已读', + newCount: '有 {count} 条新公告', + readAt: '已读时间', + read: '已读', + unread: '未读', + startsAt: '开始时间', + endsAt: '结束时间', + empty: '暂无公告', + emptyUnread: '暂无未读公告', + total: '条公告', + emptyDescription: '暂时没有任何系统公告', + readStatus: '您已阅读此公告', + markReadHint: '点击"已读"标记此公告' + }, + // User Subscriptions Page userSubscriptions: { title: '我的订阅', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 96127063..4bb46cee 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -79,6 +79,24 @@ const routes: RouteRecordRaw[] = [ title: 'LinuxDo OAuth Callback' } }, + { + path: '/forgot-password', + name: 'ForgotPassword', + component: () => import('@/views/auth/ForgotPasswordView.vue'), + meta: { + requiresAuth: false, + title: 'Forgot Password' + } + }, + { + path: '/reset-password', + name: 'ResetPassword', + component: () => import('@/views/auth/ResetPasswordView.vue'), + meta: { + requiresAuth: false, + title: 'Reset Password' + } + }, // ==================== User Routes ==================== { @@ -157,6 +175,18 @@ const routes: RouteRecordRaw[] = [ descriptionKey: 'userSubscriptions.description' } }, + { + path: '/purchase', + name: 'PurchaseSubscription', + component: () => import('@/views/user/PurchaseSubscriptionView.vue'), + meta: { + requiresAuth: true, + requiresAdmin: false, + title: 'Purchase Subscription', + titleKey: 'purchase.title', + descriptionKey: 'purchase.description' + } + }, // ==================== Admin Routes ==================== { @@ -235,6 +265,18 @@ const routes: RouteRecordRaw[] = [ descriptionKey: 'admin.accounts.description' } }, + { + path: '/admin/announcements', + name: 'AdminAnnouncements', + component: () => import('@/views/admin/AnnouncementsView.vue'), + meta: { + requiresAuth: true, + requiresAdmin: true, + title: 'Announcements', + titleKey: 'admin.announcements.title', + descriptionKey: 'admin.announcements.description' + } + }, { path: '/admin/proxies', name: 'AdminProxies', diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index 9c4db599..0abf5a53 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -313,6 +313,8 @@ export const useAppStore = defineStore('app', () => { registration_enabled: false, email_verify_enabled: false, promo_code_enabled: true, + password_reset_enabled: false, + invitation_code_enabled: false, turnstile_enabled: false, turnstile_site_key: '', site_name: siteName.value, @@ -323,6 +325,8 @@ export const useAppStore = defineStore('app', () => { doc_url: docUrl.value, home_content: '', hide_ccs_import_button: false, + purchase_subscription_enabled: false, + purchase_subscription_url: '', linuxdo_oauth_enabled: false, version: siteVersion.value } diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 4076e154..e4612f5e 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -5,8 +5,8 @@ import { defineStore } from 'pinia' import { ref, computed, readonly } from 'vue' -import { authAPI } from '@/api' -import type { User, LoginRequest, RegisterRequest } from '@/types' +import { authAPI, isTotp2FARequired, type LoginResponse } from '@/api' +import type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types' const AUTH_TOKEN_KEY = 'auth_token' const AUTH_USER_KEY = 'auth_user' @@ -91,32 +91,23 @@ export const useAuthStore = defineStore('auth', () => { /** * User login - * @param credentials - Login credentials (username and password) - * @returns Promise resolving to the authenticated user + * @param credentials - Login credentials (email and password) + * @returns Promise resolving to the login response (may require 2FA) * @throws Error if login fails */ - async function login(credentials: LoginRequest): Promise { + async function login(credentials: LoginRequest): Promise { try { const response = await authAPI.login(credentials) - // Store token and user - token.value = response.access_token - - // Extract run_mode if present - if (response.user.run_mode) { - runMode.value = response.user.run_mode + // If 2FA is required, return the response without setting auth state + if (isTotp2FARequired(response)) { + return response } - const { run_mode: _run_mode, ...userData } = response.user - user.value = userData - // Persist to localStorage - localStorage.setItem(AUTH_TOKEN_KEY, response.access_token) - localStorage.setItem(AUTH_USER_KEY, JSON.stringify(userData)) + // Set auth state from the response + setAuthFromResponse(response) - // Start auto-refresh interval - startAutoRefresh() - - return userData + return response } catch (error) { // Clear any partial state on error clearAuth() @@ -124,6 +115,47 @@ export const useAuthStore = defineStore('auth', () => { } } + /** + * Complete login with 2FA code + * @param tempToken - Temporary token from initial login + * @param totpCode - 6-digit TOTP code + * @returns Promise resolving to the authenticated user + * @throws Error if 2FA verification fails + */ + async function login2FA(tempToken: string, totpCode: string): Promise { + try { + const response = await authAPI.login2FA({ temp_token: tempToken, totp_code: totpCode }) + setAuthFromResponse(response) + return user.value! + } catch (error) { + clearAuth() + throw error + } + } + + /** + * Set auth state from an AuthResponse + * Internal helper function + */ + function setAuthFromResponse(response: AuthResponse): void { + // Store token and user + token.value = response.access_token + + // Extract run_mode if present + if (response.user.run_mode) { + runMode.value = response.user.run_mode + } + const { run_mode: _run_mode, ...userData } = response.user + user.value = userData + + // Persist to localStorage + localStorage.setItem(AUTH_TOKEN_KEY, response.access_token) + localStorage.setItem(AUTH_USER_KEY, JSON.stringify(userData)) + + // Start auto-refresh interval + startAutoRefresh() + } + /** * User registration * @param userData - Registration data (username, email, password) @@ -253,6 +285,7 @@ export const useAuthStore = defineStore('auth', () => { // Actions login, + login2FA, register, setToken, logout, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d9e62d83..04e8ebdd 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -55,6 +55,7 @@ export interface RegisterRequest { verify_code?: string turnstile_token?: string promo_code?: string + invitation_code?: string } export interface SendVerifyCodeRequest { @@ -71,6 +72,8 @@ export interface PublicSettings { registration_enabled: boolean email_verify_enabled: boolean promo_code_enabled: boolean + password_reset_enabled: boolean + invitation_code_enabled: boolean turnstile_enabled: boolean turnstile_site_key: string site_name: string @@ -81,6 +84,8 @@ export interface PublicSettings { doc_url: string home_content: string hide_ccs_import_button: boolean + purchase_subscription_enabled: boolean + purchase_subscription_url: string linuxdo_oauth_enabled: boolean version: string } @@ -126,6 +131,81 @@ export interface UpdateSubscriptionRequest { is_active?: boolean } +// ==================== Announcement Types ==================== + +export type AnnouncementStatus = 'draft' | 'active' | 'archived' + +export type AnnouncementConditionType = 'subscription' | 'balance' + +export type AnnouncementOperator = 'in' | 'gt' | 'gte' | 'lt' | 'lte' | 'eq' + +export interface AnnouncementCondition { + type: AnnouncementConditionType + operator: AnnouncementOperator + group_ids?: number[] + value?: number +} + +export interface AnnouncementConditionGroup { + all_of?: AnnouncementCondition[] +} + +export interface AnnouncementTargeting { + any_of?: AnnouncementConditionGroup[] +} + +export interface Announcement { + id: number + title: string + content: string + status: AnnouncementStatus + targeting: AnnouncementTargeting + starts_at?: string + ends_at?: string + created_by?: number + updated_by?: number + created_at: string + updated_at: string +} + +export interface UserAnnouncement { + id: number + title: string + content: string + starts_at?: string + ends_at?: string + read_at?: string + created_at: string + updated_at: string +} + +export interface CreateAnnouncementRequest { + title: string + content: string + status?: AnnouncementStatus + targeting: AnnouncementTargeting + starts_at?: number + ends_at?: number +} + +export interface UpdateAnnouncementRequest { + title?: string + content?: string + status?: AnnouncementStatus + targeting?: AnnouncementTargeting + starts_at?: number + ends_at?: number +} + +export interface AnnouncementUserReadStatus { + user_id: number + email: string + username: string + balance: number + eligible: boolean + read_at?: string +} + // ==================== Proxy Node Types ==================== export interface ProxyNode { @@ -342,6 +422,8 @@ export interface CreateGroupRequest { sora_video_price_per_request_hd?: number | null claude_code_only?: boolean fallback_group_id?: number | null + // 从指定分组复制账号 + copy_accounts_from_group_ids?: number[] } export interface UpdateGroupRequest { @@ -364,6 +446,7 @@ export interface UpdateGroupRequest { sora_video_price_per_request_hd?: number | null claude_code_only?: boolean fallback_group_id?: number | null + copy_accounts_from_group_ids?: number[] } // ==================== Account & Proxy Types ==================== @@ -491,6 +574,9 @@ export interface Account { temp_unschedulable_until: string | null temp_unschedulable_reason: string | null + // Antigravity scope 级限流状态 + scope_rate_limits?: Record + // Session window fields (5-hour window) session_window_start: string | null session_window_end: string | null @@ -633,7 +719,7 @@ export interface UpdateProxyRequest { // ==================== Usage & Redeem Types ==================== -export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' +export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' | 'invitation' export interface UsageLog { id: number @@ -642,6 +728,7 @@ export interface UsageLog { account_id: number | null request_id: string model: string + reasoning_effort?: string | null group_id: number | null subscription_id: number | null @@ -1120,3 +1207,52 @@ export interface UpdatePromoCodeRequest { expires_at?: number | null notes?: string } + +// ==================== TOTP (2FA) Types ==================== + +export interface TotpStatus { + enabled: boolean + enabled_at: number | null // Unix timestamp in seconds + feature_enabled: boolean +} + +export interface TotpSetupRequest { + email_code?: string + password?: string +} + +export interface TotpSetupResponse { + secret: string + qr_code_url: string + setup_token: string + countdown: number +} + +export interface TotpEnableRequest { + totp_code: string + setup_token: string +} + +export interface TotpEnableResponse { + success: boolean +} + +export interface TotpDisableRequest { + email_code?: string + password?: string +} + +export interface TotpVerificationMethod { + method: 'email' | 'password' +} + +export interface TotpLoginResponse { + requires_2fa: boolean + temp_token?: string + user_email_masked?: string +} + +export interface TotpLogin2FARequest { + temp_token: string + totp_code: string +} diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts index 78e45354..f21dd0f6 100644 --- a/frontend/src/utils/format.ts +++ b/frontend/src/utils/format.ts @@ -174,6 +174,35 @@ export function parseDateTimeLocalInput(value: string): number | null { return Math.floor(date.getTime() / 1000) } +/** + * 格式化 OpenAI reasoning effort(用于使用记录展示) + * @param effort 原始 effort(如 "low" / "medium" / "high" / "xhigh") + * @returns 格式化后的字符串(Low / Medium / High / Xhigh),无值返回 "-" + */ +export function formatReasoningEffort(effort: string | null | undefined): string { + const raw = (effort ?? '').toString().trim() + if (!raw) return '-' + + const normalized = raw.toLowerCase().replace(/[-_\s]/g, '') + switch (normalized) { + case 'low': + return 'Low' + case 'medium': + return 'Medium' + case 'high': + return 'High' + case 'xhigh': + case 'extrahigh': + return 'Xhigh' + case 'none': + case 'minimal': + return '-' + default: + // best-effort: Title-case first letter + return raw.length > 1 ? raw[0].toUpperCase() + raw.slice(1) : raw.toUpperCase() + } +} + /** * 格式化时间(只显示时分) * @param date 日期字符串或 Date 对象 @@ -261,3 +290,22 @@ export function formatCountdownWithSuffix(targetDate: string | Date | null | und if (!countdown) return null return i18n.global.t('common.time.countdown.withSuffix', { time: countdown }) } + +/** + * 格式化为相对时间 + 具体时间组合 + * @param date 日期字符串或 Date 对象 + * @returns 组合时间字符串,如 "5 天前 · 2026-01-27 15:25" + */ +export function formatRelativeWithDateTime(date: string | Date | null | undefined): string { + if (!date) return '' + + const relativeTime = formatRelativeTime(date) + const dateTime = formatDateTime(date) + + // 如果是 "从未" 或空字符串,只返回相对时间 + if (!dateTime || relativeTime === i18n.global.t('common.time.never')) { + return relativeTime + } + + return `${relativeTime} · ${dateTime}` +} diff --git a/frontend/src/views/admin/AnnouncementsView.vue b/frontend/src/views/admin/AnnouncementsView.vue new file mode 100644 index 00000000..38574454 --- /dev/null +++ b/frontend/src/views/admin/AnnouncementsView.vue @@ -0,0 +1,538 @@ + + + diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index de9d8d0b..d927ad67 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -240,9 +240,73 @@ v-model="createForm.platform" :options="platformOptions" data-tour="group-form-platform" + @change="createForm.copy_accounts_from_group_ids = []" />

{{ t('admin.groups.platformHint') }}

+ +
+
+ +
+ +
+
+

+ {{ t('admin.groups.copyAccounts.tooltip') }} +

+
+
+
+
+
+ +
+ + {{ copyAccountsGroupOptions.find(o => o.value === groupId)?.label || `#${groupId}` }} + + +
+ + +

{{ t('admin.groups.copyAccounts.hint') }}

+

{{ t('admin.groups.platformNotEditable') }}

+ +
+
+ +
+ +
+
+

+ {{ t('admin.groups.copyAccounts.tooltipEdit') }} +

+
+
+
+
+
+ +
+ + {{ copyAccountsGroupOptionsForEdit.find(o => o.value === groupId)?.label || `#${groupId}` }} + + +
+ + +

{{ t('admin.groups.copyAccounts.hintEdit') }}

+
{ return options }) +// 复制账号的源分组选项(创建时)- 仅包含相同平台且有账号的分组 +const copyAccountsGroupOptions = computed(() => { + const eligibleGroups = groups.value.filter( + (g) => g.platform === createForm.platform && (g.account_count || 0) > 0 + ) + return eligibleGroups.map((g) => ({ + value: g.id, + label: `${g.name} (${g.account_count || 0} 个账号)` + })) +}) + +// 复制账号的源分组选项(编辑时)- 仅包含相同平台且有账号的分组,排除自身 +const copyAccountsGroupOptionsForEdit = computed(() => { + const currentId = editingGroup.value?.id + const eligibleGroups = groups.value.filter( + (g) => g.platform === editForm.platform && (g.account_count || 0) > 0 && g.id !== currentId + ) + return eligibleGroups.map((g) => ({ + value: g.id, + label: `${g.name} (${g.account_count || 0} 个账号)` + })) +}) + const groups = ref([]) const loading = ref(false) const searchQuery = ref('') @@ -1367,7 +1517,9 @@ const createForm = reactive({ claude_code_only: false, fallback_group_id: null as number | null, // 模型路由开关 - model_routing_enabled: false + model_routing_enabled: false, + // 从分组复制账号 + copy_accounts_from_group_ids: [] as number[] }) // 简单账号类型(用于模型路由选择) @@ -1543,7 +1695,9 @@ const editForm = reactive({ claude_code_only: false, fallback_group_id: null as number | null, // 模型路由开关 - model_routing_enabled: false + model_routing_enabled: false, + // 从分组复制账号 + copy_accounts_from_group_ids: [] as number[] }) // 根据分组类型返回不同的删除确认消息 @@ -1629,6 +1783,7 @@ const closeCreateModal = () => { createForm.sora_video_price_per_request_hd = null createForm.claude_code_only = false createForm.fallback_group_id = null + createForm.copy_accounts_from_group_ids = [] createModelRoutingRules.value = [] } @@ -1683,6 +1838,7 @@ const handleEdit = async (group: AdminGroup) => { editForm.claude_code_only = group.claude_code_only || false editForm.fallback_group_id = group.fallback_group_id editForm.model_routing_enabled = group.model_routing_enabled || false + editForm.copy_accounts_from_group_ids = [] // 复制账号字段每次编辑时重置为空 // 加载模型路由规则(异步加载账号名称) editModelRoutingRules.value = await convertApiFormatToRoutingRules(group.model_routing) showEditModal.value = true @@ -1692,6 +1848,7 @@ const closeEditModal = () => { showEditModal.value = false editingGroup.value = null editModelRoutingRules.value = [] + editForm.copy_accounts_from_group_ids = [] } const handleUpdateGroup = async () => { diff --git a/frontend/src/views/admin/RedeemView.vue b/frontend/src/views/admin/RedeemView.vue index 907c7541..06794879 100644 --- a/frontend/src/views/admin/RedeemView.vue +++ b/frontend/src/views/admin/RedeemView.vue @@ -213,7 +213,7 @@ + +
+ + + + +
+
+ +
+
+ +
+
+ + +
+ + + {{ t('auth.invitationCodeValid') }} + +
+

+ {{ invitationValidation.message }} +

+

+ {{ errors.invitation_code }} +

+
+ +
+
+
+

+ {{ t("setup.redis.enableTls") }} +

+

+ {{ t("setup.redis.enableTlsHint") }} +

+
+ +
+