diff --git a/.gitignore b/.gitignore index 93ae19f3..a50f3ecc 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ backend/server backend/sub2api backend/main +# Go 测试二进制 +*.test + # 测试覆盖率 *.out coverage.html diff --git a/Linux DO Connect.md b/Linux DO Connect.md new file mode 100644 index 00000000..7ca1260f --- /dev/null +++ b/Linux DO Connect.md @@ -0,0 +1,368 @@ +# Linux DO Connect + +OAuth(Open Authorization)是一个开放的网络授权标准,目前最新版本为 OAuth 2.0。我们日常使用的第三方登录(如 Google 账号登录)就采用了该标准。OAuth 允许用户授权第三方应用访问存储在其他服务提供商(如 Google)上的信息,无需在不同平台上重复填写注册信息。用户授权后,平台可以直接访问用户的账户信息进行身份验证,而用户无需向第三方应用提供密码。 + +目前系统已实现完整的 OAuth2 授权码(code)方式鉴权,但界面等配套功能还在持续完善中。让我们一起打造一个更完善的共享方案。 + +## 基本介绍 + +这是一套标准的 OAuth2 鉴权系统,可以让开发者共享论坛的用户基本信息。 + +- 可获取字段: + +| 参数 | 说明 | +| ----------------- | ------------------------------- | +| `id` | 用户唯一标识(不可变) | +| `username` | 论坛用户名 | +| `name` | 论坛用户昵称(可变) | +| `avatar_template` | 用户头像模板URL(支持多种尺寸) | +| `active` | 账号活跃状态 | +| `trust_level` | 信任等级(0-4) | +| `silenced` | 禁言状态 | +| `external_ids` | 外部ID关联信息 | +| `api_key` | API访问密钥 | + +通过这些信息,公益网站/接口可以实现: + +1. 基于 `id` 的服务频率限制 +2. 基于 `trust_level` 的服务额度分配 +3. 基于用户信息的滥用举报机制 + +## 相关端点 + +- Authorize 端点: `https://connect.linux.do/oauth2/authorize` +- Token 端点:`https://connect.linux.do/oauth2/token` +- 用户信息 端点:`https://connect.linux.do/api/user` + +## 申请使用 + +- 访问 [Connect.Linux.Do](https://connect.linux.do/) 申请接入你的应用。 + +![linuxdoconnect_1](https://wiki.linux.do/_next/image?url=%2Flinuxdoconnect_1.png&w=1080&q=75) + +- 点击 **`我的应用接入`** - **`申请新接入`**,填写相关信息。其中 **`回调地址`** 是你的应用接收用户信息的地址。 + +![linuxdoconnect_2](https://wiki.linux.do/_next/image?url=%2Flinuxdoconnect_2.png&w=1080&q=75) + +- 申请成功后,你将获得 **`Client Id`** 和 **`Client Secret`**,这是你应用的唯一身份凭证。 + +![linuxdoconnect_3](https://wiki.linux.do/_next/image?url=%2Flinuxdoconnect_3.png&w=1080&q=75) + +## 接入 Linux Do + +JavaScript +```JavaScript +// 安装第三方请求库(或使用原生的 Fetch API),本例中使用 axios +// npm install axios + +// 通过 OAuth2 获取 Linux Do 用户信息的参考流程 +const axios = require('axios'); +const readline = require('readline'); + +// 配置信息(建议通过环境变量配置,避免使用硬编码) +const CLIENT_ID = '你的 Client ID'; +const CLIENT_SECRET = '你的 Client Secret'; +const REDIRECT_URI = '你的回调地址'; +const AUTH_URL = 'https://connect.linux.do/oauth2/authorize'; +const TOKEN_URL = 'https://connect.linux.do/oauth2/token'; +const USER_INFO_URL = 'https://connect.linux.do/api/user'; + +// 第一步:生成授权 URL +function getAuthUrl() { + const params = new URLSearchParams({ + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + response_type: 'code', + scope: 'user' + }); + + return `${AUTH_URL}?${params.toString()}`; +} + +// 第二步:获取 code 参数 +function getCode() { + return new Promise((resolve) => { + // 本例中使用终端输入来模拟流程,仅供本地测试 + // 请在实际应用中替换为真实的处理逻辑 + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question('从回调 URL 中提取出 code,粘贴到此处并按回车:', (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +// 第三步:使用 code 参数获取访问令牌 +async function getAccessToken(code) { + try { + const form = new URLSearchParams({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code: code, + redirect_uri: REDIRECT_URI, + grant_type: 'authorization_code' + }).toString(); + + const response = await axios.post(TOKEN_URL, form, { + // 提醒:需正确配置请求头,否则无法正常获取访问令牌 + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json' + } + }); + + return response.data; + } catch (error) { + console.error(`获取访问令牌失败:${error.response ? JSON.stringify(error.response.data) : error.message}`); + throw error; + } +} + +// 第四步:使用访问令牌获取用户信息 +async function getUserInfo(accessToken) { + try { + const response = await axios.get(USER_INFO_URL, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + + return response.data; + } catch (error) { + console.error(`获取用户信息失败:${error.response ? JSON.stringify(error.response.data) : error.message}`); + throw error; + } +} + +// 主流程 +async function main() { + // 1. 生成授权 URL,前端引导用户访问授权页 + const authUrl = getAuthUrl(); + console.log(`请访问此 URL 授权:${authUrl} +`); + + // 2. 用户授权后,从回调 URL 获取 code 参数 + const code = await getCode(); + + try { + // 3. 使用 code 参数获取访问令牌 + const tokenData = await getAccessToken(code); + const accessToken = tokenData.access_token; + + // 4. 使用访问令牌获取用户信息 + if (accessToken) { + const userInfo = await getUserInfo(accessToken); + console.log(` +获取用户信息成功:${JSON.stringify(userInfo, null, 2)}`); + } else { + console.log(` +获取访问令牌失败:${JSON.stringify(tokenData)}`); + } + } catch (error) { + console.error('发生错误:', error); + } +} +``` +Python +```python +# 安装第三方请求库,本例中使用 requests +# pip install requests + +# 通过 OAuth2 获取 Linux Do 用户信息的参考流程 +import requests +import json + +# 配置信息(建议通过环境变量配置,避免使用硬编码) +CLIENT_ID = '你的 Client ID' +CLIENT_SECRET = '你的 Client Secret' +REDIRECT_URI = '你的回调地址' +AUTH_URL = 'https://connect.linux.do/oauth2/authorize' +TOKEN_URL = 'https://connect.linux.do/oauth2/token' +USER_INFO_URL = 'https://connect.linux.do/api/user' + +# 第一步:生成授权 URL +def get_auth_url(): + params = { + 'client_id': CLIENT_ID, + 'redirect_uri': REDIRECT_URI, + 'response_type': 'code', + 'scope': 'user' + } + auth_url = f"{AUTH_URL}?{'&'.join(f'{k}={v}' for k, v in params.items())}" + return auth_url + +# 第二步:获取 code 参数 +def get_code(): + # 本例中使用终端输入来模拟流程,仅供本地测试 + # 请在实际应用中替换为真实的处理逻辑 + return input('从回调 URL 中提取出 code,粘贴到此处并按回车:').strip() + +# 第三步:使用 code 参数获取访问令牌 +def get_access_token(code): + try: + data = { + 'client_id': CLIENT_ID, + 'client_secret': CLIENT_SECRET, + 'code': code, + 'redirect_uri': REDIRECT_URI, + 'grant_type': 'authorization_code' + } + # 提醒:需正确配置请求头,否则无法正常获取访问令牌 + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json' + } + response = requests.post(TOKEN_URL, data=data, headers=headers) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + print(f"获取访问令牌失败:{e}") + return None + +# 第四步:使用访问令牌获取用户信息 +def get_user_info(access_token): + try: + headers = { + 'Authorization': f'Bearer {access_token}' + } + response = requests.get(USER_INFO_URL, headers=headers) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + print(f"获取用户信息失败:{e}") + return None + +# 主流程 +if __name__ == '__main__': + # 1. 生成授权 URL,前端引导用户访问授权页 + auth_url = get_auth_url() + print(f'请访问此 URL 授权:{auth_url} +') + + # 2. 用户授权后,从回调 URL 获取 code 参数 + code = get_code() + + # 3. 使用 code 参数获取访问令牌 + token_data = get_access_token(code) + if token_data: + access_token = token_data.get('access_token') + + # 4. 使用访问令牌获取用户信息 + if access_token: + user_info = get_user_info(access_token) + if user_info: + print(f" +获取用户信息成功:{json.dumps(user_info, indent=2)}") + else: + print(" +获取用户信息失败") + else: + print(f" +获取访问令牌失败:{json.dumps(token_data, indent=2)}") + else: + print(" +获取访问令牌失败") +``` +PHP +```php +// 通过 OAuth2 获取 Linux Do 用户信息的参考流程 + +// 配置信息 +$CLIENT_ID = '你的 Client ID'; +$CLIENT_SECRET = '你的 Client Secret'; +$REDIRECT_URI = '你的回调地址'; +$AUTH_URL = 'https://connect.linux.do/oauth2/authorize'; +$TOKEN_URL = 'https://connect.linux.do/oauth2/token'; +$USER_INFO_URL = 'https://connect.linux.do/api/user'; + +// 生成授权 URL +function getAuthUrl($clientId, $redirectUri) { + global $AUTH_URL; + return $AUTH_URL . '?' . http_build_query([ + 'client_id' => $clientId, + 'redirect_uri' => $redirectUri, + 'response_type' => 'code', + 'scope' => 'user' + ]); +} + +// 使用 code 参数获取用户信息(合并获取令牌和获取用户信息的步骤) +function getUserInfoWithCode($code, $clientId, $clientSecret, $redirectUri) { + global $TOKEN_URL, $USER_INFO_URL; + + // 1. 获取访问令牌 + $ch = curl_init($TOKEN_URL); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([ + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + 'code' => $code, + 'redirect_uri' => $redirectUri, + 'grant_type' => 'authorization_code' + ])); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/x-www-form-urlencoded', + 'Accept: application/json' + ]); + + $tokenResponse = curl_exec($ch); + curl_close($ch); + + $tokenData = json_decode($tokenResponse, true); + if (!isset($tokenData['access_token'])) { + return ['error' => '获取访问令牌失败', 'details' => $tokenData]; + } + + // 2. 获取用户信息 + $ch = curl_init($USER_INFO_URL); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Authorization: Bearer ' . $tokenData['access_token'] + ]); + + $userResponse = curl_exec($ch); + curl_close($ch); + + return json_decode($userResponse, true); +} + +// 主流程 +// 1. 生成授权 URL +$authUrl = getAuthUrl($CLIENT_ID, $REDIRECT_URI); +echo "使用 Linux Do 登录"; + +// 2. 处理回调并获取用户信息 +if (isset($_GET['code'])) { + $userInfo = getUserInfoWithCode( + $_GET['code'], + $CLIENT_ID, + $CLIENT_SECRET, + $REDIRECT_URI + ); + + if (isset($userInfo['error'])) { + echo '错误: ' . $userInfo['error']; + } else { + echo '欢迎, ' . $userInfo['name'] . '!'; + // 处理用户登录逻辑... + } +} +``` + +## 使用说明 + +### 授权流程 + +1. 用户点击应用中的’使用 Linux Do 登录’按钮 +2. 系统将用户重定向至 Linux Do 的授权页面 +3. 用户完成授权后,系统自动重定向回应用并携带授权码 +4. 应用使用授权码获取访问令牌 +5. 使用访问令牌获取用户信息 + +### 安全建议 + +- 切勿在前端代码中暴露 Client Secret +- 对所有用户输入数据进行严格验证 +- 确保使用 HTTPS 协议传输数据 +- 定期更新并妥善保管 Client Secret \ No newline at end of file diff --git a/backend/cmd/jwtgen/main.go b/backend/cmd/jwtgen/main.go index 1b7f4aa4..c461198b 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) + authService := service.NewAuthService(userRepo, 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 17e51c38..79e0dd8a 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.1 +0.1.46 diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 31dc3682..30ea0fdb 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -51,13 +51,17 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { turnstileVerifier := repository.NewTurnstileVerifier() turnstileService := service.NewTurnstileService(settingService, turnstileVerifier) emailQueueService := service.ProvideEmailQueueService(emailService) - authService := service.NewAuthService(userRepository, configConfig, settingService, emailService, turnstileService, emailQueueService) + promoCodeRepository := repository.NewPromoCodeRepository(client) + billingCache := repository.NewBillingCache(redisClient) + userSubscriptionRepository := repository.NewUserSubscriptionRepository(client) + billingCacheService := service.NewBillingCacheService(billingCache, userRepository, userSubscriptionRepository, configConfig) + promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client) + authService := service.NewAuthService(userRepository, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService) userService := service.NewUserService(userRepository) - authHandler := handler.NewAuthHandler(configConfig, authService, userService) + authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService) userHandler := handler.NewUserHandler(userService) apiKeyRepository := repository.NewAPIKeyRepository(client) groupRepository := repository.NewGroupRepository(client, db) - userSubscriptionRepository := repository.NewUserSubscriptionRepository(client) apiKeyCache := repository.NewAPIKeyCache(redisClient) apiKeyService := service.NewAPIKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, apiKeyCache, configConfig) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) @@ -65,8 +69,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { usageService := service.NewUsageService(usageLogRepository, userRepository, client) usageHandler := handler.NewUsageHandler(usageService, apiKeyService) redeemCodeRepository := repository.NewRedeemCodeRepository(client) - billingCache := repository.NewBillingCache(redisClient) - billingCacheService := service.NewBillingCacheService(billingCache, userRepository, userSubscriptionRepository, configConfig) subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService) redeemCache := repository.NewRedeemCache(redisClient) redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client) @@ -112,6 +114,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService) proxyHandler := admin.NewProxyHandler(adminService) adminRedeemHandler := admin.NewRedeemHandler(adminService) + promoHandler := admin.NewPromoHandler(promoService) settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService) updateCache := repository.NewUpdateCache(redisClient) gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig) @@ -124,7 +127,7 @@ 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, settingHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler) + adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler) pricingRemoteClient := repository.ProvidePricingRemoteClient(configConfig) pricingService, err := service.ProvidePricingService(configConfig, pricingRemoteClient) if err != nil { @@ -145,7 +148,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService) adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService) apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig) - engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService) + engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService, redisClient) httpServer := server.ProvideHTTPServer(configConfig, engine) tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, configConfig) accountExpiryService := service.ProvideAccountExpiryService(accountRepository) diff --git a/backend/ent/account_query.go b/backend/ent/account_query.go index 3e363ecd..1761fa63 100644 --- a/backend/ent/account_query.go +++ b/backend/ent/account_query.go @@ -9,6 +9,7 @@ import ( "math" "entgo.io/ent" + "entgo.io/ent/dialect" "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" @@ -31,6 +32,7 @@ type AccountQuery struct { withProxy *ProxyQuery withUsageLogs *UsageLogQuery withAccountGroups *AccountGroupQuery + modifiers []func(*sql.Selector) // intermediate query (i.e. traversal path). sql *sql.Selector path func(context.Context) (*sql.Selector, error) @@ -495,6 +497,9 @@ func (_q *AccountQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Acco 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) } @@ -690,6 +695,9 @@ func (_q *AccountQuery) loadAccountGroups(ctx context.Context, query *AccountGro func (_q *AccountQuery) 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 @@ -755,6 +763,9 @@ func (_q *AccountQuery) sqlQuery(ctx context.Context) *sql.Selector { if _q.ctx.Unique != nil && *_q.ctx.Unique { selector.Distinct() } + for _, m := range _q.modifiers { + m(selector) + } for _, p := range _q.predicates { p(selector) } @@ -772,6 +783,32 @@ func (_q *AccountQuery) sqlQuery(ctx context.Context) *sql.Selector { 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 *AccountQuery) ForUpdate(opts ...sql.LockOption) *AccountQuery { + 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 *AccountQuery) ForShare(opts ...sql.LockOption) *AccountQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForShare(opts...) + }) + return _q +} + // AccountGroupBy is the group-by builder for Account entities. type AccountGroupBy struct { selector diff --git a/backend/ent/accountgroup_query.go b/backend/ent/accountgroup_query.go index 98e1c3f6..d0a4f58d 100644 --- a/backend/ent/accountgroup_query.go +++ b/backend/ent/accountgroup_query.go @@ -8,6 +8,7 @@ import ( "math" "entgo.io/ent" + "entgo.io/ent/dialect" "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" "github.com/Wei-Shaw/sub2api/ent/account" @@ -25,6 +26,7 @@ type AccountGroupQuery struct { predicates []predicate.AccountGroup withAccount *AccountQuery withGroup *GroupQuery + modifiers []func(*sql.Selector) // intermediate query (i.e. traversal path). sql *sql.Selector path func(context.Context) (*sql.Selector, error) @@ -347,6 +349,9 @@ func (_q *AccountGroupQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([] 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) } @@ -432,6 +437,9 @@ func (_q *AccountGroupQuery) loadGroup(ctx context.Context, query *GroupQuery, n func (_q *AccountGroupQuery) sqlCount(ctx context.Context) (int, error) { _spec := _q.querySpec() + if len(_q.modifiers) > 0 { + _spec.Modifiers = _q.modifiers + } _spec.Unique = false _spec.Node.Columns = nil return sqlgraph.CountNodes(ctx, _q.driver, _spec) @@ -495,6 +503,9 @@ func (_q *AccountGroupQuery) sqlQuery(ctx context.Context) *sql.Selector { if _q.ctx.Unique != nil && *_q.ctx.Unique { selector.Distinct() } + for _, m := range _q.modifiers { + m(selector) + } for _, p := range _q.predicates { p(selector) } @@ -512,6 +523,32 @@ func (_q *AccountGroupQuery) sqlQuery(ctx context.Context) *sql.Selector { 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 *AccountGroupQuery) ForUpdate(opts ...sql.LockOption) *AccountGroupQuery { + 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 *AccountGroupQuery) ForShare(opts ...sql.LockOption) *AccountGroupQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForShare(opts...) + }) + return _q +} + // AccountGroupGroupBy is the group-by builder for AccountGroup entities. type AccountGroupGroupBy struct { selector diff --git a/backend/ent/apikey.go b/backend/ent/apikey.go index fe3ad0cf..95586017 100644 --- a/backend/ent/apikey.go +++ b/backend/ent/apikey.go @@ -3,6 +3,7 @@ package ent import ( + "encoding/json" "fmt" "strings" "time" @@ -35,6 +36,10 @@ type APIKey struct { GroupID *int64 `json:"group_id,omitempty"` // Status holds the value of the "status" field. Status string `json:"status,omitempty"` + // Allowed IPs/CIDRs, e.g. ["192.168.1.100", "10.0.0.0/8"] + IPWhitelist []string `json:"ip_whitelist,omitempty"` + // Blocked IPs/CIDRs + IPBlacklist []string `json:"ip_blacklist,omitempty"` // Edges holds the relations/edges for other nodes in the graph. // The values are being populated by the APIKeyQuery when eager-loading is set. Edges APIKeyEdges `json:"edges"` @@ -90,6 +95,8 @@ func (*APIKey) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { + case apikey.FieldIPWhitelist, apikey.FieldIPBlacklist: + values[i] = new([]byte) case apikey.FieldID, apikey.FieldUserID, apikey.FieldGroupID: values[i] = new(sql.NullInt64) case apikey.FieldKey, apikey.FieldName, apikey.FieldStatus: @@ -167,6 +174,22 @@ func (_m *APIKey) assignValues(columns []string, values []any) error { } else if value.Valid { _m.Status = value.String } + case apikey.FieldIPWhitelist: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field ip_whitelist", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.IPWhitelist); err != nil { + return fmt.Errorf("unmarshal field ip_whitelist: %w", err) + } + } + case apikey.FieldIPBlacklist: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field ip_blacklist", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.IPBlacklist); err != nil { + return fmt.Errorf("unmarshal field ip_blacklist: %w", err) + } + } default: _m.selectValues.Set(columns[i], values[i]) } @@ -245,6 +268,12 @@ func (_m *APIKey) String() string { builder.WriteString(", ") builder.WriteString("status=") builder.WriteString(_m.Status) + builder.WriteString(", ") + builder.WriteString("ip_whitelist=") + builder.WriteString(fmt.Sprintf("%v", _m.IPWhitelist)) + builder.WriteString(", ") + builder.WriteString("ip_blacklist=") + builder.WriteString(fmt.Sprintf("%v", _m.IPBlacklist)) builder.WriteByte(')') return builder.String() } diff --git a/backend/ent/apikey/apikey.go b/backend/ent/apikey/apikey.go index 91f7d620..564cddb1 100644 --- a/backend/ent/apikey/apikey.go +++ b/backend/ent/apikey/apikey.go @@ -31,6 +31,10 @@ const ( FieldGroupID = "group_id" // FieldStatus holds the string denoting the status field in the database. FieldStatus = "status" + // FieldIPWhitelist holds the string denoting the ip_whitelist field in the database. + FieldIPWhitelist = "ip_whitelist" + // FieldIPBlacklist holds the string denoting the ip_blacklist field in the database. + FieldIPBlacklist = "ip_blacklist" // EdgeUser holds the string denoting the user edge name in mutations. EdgeUser = "user" // EdgeGroup holds the string denoting the group edge name in mutations. @@ -73,6 +77,8 @@ var Columns = []string{ FieldName, FieldGroupID, FieldStatus, + FieldIPWhitelist, + FieldIPBlacklist, } // ValidColumn reports if the column name is valid (part of the table columns). diff --git a/backend/ent/apikey/where.go b/backend/ent/apikey/where.go index 5e739006..5152867f 100644 --- a/backend/ent/apikey/where.go +++ b/backend/ent/apikey/where.go @@ -470,6 +470,26 @@ func StatusContainsFold(v string) predicate.APIKey { return predicate.APIKey(sql.FieldContainsFold(FieldStatus, v)) } +// IPWhitelistIsNil applies the IsNil predicate on the "ip_whitelist" field. +func IPWhitelistIsNil() predicate.APIKey { + return predicate.APIKey(sql.FieldIsNull(FieldIPWhitelist)) +} + +// IPWhitelistNotNil applies the NotNil predicate on the "ip_whitelist" field. +func IPWhitelistNotNil() predicate.APIKey { + return predicate.APIKey(sql.FieldNotNull(FieldIPWhitelist)) +} + +// IPBlacklistIsNil applies the IsNil predicate on the "ip_blacklist" field. +func IPBlacklistIsNil() predicate.APIKey { + return predicate.APIKey(sql.FieldIsNull(FieldIPBlacklist)) +} + +// IPBlacklistNotNil applies the NotNil predicate on the "ip_blacklist" field. +func IPBlacklistNotNil() predicate.APIKey { + return predicate.APIKey(sql.FieldNotNull(FieldIPBlacklist)) +} + // HasUser applies the HasEdge predicate on the "user" edge. func HasUser() predicate.APIKey { return predicate.APIKey(func(s *sql.Selector) { diff --git a/backend/ent/apikey_create.go b/backend/ent/apikey_create.go index 2098872c..d5363be5 100644 --- a/backend/ent/apikey_create.go +++ b/backend/ent/apikey_create.go @@ -113,6 +113,18 @@ func (_c *APIKeyCreate) SetNillableStatus(v *string) *APIKeyCreate { return _c } +// SetIPWhitelist sets the "ip_whitelist" field. +func (_c *APIKeyCreate) SetIPWhitelist(v []string) *APIKeyCreate { + _c.mutation.SetIPWhitelist(v) + return _c +} + +// SetIPBlacklist sets the "ip_blacklist" field. +func (_c *APIKeyCreate) SetIPBlacklist(v []string) *APIKeyCreate { + _c.mutation.SetIPBlacklist(v) + return _c +} + // SetUser sets the "user" edge to the User entity. func (_c *APIKeyCreate) SetUser(v *User) *APIKeyCreate { return _c.SetUserID(v.ID) @@ -285,6 +297,14 @@ func (_c *APIKeyCreate) createSpec() (*APIKey, *sqlgraph.CreateSpec) { _spec.SetField(apikey.FieldStatus, field.TypeString, value) _node.Status = value } + if value, ok := _c.mutation.IPWhitelist(); ok { + _spec.SetField(apikey.FieldIPWhitelist, field.TypeJSON, value) + _node.IPWhitelist = value + } + if value, ok := _c.mutation.IPBlacklist(); ok { + _spec.SetField(apikey.FieldIPBlacklist, field.TypeJSON, value) + _node.IPBlacklist = value + } if nodes := _c.mutation.UserIDs(); len(nodes) > 0 { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.M2O, @@ -483,6 +503,42 @@ func (u *APIKeyUpsert) UpdateStatus() *APIKeyUpsert { return u } +// SetIPWhitelist sets the "ip_whitelist" field. +func (u *APIKeyUpsert) SetIPWhitelist(v []string) *APIKeyUpsert { + u.Set(apikey.FieldIPWhitelist, v) + return u +} + +// UpdateIPWhitelist sets the "ip_whitelist" field to the value that was provided on create. +func (u *APIKeyUpsert) UpdateIPWhitelist() *APIKeyUpsert { + u.SetExcluded(apikey.FieldIPWhitelist) + return u +} + +// ClearIPWhitelist clears the value of the "ip_whitelist" field. +func (u *APIKeyUpsert) ClearIPWhitelist() *APIKeyUpsert { + u.SetNull(apikey.FieldIPWhitelist) + return u +} + +// SetIPBlacklist sets the "ip_blacklist" field. +func (u *APIKeyUpsert) SetIPBlacklist(v []string) *APIKeyUpsert { + u.Set(apikey.FieldIPBlacklist, v) + return u +} + +// UpdateIPBlacklist sets the "ip_blacklist" field to the value that was provided on create. +func (u *APIKeyUpsert) UpdateIPBlacklist() *APIKeyUpsert { + u.SetExcluded(apikey.FieldIPBlacklist) + return u +} + +// ClearIPBlacklist clears the value of the "ip_blacklist" field. +func (u *APIKeyUpsert) ClearIPBlacklist() *APIKeyUpsert { + u.SetNull(apikey.FieldIPBlacklist) + return u +} + // UpdateNewValues updates the mutable fields using the new values that were set on create. // Using this option is equivalent to using: // @@ -640,6 +696,48 @@ func (u *APIKeyUpsertOne) UpdateStatus() *APIKeyUpsertOne { }) } +// SetIPWhitelist sets the "ip_whitelist" field. +func (u *APIKeyUpsertOne) SetIPWhitelist(v []string) *APIKeyUpsertOne { + return u.Update(func(s *APIKeyUpsert) { + s.SetIPWhitelist(v) + }) +} + +// UpdateIPWhitelist sets the "ip_whitelist" field to the value that was provided on create. +func (u *APIKeyUpsertOne) UpdateIPWhitelist() *APIKeyUpsertOne { + return u.Update(func(s *APIKeyUpsert) { + s.UpdateIPWhitelist() + }) +} + +// ClearIPWhitelist clears the value of the "ip_whitelist" field. +func (u *APIKeyUpsertOne) ClearIPWhitelist() *APIKeyUpsertOne { + return u.Update(func(s *APIKeyUpsert) { + s.ClearIPWhitelist() + }) +} + +// SetIPBlacklist sets the "ip_blacklist" field. +func (u *APIKeyUpsertOne) SetIPBlacklist(v []string) *APIKeyUpsertOne { + return u.Update(func(s *APIKeyUpsert) { + s.SetIPBlacklist(v) + }) +} + +// UpdateIPBlacklist sets the "ip_blacklist" field to the value that was provided on create. +func (u *APIKeyUpsertOne) UpdateIPBlacklist() *APIKeyUpsertOne { + return u.Update(func(s *APIKeyUpsert) { + s.UpdateIPBlacklist() + }) +} + +// ClearIPBlacklist clears the value of the "ip_blacklist" field. +func (u *APIKeyUpsertOne) ClearIPBlacklist() *APIKeyUpsertOne { + return u.Update(func(s *APIKeyUpsert) { + s.ClearIPBlacklist() + }) +} + // Exec executes the query. func (u *APIKeyUpsertOne) Exec(ctx context.Context) error { if len(u.create.conflict) == 0 { @@ -963,6 +1061,48 @@ func (u *APIKeyUpsertBulk) UpdateStatus() *APIKeyUpsertBulk { }) } +// SetIPWhitelist sets the "ip_whitelist" field. +func (u *APIKeyUpsertBulk) SetIPWhitelist(v []string) *APIKeyUpsertBulk { + return u.Update(func(s *APIKeyUpsert) { + s.SetIPWhitelist(v) + }) +} + +// UpdateIPWhitelist sets the "ip_whitelist" field to the value that was provided on create. +func (u *APIKeyUpsertBulk) UpdateIPWhitelist() *APIKeyUpsertBulk { + return u.Update(func(s *APIKeyUpsert) { + s.UpdateIPWhitelist() + }) +} + +// ClearIPWhitelist clears the value of the "ip_whitelist" field. +func (u *APIKeyUpsertBulk) ClearIPWhitelist() *APIKeyUpsertBulk { + return u.Update(func(s *APIKeyUpsert) { + s.ClearIPWhitelist() + }) +} + +// SetIPBlacklist sets the "ip_blacklist" field. +func (u *APIKeyUpsertBulk) SetIPBlacklist(v []string) *APIKeyUpsertBulk { + return u.Update(func(s *APIKeyUpsert) { + s.SetIPBlacklist(v) + }) +} + +// UpdateIPBlacklist sets the "ip_blacklist" field to the value that was provided on create. +func (u *APIKeyUpsertBulk) UpdateIPBlacklist() *APIKeyUpsertBulk { + return u.Update(func(s *APIKeyUpsert) { + s.UpdateIPBlacklist() + }) +} + +// ClearIPBlacklist clears the value of the "ip_blacklist" field. +func (u *APIKeyUpsertBulk) ClearIPBlacklist() *APIKeyUpsertBulk { + return u.Update(func(s *APIKeyUpsert) { + s.ClearIPBlacklist() + }) +} + // Exec executes the query. func (u *APIKeyUpsertBulk) Exec(ctx context.Context) error { if u.create.err != nil { diff --git a/backend/ent/apikey_query.go b/backend/ent/apikey_query.go index 6e5c0f5e..9eee4077 100644 --- a/backend/ent/apikey_query.go +++ b/backend/ent/apikey_query.go @@ -9,6 +9,7 @@ import ( "math" "entgo.io/ent" + "entgo.io/ent/dialect" "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" @@ -29,6 +30,7 @@ type APIKeyQuery struct { withUser *UserQuery withGroup *GroupQuery withUsageLogs *UsageLogQuery + modifiers []func(*sql.Selector) // intermediate query (i.e. traversal path). sql *sql.Selector path func(context.Context) (*sql.Selector, error) @@ -458,6 +460,9 @@ func (_q *APIKeyQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*APIKe 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) } @@ -583,6 +588,9 @@ func (_q *APIKeyQuery) loadUsageLogs(ctx context.Context, query *UsageLogQuery, func (_q *APIKeyQuery) 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 @@ -651,6 +659,9 @@ func (_q *APIKeyQuery) sqlQuery(ctx context.Context) *sql.Selector { if _q.ctx.Unique != nil && *_q.ctx.Unique { selector.Distinct() } + for _, m := range _q.modifiers { + m(selector) + } for _, p := range _q.predicates { p(selector) } @@ -668,6 +679,32 @@ func (_q *APIKeyQuery) sqlQuery(ctx context.Context) *sql.Selector { 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 *APIKeyQuery) ForUpdate(opts ...sql.LockOption) *APIKeyQuery { + 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 *APIKeyQuery) ForShare(opts ...sql.LockOption) *APIKeyQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForShare(opts...) + }) + return _q +} + // APIKeyGroupBy is the group-by builder for APIKey entities. type APIKeyGroupBy struct { selector diff --git a/backend/ent/apikey_update.go b/backend/ent/apikey_update.go index 4a16369b..9ae332a8 100644 --- a/backend/ent/apikey_update.go +++ b/backend/ent/apikey_update.go @@ -10,6 +10,7 @@ import ( "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/dialect/sql/sqljson" "entgo.io/ent/schema/field" "github.com/Wei-Shaw/sub2api/ent/apikey" "github.com/Wei-Shaw/sub2api/ent/group" @@ -133,6 +134,42 @@ func (_u *APIKeyUpdate) SetNillableStatus(v *string) *APIKeyUpdate { return _u } +// SetIPWhitelist sets the "ip_whitelist" field. +func (_u *APIKeyUpdate) SetIPWhitelist(v []string) *APIKeyUpdate { + _u.mutation.SetIPWhitelist(v) + return _u +} + +// AppendIPWhitelist appends value to the "ip_whitelist" field. +func (_u *APIKeyUpdate) AppendIPWhitelist(v []string) *APIKeyUpdate { + _u.mutation.AppendIPWhitelist(v) + return _u +} + +// ClearIPWhitelist clears the value of the "ip_whitelist" field. +func (_u *APIKeyUpdate) ClearIPWhitelist() *APIKeyUpdate { + _u.mutation.ClearIPWhitelist() + return _u +} + +// SetIPBlacklist sets the "ip_blacklist" field. +func (_u *APIKeyUpdate) SetIPBlacklist(v []string) *APIKeyUpdate { + _u.mutation.SetIPBlacklist(v) + return _u +} + +// AppendIPBlacklist appends value to the "ip_blacklist" field. +func (_u *APIKeyUpdate) AppendIPBlacklist(v []string) *APIKeyUpdate { + _u.mutation.AppendIPBlacklist(v) + return _u +} + +// ClearIPBlacklist clears the value of the "ip_blacklist" field. +func (_u *APIKeyUpdate) ClearIPBlacklist() *APIKeyUpdate { + _u.mutation.ClearIPBlacklist() + return _u +} + // SetUser sets the "user" edge to the User entity. func (_u *APIKeyUpdate) SetUser(v *User) *APIKeyUpdate { return _u.SetUserID(v.ID) @@ -291,6 +328,28 @@ func (_u *APIKeyUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.Status(); ok { _spec.SetField(apikey.FieldStatus, field.TypeString, value) } + if value, ok := _u.mutation.IPWhitelist(); ok { + _spec.SetField(apikey.FieldIPWhitelist, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedIPWhitelist(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, apikey.FieldIPWhitelist, value) + }) + } + if _u.mutation.IPWhitelistCleared() { + _spec.ClearField(apikey.FieldIPWhitelist, field.TypeJSON) + } + if value, ok := _u.mutation.IPBlacklist(); ok { + _spec.SetField(apikey.FieldIPBlacklist, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedIPBlacklist(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, apikey.FieldIPBlacklist, value) + }) + } + if _u.mutation.IPBlacklistCleared() { + _spec.ClearField(apikey.FieldIPBlacklist, field.TypeJSON) + } if _u.mutation.UserCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.M2O, @@ -516,6 +575,42 @@ func (_u *APIKeyUpdateOne) SetNillableStatus(v *string) *APIKeyUpdateOne { return _u } +// SetIPWhitelist sets the "ip_whitelist" field. +func (_u *APIKeyUpdateOne) SetIPWhitelist(v []string) *APIKeyUpdateOne { + _u.mutation.SetIPWhitelist(v) + return _u +} + +// AppendIPWhitelist appends value to the "ip_whitelist" field. +func (_u *APIKeyUpdateOne) AppendIPWhitelist(v []string) *APIKeyUpdateOne { + _u.mutation.AppendIPWhitelist(v) + return _u +} + +// ClearIPWhitelist clears the value of the "ip_whitelist" field. +func (_u *APIKeyUpdateOne) ClearIPWhitelist() *APIKeyUpdateOne { + _u.mutation.ClearIPWhitelist() + return _u +} + +// SetIPBlacklist sets the "ip_blacklist" field. +func (_u *APIKeyUpdateOne) SetIPBlacklist(v []string) *APIKeyUpdateOne { + _u.mutation.SetIPBlacklist(v) + return _u +} + +// AppendIPBlacklist appends value to the "ip_blacklist" field. +func (_u *APIKeyUpdateOne) AppendIPBlacklist(v []string) *APIKeyUpdateOne { + _u.mutation.AppendIPBlacklist(v) + return _u +} + +// ClearIPBlacklist clears the value of the "ip_blacklist" field. +func (_u *APIKeyUpdateOne) ClearIPBlacklist() *APIKeyUpdateOne { + _u.mutation.ClearIPBlacklist() + return _u +} + // SetUser sets the "user" edge to the User entity. func (_u *APIKeyUpdateOne) SetUser(v *User) *APIKeyUpdateOne { return _u.SetUserID(v.ID) @@ -704,6 +799,28 @@ func (_u *APIKeyUpdateOne) sqlSave(ctx context.Context) (_node *APIKey, err erro if value, ok := _u.mutation.Status(); ok { _spec.SetField(apikey.FieldStatus, field.TypeString, value) } + if value, ok := _u.mutation.IPWhitelist(); ok { + _spec.SetField(apikey.FieldIPWhitelist, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedIPWhitelist(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, apikey.FieldIPWhitelist, value) + }) + } + if _u.mutation.IPWhitelistCleared() { + _spec.ClearField(apikey.FieldIPWhitelist, field.TypeJSON) + } + if value, ok := _u.mutation.IPBlacklist(); ok { + _spec.SetField(apikey.FieldIPBlacklist, field.TypeJSON, value) + } + if value, ok := _u.mutation.AppendedIPBlacklist(); ok { + _spec.AddModifier(func(u *sql.UpdateBuilder) { + sqljson.Append(u, apikey.FieldIPBlacklist, value) + }) + } + if _u.mutation.IPBlacklistCleared() { + _spec.ClearField(apikey.FieldIPBlacklist, field.TypeJSON) + } if _u.mutation.UserCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.M2O, diff --git a/backend/ent/client.go b/backend/ent/client.go index 4084dac2..35cf644f 100644 --- a/backend/ent/client.go +++ b/backend/ent/client.go @@ -19,6 +19,8 @@ import ( "github.com/Wei-Shaw/sub2api/ent/accountgroup" "github.com/Wei-Shaw/sub2api/ent/apikey" "github.com/Wei-Shaw/sub2api/ent/group" + "github.com/Wei-Shaw/sub2api/ent/promocode" + "github.com/Wei-Shaw/sub2api/ent/promocodeusage" "github.com/Wei-Shaw/sub2api/ent/proxy" "github.com/Wei-Shaw/sub2api/ent/redeemcode" "github.com/Wei-Shaw/sub2api/ent/setting" @@ -45,6 +47,10 @@ type Client struct { AccountGroup *AccountGroupClient // Group is the client for interacting with the Group builders. Group *GroupClient + // PromoCode is the client for interacting with the PromoCode builders. + PromoCode *PromoCodeClient + // PromoCodeUsage is the client for interacting with the PromoCodeUsage builders. + PromoCodeUsage *PromoCodeUsageClient // Proxy is the client for interacting with the Proxy builders. Proxy *ProxyClient // RedeemCode is the client for interacting with the RedeemCode builders. @@ -78,6 +84,8 @@ func (c *Client) init() { c.Account = NewAccountClient(c.config) c.AccountGroup = NewAccountGroupClient(c.config) c.Group = NewGroupClient(c.config) + c.PromoCode = NewPromoCodeClient(c.config) + c.PromoCodeUsage = NewPromoCodeUsageClient(c.config) c.Proxy = NewProxyClient(c.config) c.RedeemCode = NewRedeemCodeClient(c.config) c.Setting = NewSettingClient(c.config) @@ -183,6 +191,8 @@ func (c *Client) Tx(ctx context.Context) (*Tx, error) { Account: NewAccountClient(cfg), AccountGroup: NewAccountGroupClient(cfg), Group: NewGroupClient(cfg), + PromoCode: NewPromoCodeClient(cfg), + PromoCodeUsage: NewPromoCodeUsageClient(cfg), Proxy: NewProxyClient(cfg), RedeemCode: NewRedeemCodeClient(cfg), Setting: NewSettingClient(cfg), @@ -215,6 +225,8 @@ func (c *Client) BeginTx(ctx context.Context, opts *sql.TxOptions) (*Tx, error) Account: NewAccountClient(cfg), AccountGroup: NewAccountGroupClient(cfg), Group: NewGroupClient(cfg), + PromoCode: NewPromoCodeClient(cfg), + PromoCodeUsage: NewPromoCodeUsageClient(cfg), Proxy: NewProxyClient(cfg), RedeemCode: NewRedeemCodeClient(cfg), Setting: NewSettingClient(cfg), @@ -253,9 +265,9 @@ 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.Proxy, c.RedeemCode, c.Setting, - c.UsageLog, c.User, c.UserAllowedGroup, c.UserAttributeDefinition, - c.UserAttributeValue, c.UserSubscription, + c.APIKey, c.Account, c.AccountGroup, c.Group, c.PromoCode, c.PromoCodeUsage, + c.Proxy, c.RedeemCode, c.Setting, c.UsageLog, c.User, c.UserAllowedGroup, + c.UserAttributeDefinition, c.UserAttributeValue, c.UserSubscription, } { n.Use(hooks...) } @@ -265,9 +277,9 @@ 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.Proxy, c.RedeemCode, c.Setting, - c.UsageLog, c.User, c.UserAllowedGroup, c.UserAttributeDefinition, - c.UserAttributeValue, c.UserSubscription, + c.APIKey, c.Account, c.AccountGroup, c.Group, c.PromoCode, c.PromoCodeUsage, + c.Proxy, c.RedeemCode, c.Setting, c.UsageLog, c.User, c.UserAllowedGroup, + c.UserAttributeDefinition, c.UserAttributeValue, c.UserSubscription, } { n.Intercept(interceptors...) } @@ -284,6 +296,10 @@ func (c *Client) Mutate(ctx context.Context, m Mutation) (Value, error) { return c.AccountGroup.mutate(ctx, m) case *GroupMutation: return c.Group.mutate(ctx, m) + case *PromoCodeMutation: + return c.PromoCode.mutate(ctx, m) + case *PromoCodeUsageMutation: + return c.PromoCodeUsage.mutate(ctx, m) case *ProxyMutation: return c.Proxy.mutate(ctx, m) case *RedeemCodeMutation: @@ -1068,6 +1084,320 @@ func (c *GroupClient) mutate(ctx context.Context, m *GroupMutation) (Value, erro } } +// PromoCodeClient is a client for the PromoCode schema. +type PromoCodeClient struct { + config +} + +// NewPromoCodeClient returns a client for the PromoCode from the given config. +func NewPromoCodeClient(c config) *PromoCodeClient { + return &PromoCodeClient{config: c} +} + +// Use adds a list of mutation hooks to the hooks stack. +// A call to `Use(f, g, h)` equals to `promocode.Hooks(f(g(h())))`. +func (c *PromoCodeClient) Use(hooks ...Hook) { + c.hooks.PromoCode = append(c.hooks.PromoCode, hooks...) +} + +// Intercept adds a list of query interceptors to the interceptors stack. +// A call to `Intercept(f, g, h)` equals to `promocode.Intercept(f(g(h())))`. +func (c *PromoCodeClient) Intercept(interceptors ...Interceptor) { + c.inters.PromoCode = append(c.inters.PromoCode, interceptors...) +} + +// Create returns a builder for creating a PromoCode entity. +func (c *PromoCodeClient) Create() *PromoCodeCreate { + mutation := newPromoCodeMutation(c.config, OpCreate) + return &PromoCodeCreate{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// CreateBulk returns a builder for creating a bulk of PromoCode entities. +func (c *PromoCodeClient) CreateBulk(builders ...*PromoCodeCreate) *PromoCodeCreateBulk { + return &PromoCodeCreateBulk{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 *PromoCodeClient) MapCreateBulk(slice any, setFunc func(*PromoCodeCreate, int)) *PromoCodeCreateBulk { + rv := reflect.ValueOf(slice) + if rv.Kind() != reflect.Slice { + return &PromoCodeCreateBulk{err: fmt.Errorf("calling to PromoCodeClient.MapCreateBulk with wrong type %T, need slice", slice)} + } + builders := make([]*PromoCodeCreate, rv.Len()) + for i := 0; i < rv.Len(); i++ { + builders[i] = c.Create() + setFunc(builders[i], i) + } + return &PromoCodeCreateBulk{config: c.config, builders: builders} +} + +// Update returns an update builder for PromoCode. +func (c *PromoCodeClient) Update() *PromoCodeUpdate { + mutation := newPromoCodeMutation(c.config, OpUpdate) + return &PromoCodeUpdate{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// UpdateOne returns an update builder for the given entity. +func (c *PromoCodeClient) UpdateOne(_m *PromoCode) *PromoCodeUpdateOne { + mutation := newPromoCodeMutation(c.config, OpUpdateOne, withPromoCode(_m)) + return &PromoCodeUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// UpdateOneID returns an update builder for the given id. +func (c *PromoCodeClient) UpdateOneID(id int64) *PromoCodeUpdateOne { + mutation := newPromoCodeMutation(c.config, OpUpdateOne, withPromoCodeID(id)) + return &PromoCodeUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// Delete returns a delete builder for PromoCode. +func (c *PromoCodeClient) Delete() *PromoCodeDelete { + mutation := newPromoCodeMutation(c.config, OpDelete) + return &PromoCodeDelete{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// DeleteOne returns a builder for deleting the given entity. +func (c *PromoCodeClient) DeleteOne(_m *PromoCode) *PromoCodeDeleteOne { + return c.DeleteOneID(_m.ID) +} + +// DeleteOneID returns a builder for deleting the given entity by its id. +func (c *PromoCodeClient) DeleteOneID(id int64) *PromoCodeDeleteOne { + builder := c.Delete().Where(promocode.ID(id)) + builder.mutation.id = &id + builder.mutation.op = OpDeleteOne + return &PromoCodeDeleteOne{builder} +} + +// Query returns a query builder for PromoCode. +func (c *PromoCodeClient) Query() *PromoCodeQuery { + return &PromoCodeQuery{ + config: c.config, + ctx: &QueryContext{Type: TypePromoCode}, + inters: c.Interceptors(), + } +} + +// Get returns a PromoCode entity by its id. +func (c *PromoCodeClient) Get(ctx context.Context, id int64) (*PromoCode, error) { + return c.Query().Where(promocode.ID(id)).Only(ctx) +} + +// GetX is like Get, but panics if an error occurs. +func (c *PromoCodeClient) GetX(ctx context.Context, id int64) *PromoCode { + obj, err := c.Get(ctx, id) + if err != nil { + panic(err) + } + return obj +} + +// QueryUsageRecords queries the usage_records edge of a PromoCode. +func (c *PromoCodeClient) QueryUsageRecords(_m *PromoCode) *PromoCodeUsageQuery { + query := (&PromoCodeUsageClient{config: c.config}).Query() + query.path = func(context.Context) (fromV *sql.Selector, _ error) { + id := _m.ID + step := sqlgraph.NewStep( + sqlgraph.From(promocode.Table, promocode.FieldID, id), + sqlgraph.To(promocodeusage.Table, promocodeusage.FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, promocode.UsageRecordsTable, promocode.UsageRecordsColumn), + ) + fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) + return fromV, nil + } + return query +} + +// Hooks returns the client hooks. +func (c *PromoCodeClient) Hooks() []Hook { + return c.hooks.PromoCode +} + +// Interceptors returns the client interceptors. +func (c *PromoCodeClient) Interceptors() []Interceptor { + return c.inters.PromoCode +} + +func (c *PromoCodeClient) mutate(ctx context.Context, m *PromoCodeMutation) (Value, error) { + switch m.Op() { + case OpCreate: + return (&PromoCodeCreate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpUpdate: + return (&PromoCodeUpdate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpUpdateOne: + return (&PromoCodeUpdateOne{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpDelete, OpDeleteOne: + return (&PromoCodeDelete{config: c.config, hooks: c.Hooks(), mutation: m}).Exec(ctx) + default: + return nil, fmt.Errorf("ent: unknown PromoCode mutation op: %q", m.Op()) + } +} + +// PromoCodeUsageClient is a client for the PromoCodeUsage schema. +type PromoCodeUsageClient struct { + config +} + +// NewPromoCodeUsageClient returns a client for the PromoCodeUsage from the given config. +func NewPromoCodeUsageClient(c config) *PromoCodeUsageClient { + return &PromoCodeUsageClient{config: c} +} + +// Use adds a list of mutation hooks to the hooks stack. +// A call to `Use(f, g, h)` equals to `promocodeusage.Hooks(f(g(h())))`. +func (c *PromoCodeUsageClient) Use(hooks ...Hook) { + c.hooks.PromoCodeUsage = append(c.hooks.PromoCodeUsage, hooks...) +} + +// Intercept adds a list of query interceptors to the interceptors stack. +// A call to `Intercept(f, g, h)` equals to `promocodeusage.Intercept(f(g(h())))`. +func (c *PromoCodeUsageClient) Intercept(interceptors ...Interceptor) { + c.inters.PromoCodeUsage = append(c.inters.PromoCodeUsage, interceptors...) +} + +// Create returns a builder for creating a PromoCodeUsage entity. +func (c *PromoCodeUsageClient) Create() *PromoCodeUsageCreate { + mutation := newPromoCodeUsageMutation(c.config, OpCreate) + return &PromoCodeUsageCreate{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// CreateBulk returns a builder for creating a bulk of PromoCodeUsage entities. +func (c *PromoCodeUsageClient) CreateBulk(builders ...*PromoCodeUsageCreate) *PromoCodeUsageCreateBulk { + return &PromoCodeUsageCreateBulk{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 *PromoCodeUsageClient) MapCreateBulk(slice any, setFunc func(*PromoCodeUsageCreate, int)) *PromoCodeUsageCreateBulk { + rv := reflect.ValueOf(slice) + if rv.Kind() != reflect.Slice { + return &PromoCodeUsageCreateBulk{err: fmt.Errorf("calling to PromoCodeUsageClient.MapCreateBulk with wrong type %T, need slice", slice)} + } + builders := make([]*PromoCodeUsageCreate, rv.Len()) + for i := 0; i < rv.Len(); i++ { + builders[i] = c.Create() + setFunc(builders[i], i) + } + return &PromoCodeUsageCreateBulk{config: c.config, builders: builders} +} + +// Update returns an update builder for PromoCodeUsage. +func (c *PromoCodeUsageClient) Update() *PromoCodeUsageUpdate { + mutation := newPromoCodeUsageMutation(c.config, OpUpdate) + return &PromoCodeUsageUpdate{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// UpdateOne returns an update builder for the given entity. +func (c *PromoCodeUsageClient) UpdateOne(_m *PromoCodeUsage) *PromoCodeUsageUpdateOne { + mutation := newPromoCodeUsageMutation(c.config, OpUpdateOne, withPromoCodeUsage(_m)) + return &PromoCodeUsageUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// UpdateOneID returns an update builder for the given id. +func (c *PromoCodeUsageClient) UpdateOneID(id int64) *PromoCodeUsageUpdateOne { + mutation := newPromoCodeUsageMutation(c.config, OpUpdateOne, withPromoCodeUsageID(id)) + return &PromoCodeUsageUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// Delete returns a delete builder for PromoCodeUsage. +func (c *PromoCodeUsageClient) Delete() *PromoCodeUsageDelete { + mutation := newPromoCodeUsageMutation(c.config, OpDelete) + return &PromoCodeUsageDelete{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// DeleteOne returns a builder for deleting the given entity. +func (c *PromoCodeUsageClient) DeleteOne(_m *PromoCodeUsage) *PromoCodeUsageDeleteOne { + return c.DeleteOneID(_m.ID) +} + +// DeleteOneID returns a builder for deleting the given entity by its id. +func (c *PromoCodeUsageClient) DeleteOneID(id int64) *PromoCodeUsageDeleteOne { + builder := c.Delete().Where(promocodeusage.ID(id)) + builder.mutation.id = &id + builder.mutation.op = OpDeleteOne + return &PromoCodeUsageDeleteOne{builder} +} + +// Query returns a query builder for PromoCodeUsage. +func (c *PromoCodeUsageClient) Query() *PromoCodeUsageQuery { + return &PromoCodeUsageQuery{ + config: c.config, + ctx: &QueryContext{Type: TypePromoCodeUsage}, + inters: c.Interceptors(), + } +} + +// Get returns a PromoCodeUsage entity by its id. +func (c *PromoCodeUsageClient) Get(ctx context.Context, id int64) (*PromoCodeUsage, error) { + return c.Query().Where(promocodeusage.ID(id)).Only(ctx) +} + +// GetX is like Get, but panics if an error occurs. +func (c *PromoCodeUsageClient) GetX(ctx context.Context, id int64) *PromoCodeUsage { + obj, err := c.Get(ctx, id) + if err != nil { + panic(err) + } + return obj +} + +// QueryPromoCode queries the promo_code edge of a PromoCodeUsage. +func (c *PromoCodeUsageClient) QueryPromoCode(_m *PromoCodeUsage) *PromoCodeQuery { + query := (&PromoCodeClient{config: c.config}).Query() + query.path = func(context.Context) (fromV *sql.Selector, _ error) { + id := _m.ID + step := sqlgraph.NewStep( + sqlgraph.From(promocodeusage.Table, promocodeusage.FieldID, id), + sqlgraph.To(promocode.Table, promocode.FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, promocodeusage.PromoCodeTable, promocodeusage.PromoCodeColumn), + ) + fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) + return fromV, nil + } + return query +} + +// QueryUser queries the user edge of a PromoCodeUsage. +func (c *PromoCodeUsageClient) QueryUser(_m *PromoCodeUsage) *UserQuery { + query := (&UserClient{config: c.config}).Query() + query.path = func(context.Context) (fromV *sql.Selector, _ error) { + id := _m.ID + step := sqlgraph.NewStep( + sqlgraph.From(promocodeusage.Table, promocodeusage.FieldID, id), + sqlgraph.To(user.Table, user.FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, promocodeusage.UserTable, promocodeusage.UserColumn), + ) + fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) + return fromV, nil + } + return query +} + +// Hooks returns the client hooks. +func (c *PromoCodeUsageClient) Hooks() []Hook { + return c.hooks.PromoCodeUsage +} + +// Interceptors returns the client interceptors. +func (c *PromoCodeUsageClient) Interceptors() []Interceptor { + return c.inters.PromoCodeUsage +} + +func (c *PromoCodeUsageClient) mutate(ctx context.Context, m *PromoCodeUsageMutation) (Value, error) { + switch m.Op() { + case OpCreate: + return (&PromoCodeUsageCreate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpUpdate: + return (&PromoCodeUsageUpdate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpUpdateOne: + return (&PromoCodeUsageUpdateOne{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpDelete, OpDeleteOne: + return (&PromoCodeUsageDelete{config: c.config, hooks: c.Hooks(), mutation: m}).Exec(ctx) + default: + return nil, fmt.Errorf("ent: unknown PromoCodeUsage mutation op: %q", m.Op()) + } +} + // ProxyClient is a client for the Proxy schema. type ProxyClient struct { config @@ -1950,6 +2280,22 @@ func (c *UserClient) QueryAttributeValues(_m *User) *UserAttributeValueQuery { return query } +// QueryPromoCodeUsages queries the promo_code_usages edge of a User. +func (c *UserClient) QueryPromoCodeUsages(_m *User) *PromoCodeUsageQuery { + query := (&PromoCodeUsageClient{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(promocodeusage.Table, promocodeusage.FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, user.PromoCodeUsagesTable, user.PromoCodeUsagesColumn), + ) + fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) + return fromV, nil + } + return query +} + // QueryUserAllowedGroups queries the user_allowed_groups edge of a User. func (c *UserClient) QueryUserAllowedGroups(_m *User) *UserAllowedGroupQuery { query := (&UserAllowedGroupClient{config: c.config}).Query() @@ -2627,14 +2973,14 @@ func (c *UserSubscriptionClient) mutate(ctx context.Context, m *UserSubscription // hooks and interceptors per client, for fast access. type ( hooks struct { - APIKey, Account, AccountGroup, Group, Proxy, RedeemCode, Setting, UsageLog, - User, UserAllowedGroup, UserAttributeDefinition, UserAttributeValue, - UserSubscription []ent.Hook + APIKey, Account, AccountGroup, Group, PromoCode, PromoCodeUsage, Proxy, + RedeemCode, Setting, UsageLog, User, UserAllowedGroup, UserAttributeDefinition, + UserAttributeValue, UserSubscription []ent.Hook } inters struct { - APIKey, Account, AccountGroup, Group, Proxy, RedeemCode, Setting, UsageLog, - User, UserAllowedGroup, UserAttributeDefinition, UserAttributeValue, - UserSubscription []ent.Interceptor + APIKey, Account, AccountGroup, Group, PromoCode, PromoCodeUsage, Proxy, + RedeemCode, Setting, UsageLog, User, UserAllowedGroup, UserAttributeDefinition, + UserAttributeValue, UserSubscription []ent.Interceptor } ) diff --git a/backend/ent/ent.go b/backend/ent/ent.go index 670ea0b2..410375a7 100644 --- a/backend/ent/ent.go +++ b/backend/ent/ent.go @@ -16,6 +16,8 @@ import ( "github.com/Wei-Shaw/sub2api/ent/accountgroup" "github.com/Wei-Shaw/sub2api/ent/apikey" "github.com/Wei-Shaw/sub2api/ent/group" + "github.com/Wei-Shaw/sub2api/ent/promocode" + "github.com/Wei-Shaw/sub2api/ent/promocodeusage" "github.com/Wei-Shaw/sub2api/ent/proxy" "github.com/Wei-Shaw/sub2api/ent/redeemcode" "github.com/Wei-Shaw/sub2api/ent/setting" @@ -89,6 +91,8 @@ func checkColumn(t, c string) error { account.Table: account.ValidColumn, accountgroup.Table: accountgroup.ValidColumn, group.Table: group.ValidColumn, + promocode.Table: promocode.ValidColumn, + promocodeusage.Table: promocodeusage.ValidColumn, proxy.Table: proxy.ValidColumn, redeemcode.Table: redeemcode.ValidColumn, setting.Table: setting.ValidColumn, diff --git a/backend/ent/generate.go b/backend/ent/generate.go index 22ab4a78..59843cec 100644 --- a/backend/ent/generate.go +++ b/backend/ent/generate.go @@ -2,4 +2,5 @@ package ent // 启用 sql/execquery 以生成 ExecContext/QueryContext 的透传接口,便于事务内执行原生 SQL。 -//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature sql/upsert,intercept,sql/execquery --idtype int64 ./schema +// 启用 sql/lock 以支持 FOR UPDATE 行锁。 +//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature sql/upsert,intercept,sql/execquery,sql/lock --idtype int64 ./schema diff --git a/backend/ent/group_query.go b/backend/ent/group_query.go index 3cc976cb..d4cc4f8d 100644 --- a/backend/ent/group_query.go +++ b/backend/ent/group_query.go @@ -9,6 +9,7 @@ import ( "math" "entgo.io/ent" + "entgo.io/ent/dialect" "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" @@ -39,6 +40,7 @@ type GroupQuery struct { withAllowedUsers *UserQuery withAccountGroups *AccountGroupQuery withUserAllowedGroups *UserAllowedGroupQuery + modifiers []func(*sql.Selector) // intermediate query (i.e. traversal path). sql *sql.Selector path func(context.Context) (*sql.Selector, error) @@ -643,6 +645,9 @@ func (_q *GroupQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Group, 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) } @@ -1025,6 +1030,9 @@ func (_q *GroupQuery) loadUserAllowedGroups(ctx context.Context, query *UserAllo func (_q *GroupQuery) 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 @@ -1087,6 +1095,9 @@ func (_q *GroupQuery) sqlQuery(ctx context.Context) *sql.Selector { if _q.ctx.Unique != nil && *_q.ctx.Unique { selector.Distinct() } + for _, m := range _q.modifiers { + m(selector) + } for _, p := range _q.predicates { p(selector) } @@ -1104,6 +1115,32 @@ func (_q *GroupQuery) sqlQuery(ctx context.Context) *sql.Selector { 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 *GroupQuery) ForUpdate(opts ...sql.LockOption) *GroupQuery { + 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 *GroupQuery) ForShare(opts ...sql.LockOption) *GroupQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForShare(opts...) + }) + return _q +} + // GroupGroupBy is the group-by builder for Group entities. type GroupGroupBy struct { selector diff --git a/backend/ent/hook/hook.go b/backend/ent/hook/hook.go index e82b00f9..532b0d2c 100644 --- a/backend/ent/hook/hook.go +++ b/backend/ent/hook/hook.go @@ -57,6 +57,30 @@ func (f GroupFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.GroupMutation", m) } +// The PromoCodeFunc type is an adapter to allow the use of ordinary +// function as PromoCode mutator. +type PromoCodeFunc func(context.Context, *ent.PromoCodeMutation) (ent.Value, error) + +// Mutate calls f(ctx, m). +func (f PromoCodeFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) { + if mv, ok := m.(*ent.PromoCodeMutation); ok { + return f(ctx, mv) + } + return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.PromoCodeMutation", m) +} + +// The PromoCodeUsageFunc type is an adapter to allow the use of ordinary +// function as PromoCodeUsage mutator. +type PromoCodeUsageFunc func(context.Context, *ent.PromoCodeUsageMutation) (ent.Value, error) + +// Mutate calls f(ctx, m). +func (f PromoCodeUsageFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) { + if mv, ok := m.(*ent.PromoCodeUsageMutation); ok { + return f(ctx, mv) + } + return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.PromoCodeUsageMutation", m) +} + // The ProxyFunc type is an adapter to allow the use of ordinary // function as Proxy mutator. type ProxyFunc func(context.Context, *ent.ProxyMutation) (ent.Value, error) diff --git a/backend/ent/intercept/intercept.go b/backend/ent/intercept/intercept.go index 6add6fed..765d39b4 100644 --- a/backend/ent/intercept/intercept.go +++ b/backend/ent/intercept/intercept.go @@ -13,6 +13,8 @@ import ( "github.com/Wei-Shaw/sub2api/ent/apikey" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/predicate" + "github.com/Wei-Shaw/sub2api/ent/promocode" + "github.com/Wei-Shaw/sub2api/ent/promocodeusage" "github.com/Wei-Shaw/sub2api/ent/proxy" "github.com/Wei-Shaw/sub2api/ent/redeemcode" "github.com/Wei-Shaw/sub2api/ent/setting" @@ -188,6 +190,60 @@ func (f TraverseGroup) Traverse(ctx context.Context, q ent.Query) error { return fmt.Errorf("unexpected query type %T. expect *ent.GroupQuery", q) } +// The PromoCodeFunc type is an adapter to allow the use of ordinary function as a Querier. +type PromoCodeFunc func(context.Context, *ent.PromoCodeQuery) (ent.Value, error) + +// Query calls f(ctx, q). +func (f PromoCodeFunc) Query(ctx context.Context, q ent.Query) (ent.Value, error) { + if q, ok := q.(*ent.PromoCodeQuery); ok { + return f(ctx, q) + } + return nil, fmt.Errorf("unexpected query type %T. expect *ent.PromoCodeQuery", q) +} + +// The TraversePromoCode type is an adapter to allow the use of ordinary function as Traverser. +type TraversePromoCode func(context.Context, *ent.PromoCodeQuery) error + +// Intercept is a dummy implementation of Intercept that returns the next Querier in the pipeline. +func (f TraversePromoCode) Intercept(next ent.Querier) ent.Querier { + return next +} + +// Traverse calls f(ctx, q). +func (f TraversePromoCode) Traverse(ctx context.Context, q ent.Query) error { + if q, ok := q.(*ent.PromoCodeQuery); ok { + return f(ctx, q) + } + return fmt.Errorf("unexpected query type %T. expect *ent.PromoCodeQuery", q) +} + +// The PromoCodeUsageFunc type is an adapter to allow the use of ordinary function as a Querier. +type PromoCodeUsageFunc func(context.Context, *ent.PromoCodeUsageQuery) (ent.Value, error) + +// Query calls f(ctx, q). +func (f PromoCodeUsageFunc) Query(ctx context.Context, q ent.Query) (ent.Value, error) { + if q, ok := q.(*ent.PromoCodeUsageQuery); ok { + return f(ctx, q) + } + return nil, fmt.Errorf("unexpected query type %T. expect *ent.PromoCodeUsageQuery", q) +} + +// The TraversePromoCodeUsage type is an adapter to allow the use of ordinary function as Traverser. +type TraversePromoCodeUsage func(context.Context, *ent.PromoCodeUsageQuery) error + +// Intercept is a dummy implementation of Intercept that returns the next Querier in the pipeline. +func (f TraversePromoCodeUsage) Intercept(next ent.Querier) ent.Querier { + return next +} + +// Traverse calls f(ctx, q). +func (f TraversePromoCodeUsage) Traverse(ctx context.Context, q ent.Query) error { + if q, ok := q.(*ent.PromoCodeUsageQuery); ok { + return f(ctx, q) + } + return fmt.Errorf("unexpected query type %T. expect *ent.PromoCodeUsageQuery", q) +} + // The ProxyFunc type is an adapter to allow the use of ordinary function as a Querier. type ProxyFunc func(context.Context, *ent.ProxyQuery) (ent.Value, error) @@ -442,6 +498,10 @@ func NewQuery(q ent.Query) (Query, error) { return &query[*ent.AccountGroupQuery, predicate.AccountGroup, accountgroup.OrderOption]{typ: ent.TypeAccountGroup, tq: q}, nil case *ent.GroupQuery: return &query[*ent.GroupQuery, predicate.Group, group.OrderOption]{typ: ent.TypeGroup, tq: q}, nil + case *ent.PromoCodeQuery: + return &query[*ent.PromoCodeQuery, predicate.PromoCode, promocode.OrderOption]{typ: ent.TypePromoCode, tq: q}, nil + case *ent.PromoCodeUsageQuery: + return &query[*ent.PromoCodeUsageQuery, predicate.PromoCodeUsage, promocodeusage.OrderOption]{typ: ent.TypePromoCodeUsage, tq: q}, nil case *ent.ProxyQuery: return &query[*ent.ProxyQuery, predicate.Proxy, proxy.OrderOption]{typ: ent.TypeProxy, tq: q}, nil case *ent.RedeemCodeQuery: diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 13081e31..41cd8b01 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -18,6 +18,8 @@ var ( {Name: "key", Type: field.TypeString, Unique: true, Size: 128}, {Name: "name", Type: field.TypeString, Size: 100}, {Name: "status", Type: field.TypeString, Size: 20, Default: "active"}, + {Name: "ip_whitelist", Type: field.TypeJSON, Nullable: true}, + {Name: "ip_blacklist", Type: field.TypeJSON, Nullable: true}, {Name: "group_id", Type: field.TypeInt64, Nullable: true}, {Name: "user_id", Type: field.TypeInt64}, } @@ -29,13 +31,13 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "api_keys_groups_api_keys", - Columns: []*schema.Column{APIKeysColumns[7]}, + Columns: []*schema.Column{APIKeysColumns[9]}, RefColumns: []*schema.Column{GroupsColumns[0]}, OnDelete: schema.SetNull, }, { Symbol: "api_keys_users_api_keys", - Columns: []*schema.Column{APIKeysColumns[8]}, + Columns: []*schema.Column{APIKeysColumns[10]}, RefColumns: []*schema.Column{UsersColumns[0]}, OnDelete: schema.NoAction, }, @@ -44,12 +46,12 @@ var ( { Name: "apikey_user_id", Unique: false, - Columns: []*schema.Column{APIKeysColumns[8]}, + Columns: []*schema.Column{APIKeysColumns[10]}, }, { Name: "apikey_group_id", Unique: false, - Columns: []*schema.Column{APIKeysColumns[7]}, + Columns: []*schema.Column{APIKeysColumns[9]}, }, { Name: "apikey_status", @@ -257,6 +259,82 @@ var ( }, }, } + // PromoCodesColumns holds the columns for the "promo_codes" table. + PromoCodesColumns = []*schema.Column{ + {Name: "id", Type: field.TypeInt64, Increment: true}, + {Name: "code", Type: field.TypeString, Unique: true, Size: 32}, + {Name: "bonus_amount", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, + {Name: "max_uses", Type: field.TypeInt, Default: 0}, + {Name: "used_count", Type: field.TypeInt, Default: 0}, + {Name: "status", Type: field.TypeString, Size: 20, Default: "active"}, + {Name: "expires_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, + {Name: "notes", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}}, + {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, + {Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, + } + // PromoCodesTable holds the schema information for the "promo_codes" table. + PromoCodesTable = &schema.Table{ + Name: "promo_codes", + Columns: PromoCodesColumns, + PrimaryKey: []*schema.Column{PromoCodesColumns[0]}, + Indexes: []*schema.Index{ + { + Name: "promocode_status", + Unique: false, + Columns: []*schema.Column{PromoCodesColumns[5]}, + }, + { + Name: "promocode_expires_at", + Unique: false, + Columns: []*schema.Column{PromoCodesColumns[6]}, + }, + }, + } + // PromoCodeUsagesColumns holds the columns for the "promo_code_usages" table. + PromoCodeUsagesColumns = []*schema.Column{ + {Name: "id", Type: field.TypeInt64, Increment: true}, + {Name: "bonus_amount", Type: field.TypeFloat64, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, + {Name: "used_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, + {Name: "promo_code_id", Type: field.TypeInt64}, + {Name: "user_id", Type: field.TypeInt64}, + } + // PromoCodeUsagesTable holds the schema information for the "promo_code_usages" table. + PromoCodeUsagesTable = &schema.Table{ + Name: "promo_code_usages", + Columns: PromoCodeUsagesColumns, + PrimaryKey: []*schema.Column{PromoCodeUsagesColumns[0]}, + ForeignKeys: []*schema.ForeignKey{ + { + Symbol: "promo_code_usages_promo_codes_usage_records", + Columns: []*schema.Column{PromoCodeUsagesColumns[3]}, + RefColumns: []*schema.Column{PromoCodesColumns[0]}, + OnDelete: schema.NoAction, + }, + { + Symbol: "promo_code_usages_users_promo_code_usages", + Columns: []*schema.Column{PromoCodeUsagesColumns[4]}, + RefColumns: []*schema.Column{UsersColumns[0]}, + OnDelete: schema.NoAction, + }, + }, + Indexes: []*schema.Index{ + { + Name: "promocodeusage_promo_code_id", + Unique: false, + Columns: []*schema.Column{PromoCodeUsagesColumns[3]}, + }, + { + Name: "promocodeusage_user_id", + Unique: false, + Columns: []*schema.Column{PromoCodeUsagesColumns[4]}, + }, + { + Name: "promocodeusage_promo_code_id_user_id", + Unique: true, + Columns: []*schema.Column{PromoCodeUsagesColumns[3], PromoCodeUsagesColumns[4]}, + }, + }, + } // ProxiesColumns holds the columns for the "proxies" table. ProxiesColumns = []*schema.Column{ {Name: "id", Type: field.TypeInt64, Increment: true}, @@ -376,6 +454,7 @@ var ( {Name: "duration_ms", Type: field.TypeInt, Nullable: true}, {Name: "first_token_ms", Type: field.TypeInt, Nullable: true}, {Name: "user_agent", Type: field.TypeString, Nullable: true, Size: 512}, + {Name: "ip_address", Type: field.TypeString, Nullable: true, Size: 45}, {Name: "image_count", Type: field.TypeInt, Default: 0}, {Name: "image_size", Type: field.TypeString, Nullable: true, Size: 10}, {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, @@ -393,31 +472,31 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "usage_logs_api_keys_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[24]}, + Columns: []*schema.Column{UsageLogsColumns[25]}, RefColumns: []*schema.Column{APIKeysColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_accounts_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[25]}, + Columns: []*schema.Column{UsageLogsColumns[26]}, RefColumns: []*schema.Column{AccountsColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_groups_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[26]}, + Columns: []*schema.Column{UsageLogsColumns[27]}, RefColumns: []*schema.Column{GroupsColumns[0]}, OnDelete: schema.SetNull, }, { Symbol: "usage_logs_users_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[27]}, + Columns: []*schema.Column{UsageLogsColumns[28]}, RefColumns: []*schema.Column{UsersColumns[0]}, OnDelete: schema.NoAction, }, { Symbol: "usage_logs_user_subscriptions_usage_logs", - Columns: []*schema.Column{UsageLogsColumns[28]}, + Columns: []*schema.Column{UsageLogsColumns[29]}, RefColumns: []*schema.Column{UserSubscriptionsColumns[0]}, OnDelete: schema.SetNull, }, @@ -426,32 +505,32 @@ var ( { Name: "usagelog_user_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[27]}, + Columns: []*schema.Column{UsageLogsColumns[28]}, }, { Name: "usagelog_api_key_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[24]}, + Columns: []*schema.Column{UsageLogsColumns[25]}, }, { Name: "usagelog_account_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[25]}, + Columns: []*schema.Column{UsageLogsColumns[26]}, }, { Name: "usagelog_group_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[26]}, + Columns: []*schema.Column{UsageLogsColumns[27]}, }, { Name: "usagelog_subscription_id", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[28]}, + Columns: []*schema.Column{UsageLogsColumns[29]}, }, { Name: "usagelog_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[23]}, + Columns: []*schema.Column{UsageLogsColumns[24]}, }, { Name: "usagelog_model", @@ -466,12 +545,12 @@ var ( { Name: "usagelog_user_id_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[27], UsageLogsColumns[23]}, + Columns: []*schema.Column{UsageLogsColumns[28], UsageLogsColumns[24]}, }, { Name: "usagelog_api_key_id_created_at", Unique: false, - Columns: []*schema.Column{UsageLogsColumns[24], UsageLogsColumns[23]}, + Columns: []*schema.Column{UsageLogsColumns[25], UsageLogsColumns[24]}, }, }, } @@ -717,6 +796,8 @@ var ( AccountsTable, AccountGroupsTable, GroupsTable, + PromoCodesTable, + PromoCodeUsagesTable, ProxiesTable, RedeemCodesTable, SettingsTable, @@ -747,6 +828,14 @@ func init() { GroupsTable.Annotation = &entsql.Annotation{ Table: "groups", } + PromoCodesTable.Annotation = &entsql.Annotation{ + Table: "promo_codes", + } + PromoCodeUsagesTable.ForeignKeys[0].RefTable = PromoCodesTable + PromoCodeUsagesTable.ForeignKeys[1].RefTable = UsersTable + PromoCodeUsagesTable.Annotation = &entsql.Annotation{ + Table: "promo_code_usages", + } ProxiesTable.Annotation = &entsql.Annotation{ Table: "proxies", } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 4e01e12b..732abd1c 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -16,6 +16,8 @@ import ( "github.com/Wei-Shaw/sub2api/ent/apikey" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/predicate" + "github.com/Wei-Shaw/sub2api/ent/promocode" + "github.com/Wei-Shaw/sub2api/ent/promocodeusage" "github.com/Wei-Shaw/sub2api/ent/proxy" "github.com/Wei-Shaw/sub2api/ent/redeemcode" "github.com/Wei-Shaw/sub2api/ent/setting" @@ -40,6 +42,8 @@ const ( TypeAccount = "Account" TypeAccountGroup = "AccountGroup" TypeGroup = "Group" + TypePromoCode = "PromoCode" + TypePromoCodeUsage = "PromoCodeUsage" TypeProxy = "Proxy" TypeRedeemCode = "RedeemCode" TypeSetting = "Setting" @@ -54,26 +58,30 @@ const ( // APIKeyMutation represents an operation that mutates the APIKey nodes in the graph. type APIKeyMutation struct { config - op Op - typ string - id *int64 - created_at *time.Time - updated_at *time.Time - deleted_at *time.Time - key *string - name *string - status *string - clearedFields map[string]struct{} - user *int64 - cleareduser bool - group *int64 - clearedgroup bool - usage_logs map[int64]struct{} - removedusage_logs map[int64]struct{} - clearedusage_logs bool - done bool - oldValue func(context.Context) (*APIKey, error) - predicates []predicate.APIKey + op Op + typ string + id *int64 + created_at *time.Time + updated_at *time.Time + deleted_at *time.Time + key *string + name *string + status *string + ip_whitelist *[]string + appendip_whitelist []string + ip_blacklist *[]string + appendip_blacklist []string + clearedFields map[string]struct{} + user *int64 + cleareduser bool + group *int64 + clearedgroup bool + usage_logs map[int64]struct{} + removedusage_logs map[int64]struct{} + clearedusage_logs bool + done bool + oldValue func(context.Context) (*APIKey, error) + predicates []predicate.APIKey } var _ ent.Mutation = (*APIKeyMutation)(nil) @@ -488,6 +496,136 @@ func (m *APIKeyMutation) ResetStatus() { m.status = nil } +// SetIPWhitelist sets the "ip_whitelist" field. +func (m *APIKeyMutation) SetIPWhitelist(s []string) { + m.ip_whitelist = &s + m.appendip_whitelist = nil +} + +// IPWhitelist returns the value of the "ip_whitelist" field in the mutation. +func (m *APIKeyMutation) IPWhitelist() (r []string, exists bool) { + v := m.ip_whitelist + if v == nil { + return + } + return *v, true +} + +// OldIPWhitelist returns the old "ip_whitelist" field's value of the APIKey entity. +// If the APIKey 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 *APIKeyMutation) OldIPWhitelist(ctx context.Context) (v []string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldIPWhitelist is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldIPWhitelist requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldIPWhitelist: %w", err) + } + return oldValue.IPWhitelist, nil +} + +// AppendIPWhitelist adds s to the "ip_whitelist" field. +func (m *APIKeyMutation) AppendIPWhitelist(s []string) { + m.appendip_whitelist = append(m.appendip_whitelist, s...) +} + +// AppendedIPWhitelist returns the list of values that were appended to the "ip_whitelist" field in this mutation. +func (m *APIKeyMutation) AppendedIPWhitelist() ([]string, bool) { + if len(m.appendip_whitelist) == 0 { + return nil, false + } + return m.appendip_whitelist, true +} + +// ClearIPWhitelist clears the value of the "ip_whitelist" field. +func (m *APIKeyMutation) ClearIPWhitelist() { + m.ip_whitelist = nil + m.appendip_whitelist = nil + m.clearedFields[apikey.FieldIPWhitelist] = struct{}{} +} + +// IPWhitelistCleared returns if the "ip_whitelist" field was cleared in this mutation. +func (m *APIKeyMutation) IPWhitelistCleared() bool { + _, ok := m.clearedFields[apikey.FieldIPWhitelist] + return ok +} + +// ResetIPWhitelist resets all changes to the "ip_whitelist" field. +func (m *APIKeyMutation) ResetIPWhitelist() { + m.ip_whitelist = nil + m.appendip_whitelist = nil + delete(m.clearedFields, apikey.FieldIPWhitelist) +} + +// SetIPBlacklist sets the "ip_blacklist" field. +func (m *APIKeyMutation) SetIPBlacklist(s []string) { + m.ip_blacklist = &s + m.appendip_blacklist = nil +} + +// IPBlacklist returns the value of the "ip_blacklist" field in the mutation. +func (m *APIKeyMutation) IPBlacklist() (r []string, exists bool) { + v := m.ip_blacklist + if v == nil { + return + } + return *v, true +} + +// OldIPBlacklist returns the old "ip_blacklist" field's value of the APIKey entity. +// If the APIKey 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 *APIKeyMutation) OldIPBlacklist(ctx context.Context) (v []string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldIPBlacklist is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldIPBlacklist requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldIPBlacklist: %w", err) + } + return oldValue.IPBlacklist, nil +} + +// AppendIPBlacklist adds s to the "ip_blacklist" field. +func (m *APIKeyMutation) AppendIPBlacklist(s []string) { + m.appendip_blacklist = append(m.appendip_blacklist, s...) +} + +// AppendedIPBlacklist returns the list of values that were appended to the "ip_blacklist" field in this mutation. +func (m *APIKeyMutation) AppendedIPBlacklist() ([]string, bool) { + if len(m.appendip_blacklist) == 0 { + return nil, false + } + return m.appendip_blacklist, true +} + +// ClearIPBlacklist clears the value of the "ip_blacklist" field. +func (m *APIKeyMutation) ClearIPBlacklist() { + m.ip_blacklist = nil + m.appendip_blacklist = nil + m.clearedFields[apikey.FieldIPBlacklist] = struct{}{} +} + +// IPBlacklistCleared returns if the "ip_blacklist" field was cleared in this mutation. +func (m *APIKeyMutation) IPBlacklistCleared() bool { + _, ok := m.clearedFields[apikey.FieldIPBlacklist] + return ok +} + +// ResetIPBlacklist resets all changes to the "ip_blacklist" field. +func (m *APIKeyMutation) ResetIPBlacklist() { + m.ip_blacklist = nil + m.appendip_blacklist = nil + delete(m.clearedFields, apikey.FieldIPBlacklist) +} + // ClearUser clears the "user" edge to the User entity. func (m *APIKeyMutation) ClearUser() { m.cleareduser = true @@ -630,7 +768,7 @@ func (m *APIKeyMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *APIKeyMutation) Fields() []string { - fields := make([]string, 0, 8) + fields := make([]string, 0, 10) if m.created_at != nil { fields = append(fields, apikey.FieldCreatedAt) } @@ -655,6 +793,12 @@ func (m *APIKeyMutation) Fields() []string { if m.status != nil { fields = append(fields, apikey.FieldStatus) } + if m.ip_whitelist != nil { + fields = append(fields, apikey.FieldIPWhitelist) + } + if m.ip_blacklist != nil { + fields = append(fields, apikey.FieldIPBlacklist) + } return fields } @@ -679,6 +823,10 @@ func (m *APIKeyMutation) Field(name string) (ent.Value, bool) { return m.GroupID() case apikey.FieldStatus: return m.Status() + case apikey.FieldIPWhitelist: + return m.IPWhitelist() + case apikey.FieldIPBlacklist: + return m.IPBlacklist() } return nil, false } @@ -704,6 +852,10 @@ func (m *APIKeyMutation) OldField(ctx context.Context, name string) (ent.Value, return m.OldGroupID(ctx) case apikey.FieldStatus: return m.OldStatus(ctx) + case apikey.FieldIPWhitelist: + return m.OldIPWhitelist(ctx) + case apikey.FieldIPBlacklist: + return m.OldIPBlacklist(ctx) } return nil, fmt.Errorf("unknown APIKey field %s", name) } @@ -769,6 +921,20 @@ func (m *APIKeyMutation) SetField(name string, value ent.Value) error { } m.SetStatus(v) return nil + case apikey.FieldIPWhitelist: + v, ok := value.([]string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetIPWhitelist(v) + return nil + case apikey.FieldIPBlacklist: + v, ok := value.([]string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetIPBlacklist(v) + return nil } return fmt.Errorf("unknown APIKey field %s", name) } @@ -808,6 +974,12 @@ func (m *APIKeyMutation) ClearedFields() []string { if m.FieldCleared(apikey.FieldGroupID) { fields = append(fields, apikey.FieldGroupID) } + if m.FieldCleared(apikey.FieldIPWhitelist) { + fields = append(fields, apikey.FieldIPWhitelist) + } + if m.FieldCleared(apikey.FieldIPBlacklist) { + fields = append(fields, apikey.FieldIPBlacklist) + } return fields } @@ -828,6 +1000,12 @@ func (m *APIKeyMutation) ClearField(name string) error { case apikey.FieldGroupID: m.ClearGroupID() return nil + case apikey.FieldIPWhitelist: + m.ClearIPWhitelist() + return nil + case apikey.FieldIPBlacklist: + m.ClearIPBlacklist() + return nil } return fmt.Errorf("unknown APIKey nullable field %s", name) } @@ -860,6 +1038,12 @@ func (m *APIKeyMutation) ResetField(name string) error { case apikey.FieldStatus: m.ResetStatus() return nil + case apikey.FieldIPWhitelist: + m.ResetIPWhitelist() + return nil + case apikey.FieldIPBlacklist: + m.ResetIPBlacklist() + return nil } return fmt.Errorf("unknown APIKey field %s", name) } @@ -5846,6 +6030,1624 @@ func (m *GroupMutation) ResetEdge(name string) error { return fmt.Errorf("unknown Group edge %s", name) } +// PromoCodeMutation represents an operation that mutates the PromoCode nodes in the graph. +type PromoCodeMutation struct { + config + op Op + typ string + id *int64 + code *string + bonus_amount *float64 + addbonus_amount *float64 + max_uses *int + addmax_uses *int + used_count *int + addused_count *int + status *string + expires_at *time.Time + notes *string + created_at *time.Time + updated_at *time.Time + clearedFields map[string]struct{} + usage_records map[int64]struct{} + removedusage_records map[int64]struct{} + clearedusage_records bool + done bool + oldValue func(context.Context) (*PromoCode, error) + predicates []predicate.PromoCode +} + +var _ ent.Mutation = (*PromoCodeMutation)(nil) + +// promocodeOption allows management of the mutation configuration using functional options. +type promocodeOption func(*PromoCodeMutation) + +// newPromoCodeMutation creates new mutation for the PromoCode entity. +func newPromoCodeMutation(c config, op Op, opts ...promocodeOption) *PromoCodeMutation { + m := &PromoCodeMutation{ + config: c, + op: op, + typ: TypePromoCode, + clearedFields: make(map[string]struct{}), + } + for _, opt := range opts { + opt(m) + } + return m +} + +// withPromoCodeID sets the ID field of the mutation. +func withPromoCodeID(id int64) promocodeOption { + return func(m *PromoCodeMutation) { + var ( + err error + once sync.Once + value *PromoCode + ) + m.oldValue = func(ctx context.Context) (*PromoCode, error) { + once.Do(func() { + if m.done { + err = errors.New("querying old values post mutation is not allowed") + } else { + value, err = m.Client().PromoCode.Get(ctx, id) + } + }) + return value, err + } + m.id = &id + } +} + +// withPromoCode sets the old PromoCode of the mutation. +func withPromoCode(node *PromoCode) promocodeOption { + return func(m *PromoCodeMutation) { + m.oldValue = func(context.Context) (*PromoCode, 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 PromoCodeMutation) 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 PromoCodeMutation) 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 *PromoCodeMutation) 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 *PromoCodeMutation) 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().PromoCode.Query().Where(m.predicates...).IDs(ctx) + default: + return nil, fmt.Errorf("IDs is not allowed on %s operations", m.op) + } +} + +// SetCode sets the "code" field. +func (m *PromoCodeMutation) SetCode(s string) { + m.code = &s +} + +// Code returns the value of the "code" field in the mutation. +func (m *PromoCodeMutation) Code() (r string, exists bool) { + v := m.code + if v == nil { + return + } + return *v, true +} + +// OldCode returns the old "code" field's value of the PromoCode entity. +// If the PromoCode 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 *PromoCodeMutation) OldCode(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldCode is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldCode requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldCode: %w", err) + } + return oldValue.Code, nil +} + +// ResetCode resets all changes to the "code" field. +func (m *PromoCodeMutation) ResetCode() { + m.code = nil +} + +// SetBonusAmount sets the "bonus_amount" field. +func (m *PromoCodeMutation) SetBonusAmount(f float64) { + m.bonus_amount = &f + m.addbonus_amount = nil +} + +// BonusAmount returns the value of the "bonus_amount" field in the mutation. +func (m *PromoCodeMutation) BonusAmount() (r float64, exists bool) { + v := m.bonus_amount + if v == nil { + return + } + return *v, true +} + +// OldBonusAmount returns the old "bonus_amount" field's value of the PromoCode entity. +// If the PromoCode 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 *PromoCodeMutation) OldBonusAmount(ctx context.Context) (v float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldBonusAmount is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldBonusAmount requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldBonusAmount: %w", err) + } + return oldValue.BonusAmount, nil +} + +// AddBonusAmount adds f to the "bonus_amount" field. +func (m *PromoCodeMutation) AddBonusAmount(f float64) { + if m.addbonus_amount != nil { + *m.addbonus_amount += f + } else { + m.addbonus_amount = &f + } +} + +// AddedBonusAmount returns the value that was added to the "bonus_amount" field in this mutation. +func (m *PromoCodeMutation) AddedBonusAmount() (r float64, exists bool) { + v := m.addbonus_amount + if v == nil { + return + } + return *v, true +} + +// ResetBonusAmount resets all changes to the "bonus_amount" field. +func (m *PromoCodeMutation) ResetBonusAmount() { + m.bonus_amount = nil + m.addbonus_amount = nil +} + +// SetMaxUses sets the "max_uses" field. +func (m *PromoCodeMutation) SetMaxUses(i int) { + m.max_uses = &i + m.addmax_uses = nil +} + +// MaxUses returns the value of the "max_uses" field in the mutation. +func (m *PromoCodeMutation) MaxUses() (r int, exists bool) { + v := m.max_uses + if v == nil { + return + } + return *v, true +} + +// OldMaxUses returns the old "max_uses" field's value of the PromoCode entity. +// If the PromoCode 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 *PromoCodeMutation) OldMaxUses(ctx context.Context) (v int, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldMaxUses is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldMaxUses requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldMaxUses: %w", err) + } + return oldValue.MaxUses, nil +} + +// AddMaxUses adds i to the "max_uses" field. +func (m *PromoCodeMutation) AddMaxUses(i int) { + if m.addmax_uses != nil { + *m.addmax_uses += i + } else { + m.addmax_uses = &i + } +} + +// AddedMaxUses returns the value that was added to the "max_uses" field in this mutation. +func (m *PromoCodeMutation) AddedMaxUses() (r int, exists bool) { + v := m.addmax_uses + if v == nil { + return + } + return *v, true +} + +// ResetMaxUses resets all changes to the "max_uses" field. +func (m *PromoCodeMutation) ResetMaxUses() { + m.max_uses = nil + m.addmax_uses = nil +} + +// SetUsedCount sets the "used_count" field. +func (m *PromoCodeMutation) SetUsedCount(i int) { + m.used_count = &i + m.addused_count = nil +} + +// UsedCount returns the value of the "used_count" field in the mutation. +func (m *PromoCodeMutation) UsedCount() (r int, exists bool) { + v := m.used_count + if v == nil { + return + } + return *v, true +} + +// OldUsedCount returns the old "used_count" field's value of the PromoCode entity. +// If the PromoCode 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 *PromoCodeMutation) OldUsedCount(ctx context.Context) (v int, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUsedCount is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUsedCount requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUsedCount: %w", err) + } + return oldValue.UsedCount, nil +} + +// AddUsedCount adds i to the "used_count" field. +func (m *PromoCodeMutation) AddUsedCount(i int) { + if m.addused_count != nil { + *m.addused_count += i + } else { + m.addused_count = &i + } +} + +// AddedUsedCount returns the value that was added to the "used_count" field in this mutation. +func (m *PromoCodeMutation) AddedUsedCount() (r int, exists bool) { + v := m.addused_count + if v == nil { + return + } + return *v, true +} + +// ResetUsedCount resets all changes to the "used_count" field. +func (m *PromoCodeMutation) ResetUsedCount() { + m.used_count = nil + m.addused_count = nil +} + +// SetStatus sets the "status" field. +func (m *PromoCodeMutation) SetStatus(s string) { + m.status = &s +} + +// Status returns the value of the "status" field in the mutation. +func (m *PromoCodeMutation) 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 PromoCode entity. +// If the PromoCode 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 *PromoCodeMutation) 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 *PromoCodeMutation) ResetStatus() { + m.status = nil +} + +// SetExpiresAt sets the "expires_at" field. +func (m *PromoCodeMutation) SetExpiresAt(t time.Time) { + m.expires_at = &t +} + +// ExpiresAt returns the value of the "expires_at" field in the mutation. +func (m *PromoCodeMutation) ExpiresAt() (r time.Time, exists bool) { + v := m.expires_at + if v == nil { + return + } + return *v, true +} + +// OldExpiresAt returns the old "expires_at" field's value of the PromoCode entity. +// If the PromoCode 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 *PromoCodeMutation) OldExpiresAt(ctx context.Context) (v *time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldExpiresAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldExpiresAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldExpiresAt: %w", err) + } + return oldValue.ExpiresAt, nil +} + +// ClearExpiresAt clears the value of the "expires_at" field. +func (m *PromoCodeMutation) ClearExpiresAt() { + m.expires_at = nil + m.clearedFields[promocode.FieldExpiresAt] = struct{}{} +} + +// ExpiresAtCleared returns if the "expires_at" field was cleared in this mutation. +func (m *PromoCodeMutation) ExpiresAtCleared() bool { + _, ok := m.clearedFields[promocode.FieldExpiresAt] + return ok +} + +// ResetExpiresAt resets all changes to the "expires_at" field. +func (m *PromoCodeMutation) ResetExpiresAt() { + m.expires_at = nil + delete(m.clearedFields, promocode.FieldExpiresAt) +} + +// SetNotes sets the "notes" field. +func (m *PromoCodeMutation) SetNotes(s string) { + m.notes = &s +} + +// Notes returns the value of the "notes" field in the mutation. +func (m *PromoCodeMutation) Notes() (r string, exists bool) { + v := m.notes + if v == nil { + return + } + return *v, true +} + +// OldNotes returns the old "notes" field's value of the PromoCode entity. +// If the PromoCode 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 *PromoCodeMutation) OldNotes(ctx context.Context) (v *string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldNotes is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldNotes requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldNotes: %w", err) + } + return oldValue.Notes, nil +} + +// ClearNotes clears the value of the "notes" field. +func (m *PromoCodeMutation) ClearNotes() { + m.notes = nil + m.clearedFields[promocode.FieldNotes] = struct{}{} +} + +// NotesCleared returns if the "notes" field was cleared in this mutation. +func (m *PromoCodeMutation) NotesCleared() bool { + _, ok := m.clearedFields[promocode.FieldNotes] + return ok +} + +// ResetNotes resets all changes to the "notes" field. +func (m *PromoCodeMutation) ResetNotes() { + m.notes = nil + delete(m.clearedFields, promocode.FieldNotes) +} + +// SetCreatedAt sets the "created_at" field. +func (m *PromoCodeMutation) SetCreatedAt(t time.Time) { + m.created_at = &t +} + +// CreatedAt returns the value of the "created_at" field in the mutation. +func (m *PromoCodeMutation) 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 PromoCode entity. +// If the PromoCode 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 *PromoCodeMutation) 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 *PromoCodeMutation) ResetCreatedAt() { + m.created_at = nil +} + +// SetUpdatedAt sets the "updated_at" field. +func (m *PromoCodeMutation) SetUpdatedAt(t time.Time) { + m.updated_at = &t +} + +// UpdatedAt returns the value of the "updated_at" field in the mutation. +func (m *PromoCodeMutation) 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 PromoCode entity. +// If the PromoCode 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 *PromoCodeMutation) 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 *PromoCodeMutation) ResetUpdatedAt() { + m.updated_at = nil +} + +// AddUsageRecordIDs adds the "usage_records" edge to the PromoCodeUsage entity by ids. +func (m *PromoCodeMutation) AddUsageRecordIDs(ids ...int64) { + if m.usage_records == nil { + m.usage_records = make(map[int64]struct{}) + } + for i := range ids { + m.usage_records[ids[i]] = struct{}{} + } +} + +// ClearUsageRecords clears the "usage_records" edge to the PromoCodeUsage entity. +func (m *PromoCodeMutation) ClearUsageRecords() { + m.clearedusage_records = true +} + +// UsageRecordsCleared reports if the "usage_records" edge to the PromoCodeUsage entity was cleared. +func (m *PromoCodeMutation) UsageRecordsCleared() bool { + return m.clearedusage_records +} + +// RemoveUsageRecordIDs removes the "usage_records" edge to the PromoCodeUsage entity by IDs. +func (m *PromoCodeMutation) RemoveUsageRecordIDs(ids ...int64) { + if m.removedusage_records == nil { + m.removedusage_records = make(map[int64]struct{}) + } + for i := range ids { + delete(m.usage_records, ids[i]) + m.removedusage_records[ids[i]] = struct{}{} + } +} + +// RemovedUsageRecords returns the removed IDs of the "usage_records" edge to the PromoCodeUsage entity. +func (m *PromoCodeMutation) RemovedUsageRecordsIDs() (ids []int64) { + for id := range m.removedusage_records { + ids = append(ids, id) + } + return +} + +// UsageRecordsIDs returns the "usage_records" edge IDs in the mutation. +func (m *PromoCodeMutation) UsageRecordsIDs() (ids []int64) { + for id := range m.usage_records { + ids = append(ids, id) + } + return +} + +// ResetUsageRecords resets all changes to the "usage_records" edge. +func (m *PromoCodeMutation) ResetUsageRecords() { + m.usage_records = nil + m.clearedusage_records = false + m.removedusage_records = nil +} + +// Where appends a list predicates to the PromoCodeMutation builder. +func (m *PromoCodeMutation) Where(ps ...predicate.PromoCode) { + m.predicates = append(m.predicates, ps...) +} + +// WhereP appends storage-level predicates to the PromoCodeMutation builder. Using this method, +// users can use type-assertion to append predicates that do not depend on any generated package. +func (m *PromoCodeMutation) WhereP(ps ...func(*sql.Selector)) { + p := make([]predicate.PromoCode, len(ps)) + for i := range ps { + p[i] = ps[i] + } + m.Where(p...) +} + +// Op returns the operation name. +func (m *PromoCodeMutation) Op() Op { + return m.op +} + +// SetOp allows setting the mutation operation. +func (m *PromoCodeMutation) SetOp(op Op) { + m.op = op +} + +// Type returns the node type of this mutation (PromoCode). +func (m *PromoCodeMutation) 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 *PromoCodeMutation) Fields() []string { + fields := make([]string, 0, 9) + if m.code != nil { + fields = append(fields, promocode.FieldCode) + } + if m.bonus_amount != nil { + fields = append(fields, promocode.FieldBonusAmount) + } + if m.max_uses != nil { + fields = append(fields, promocode.FieldMaxUses) + } + if m.used_count != nil { + fields = append(fields, promocode.FieldUsedCount) + } + if m.status != nil { + fields = append(fields, promocode.FieldStatus) + } + if m.expires_at != nil { + fields = append(fields, promocode.FieldExpiresAt) + } + if m.notes != nil { + fields = append(fields, promocode.FieldNotes) + } + if m.created_at != nil { + fields = append(fields, promocode.FieldCreatedAt) + } + if m.updated_at != nil { + fields = append(fields, promocode.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 *PromoCodeMutation) Field(name string) (ent.Value, bool) { + switch name { + case promocode.FieldCode: + return m.Code() + case promocode.FieldBonusAmount: + return m.BonusAmount() + case promocode.FieldMaxUses: + return m.MaxUses() + case promocode.FieldUsedCount: + return m.UsedCount() + case promocode.FieldStatus: + return m.Status() + case promocode.FieldExpiresAt: + return m.ExpiresAt() + case promocode.FieldNotes: + return m.Notes() + case promocode.FieldCreatedAt: + return m.CreatedAt() + case promocode.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 *PromoCodeMutation) OldField(ctx context.Context, name string) (ent.Value, error) { + switch name { + case promocode.FieldCode: + return m.OldCode(ctx) + case promocode.FieldBonusAmount: + return m.OldBonusAmount(ctx) + case promocode.FieldMaxUses: + return m.OldMaxUses(ctx) + case promocode.FieldUsedCount: + return m.OldUsedCount(ctx) + case promocode.FieldStatus: + return m.OldStatus(ctx) + case promocode.FieldExpiresAt: + return m.OldExpiresAt(ctx) + case promocode.FieldNotes: + return m.OldNotes(ctx) + case promocode.FieldCreatedAt: + return m.OldCreatedAt(ctx) + case promocode.FieldUpdatedAt: + return m.OldUpdatedAt(ctx) + } + return nil, fmt.Errorf("unknown PromoCode 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 *PromoCodeMutation) SetField(name string, value ent.Value) error { + switch name { + case promocode.FieldCode: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetCode(v) + return nil + case promocode.FieldBonusAmount: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetBonusAmount(v) + return nil + case promocode.FieldMaxUses: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetMaxUses(v) + return nil + case promocode.FieldUsedCount: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUsedCount(v) + return nil + case promocode.FieldStatus: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetStatus(v) + return nil + case promocode.FieldExpiresAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetExpiresAt(v) + return nil + case promocode.FieldNotes: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetNotes(v) + return nil + case promocode.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 promocode.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 PromoCode field %s", name) +} + +// AddedFields returns all numeric fields that were incremented/decremented during +// this mutation. +func (m *PromoCodeMutation) AddedFields() []string { + var fields []string + if m.addbonus_amount != nil { + fields = append(fields, promocode.FieldBonusAmount) + } + if m.addmax_uses != nil { + fields = append(fields, promocode.FieldMaxUses) + } + if m.addused_count != nil { + fields = append(fields, promocode.FieldUsedCount) + } + 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 *PromoCodeMutation) AddedField(name string) (ent.Value, bool) { + switch name { + case promocode.FieldBonusAmount: + return m.AddedBonusAmount() + case promocode.FieldMaxUses: + return m.AddedMaxUses() + case promocode.FieldUsedCount: + return m.AddedUsedCount() + } + 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 *PromoCodeMutation) AddField(name string, value ent.Value) error { + switch name { + case promocode.FieldBonusAmount: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddBonusAmount(v) + return nil + case promocode.FieldMaxUses: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddMaxUses(v) + return nil + case promocode.FieldUsedCount: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddUsedCount(v) + return nil + } + return fmt.Errorf("unknown PromoCode numeric field %s", name) +} + +// ClearedFields returns all nullable fields that were cleared during this +// mutation. +func (m *PromoCodeMutation) ClearedFields() []string { + var fields []string + if m.FieldCleared(promocode.FieldExpiresAt) { + fields = append(fields, promocode.FieldExpiresAt) + } + if m.FieldCleared(promocode.FieldNotes) { + fields = append(fields, promocode.FieldNotes) + } + return fields +} + +// FieldCleared returns a boolean indicating if a field with the given name was +// cleared in this mutation. +func (m *PromoCodeMutation) 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 *PromoCodeMutation) ClearField(name string) error { + switch name { + case promocode.FieldExpiresAt: + m.ClearExpiresAt() + return nil + case promocode.FieldNotes: + m.ClearNotes() + return nil + } + return fmt.Errorf("unknown PromoCode 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 *PromoCodeMutation) ResetField(name string) error { + switch name { + case promocode.FieldCode: + m.ResetCode() + return nil + case promocode.FieldBonusAmount: + m.ResetBonusAmount() + return nil + case promocode.FieldMaxUses: + m.ResetMaxUses() + return nil + case promocode.FieldUsedCount: + m.ResetUsedCount() + return nil + case promocode.FieldStatus: + m.ResetStatus() + return nil + case promocode.FieldExpiresAt: + m.ResetExpiresAt() + return nil + case promocode.FieldNotes: + m.ResetNotes() + return nil + case promocode.FieldCreatedAt: + m.ResetCreatedAt() + return nil + case promocode.FieldUpdatedAt: + m.ResetUpdatedAt() + return nil + } + return fmt.Errorf("unknown PromoCode field %s", name) +} + +// AddedEdges returns all edge names that were set/added in this mutation. +func (m *PromoCodeMutation) AddedEdges() []string { + edges := make([]string, 0, 1) + if m.usage_records != nil { + edges = append(edges, promocode.EdgeUsageRecords) + } + return edges +} + +// AddedIDs returns all IDs (to other nodes) that were added for the given edge +// name in this mutation. +func (m *PromoCodeMutation) AddedIDs(name string) []ent.Value { + switch name { + case promocode.EdgeUsageRecords: + ids := make([]ent.Value, 0, len(m.usage_records)) + for id := range m.usage_records { + ids = append(ids, id) + } + return ids + } + return nil +} + +// RemovedEdges returns all edge names that were removed in this mutation. +func (m *PromoCodeMutation) RemovedEdges() []string { + edges := make([]string, 0, 1) + if m.removedusage_records != nil { + edges = append(edges, promocode.EdgeUsageRecords) + } + return edges +} + +// RemovedIDs returns all IDs (to other nodes) that were removed for the edge with +// the given name in this mutation. +func (m *PromoCodeMutation) RemovedIDs(name string) []ent.Value { + switch name { + case promocode.EdgeUsageRecords: + ids := make([]ent.Value, 0, len(m.removedusage_records)) + for id := range m.removedusage_records { + ids = append(ids, id) + } + return ids + } + return nil +} + +// ClearedEdges returns all edge names that were cleared in this mutation. +func (m *PromoCodeMutation) ClearedEdges() []string { + edges := make([]string, 0, 1) + if m.clearedusage_records { + edges = append(edges, promocode.EdgeUsageRecords) + } + return edges +} + +// EdgeCleared returns a boolean which indicates if the edge with the given name +// was cleared in this mutation. +func (m *PromoCodeMutation) EdgeCleared(name string) bool { + switch name { + case promocode.EdgeUsageRecords: + return m.clearedusage_records + } + 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 *PromoCodeMutation) ClearEdge(name string) error { + switch name { + } + return fmt.Errorf("unknown PromoCode 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 *PromoCodeMutation) ResetEdge(name string) error { + switch name { + case promocode.EdgeUsageRecords: + m.ResetUsageRecords() + return nil + } + return fmt.Errorf("unknown PromoCode edge %s", name) +} + +// PromoCodeUsageMutation represents an operation that mutates the PromoCodeUsage nodes in the graph. +type PromoCodeUsageMutation struct { + config + op Op + typ string + id *int64 + bonus_amount *float64 + addbonus_amount *float64 + used_at *time.Time + clearedFields map[string]struct{} + promo_code *int64 + clearedpromo_code bool + user *int64 + cleareduser bool + done bool + oldValue func(context.Context) (*PromoCodeUsage, error) + predicates []predicate.PromoCodeUsage +} + +var _ ent.Mutation = (*PromoCodeUsageMutation)(nil) + +// promocodeusageOption allows management of the mutation configuration using functional options. +type promocodeusageOption func(*PromoCodeUsageMutation) + +// newPromoCodeUsageMutation creates new mutation for the PromoCodeUsage entity. +func newPromoCodeUsageMutation(c config, op Op, opts ...promocodeusageOption) *PromoCodeUsageMutation { + m := &PromoCodeUsageMutation{ + config: c, + op: op, + typ: TypePromoCodeUsage, + clearedFields: make(map[string]struct{}), + } + for _, opt := range opts { + opt(m) + } + return m +} + +// withPromoCodeUsageID sets the ID field of the mutation. +func withPromoCodeUsageID(id int64) promocodeusageOption { + return func(m *PromoCodeUsageMutation) { + var ( + err error + once sync.Once + value *PromoCodeUsage + ) + m.oldValue = func(ctx context.Context) (*PromoCodeUsage, error) { + once.Do(func() { + if m.done { + err = errors.New("querying old values post mutation is not allowed") + } else { + value, err = m.Client().PromoCodeUsage.Get(ctx, id) + } + }) + return value, err + } + m.id = &id + } +} + +// withPromoCodeUsage sets the old PromoCodeUsage of the mutation. +func withPromoCodeUsage(node *PromoCodeUsage) promocodeusageOption { + return func(m *PromoCodeUsageMutation) { + m.oldValue = func(context.Context) (*PromoCodeUsage, 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 PromoCodeUsageMutation) 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 PromoCodeUsageMutation) 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 *PromoCodeUsageMutation) 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 *PromoCodeUsageMutation) 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().PromoCodeUsage.Query().Where(m.predicates...).IDs(ctx) + default: + return nil, fmt.Errorf("IDs is not allowed on %s operations", m.op) + } +} + +// SetPromoCodeID sets the "promo_code_id" field. +func (m *PromoCodeUsageMutation) SetPromoCodeID(i int64) { + m.promo_code = &i +} + +// PromoCodeID returns the value of the "promo_code_id" field in the mutation. +func (m *PromoCodeUsageMutation) PromoCodeID() (r int64, exists bool) { + v := m.promo_code + if v == nil { + return + } + return *v, true +} + +// OldPromoCodeID returns the old "promo_code_id" field's value of the PromoCodeUsage entity. +// If the PromoCodeUsage 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 *PromoCodeUsageMutation) OldPromoCodeID(ctx context.Context) (v int64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldPromoCodeID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldPromoCodeID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldPromoCodeID: %w", err) + } + return oldValue.PromoCodeID, nil +} + +// ResetPromoCodeID resets all changes to the "promo_code_id" field. +func (m *PromoCodeUsageMutation) ResetPromoCodeID() { + m.promo_code = nil +} + +// SetUserID sets the "user_id" field. +func (m *PromoCodeUsageMutation) SetUserID(i int64) { + m.user = &i +} + +// UserID returns the value of the "user_id" field in the mutation. +func (m *PromoCodeUsageMutation) 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 PromoCodeUsage entity. +// If the PromoCodeUsage 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 *PromoCodeUsageMutation) 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 *PromoCodeUsageMutation) ResetUserID() { + m.user = nil +} + +// SetBonusAmount sets the "bonus_amount" field. +func (m *PromoCodeUsageMutation) SetBonusAmount(f float64) { + m.bonus_amount = &f + m.addbonus_amount = nil +} + +// BonusAmount returns the value of the "bonus_amount" field in the mutation. +func (m *PromoCodeUsageMutation) BonusAmount() (r float64, exists bool) { + v := m.bonus_amount + if v == nil { + return + } + return *v, true +} + +// OldBonusAmount returns the old "bonus_amount" field's value of the PromoCodeUsage entity. +// If the PromoCodeUsage 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 *PromoCodeUsageMutation) OldBonusAmount(ctx context.Context) (v float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldBonusAmount is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldBonusAmount requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldBonusAmount: %w", err) + } + return oldValue.BonusAmount, nil +} + +// AddBonusAmount adds f to the "bonus_amount" field. +func (m *PromoCodeUsageMutation) AddBonusAmount(f float64) { + if m.addbonus_amount != nil { + *m.addbonus_amount += f + } else { + m.addbonus_amount = &f + } +} + +// AddedBonusAmount returns the value that was added to the "bonus_amount" field in this mutation. +func (m *PromoCodeUsageMutation) AddedBonusAmount() (r float64, exists bool) { + v := m.addbonus_amount + if v == nil { + return + } + return *v, true +} + +// ResetBonusAmount resets all changes to the "bonus_amount" field. +func (m *PromoCodeUsageMutation) ResetBonusAmount() { + m.bonus_amount = nil + m.addbonus_amount = nil +} + +// SetUsedAt sets the "used_at" field. +func (m *PromoCodeUsageMutation) SetUsedAt(t time.Time) { + m.used_at = &t +} + +// UsedAt returns the value of the "used_at" field in the mutation. +func (m *PromoCodeUsageMutation) UsedAt() (r time.Time, exists bool) { + v := m.used_at + if v == nil { + return + } + return *v, true +} + +// OldUsedAt returns the old "used_at" field's value of the PromoCodeUsage entity. +// If the PromoCodeUsage 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 *PromoCodeUsageMutation) OldUsedAt(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUsedAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUsedAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUsedAt: %w", err) + } + return oldValue.UsedAt, nil +} + +// ResetUsedAt resets all changes to the "used_at" field. +func (m *PromoCodeUsageMutation) ResetUsedAt() { + m.used_at = nil +} + +// ClearPromoCode clears the "promo_code" edge to the PromoCode entity. +func (m *PromoCodeUsageMutation) ClearPromoCode() { + m.clearedpromo_code = true + m.clearedFields[promocodeusage.FieldPromoCodeID] = struct{}{} +} + +// PromoCodeCleared reports if the "promo_code" edge to the PromoCode entity was cleared. +func (m *PromoCodeUsageMutation) PromoCodeCleared() bool { + return m.clearedpromo_code +} + +// PromoCodeIDs returns the "promo_code" edge IDs in the mutation. +// Note that IDs always returns len(IDs) <= 1 for unique edges, and you should use +// PromoCodeID instead. It exists only for internal usage by the builders. +func (m *PromoCodeUsageMutation) PromoCodeIDs() (ids []int64) { + if id := m.promo_code; id != nil { + ids = append(ids, *id) + } + return +} + +// ResetPromoCode resets all changes to the "promo_code" edge. +func (m *PromoCodeUsageMutation) ResetPromoCode() { + m.promo_code = nil + m.clearedpromo_code = false +} + +// ClearUser clears the "user" edge to the User entity. +func (m *PromoCodeUsageMutation) ClearUser() { + m.cleareduser = true + m.clearedFields[promocodeusage.FieldUserID] = struct{}{} +} + +// UserCleared reports if the "user" edge to the User entity was cleared. +func (m *PromoCodeUsageMutation) 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 *PromoCodeUsageMutation) UserIDs() (ids []int64) { + if id := m.user; id != nil { + ids = append(ids, *id) + } + return +} + +// ResetUser resets all changes to the "user" edge. +func (m *PromoCodeUsageMutation) ResetUser() { + m.user = nil + m.cleareduser = false +} + +// Where appends a list predicates to the PromoCodeUsageMutation builder. +func (m *PromoCodeUsageMutation) Where(ps ...predicate.PromoCodeUsage) { + m.predicates = append(m.predicates, ps...) +} + +// WhereP appends storage-level predicates to the PromoCodeUsageMutation builder. Using this method, +// users can use type-assertion to append predicates that do not depend on any generated package. +func (m *PromoCodeUsageMutation) WhereP(ps ...func(*sql.Selector)) { + p := make([]predicate.PromoCodeUsage, len(ps)) + for i := range ps { + p[i] = ps[i] + } + m.Where(p...) +} + +// Op returns the operation name. +func (m *PromoCodeUsageMutation) Op() Op { + return m.op +} + +// SetOp allows setting the mutation operation. +func (m *PromoCodeUsageMutation) SetOp(op Op) { + m.op = op +} + +// Type returns the node type of this mutation (PromoCodeUsage). +func (m *PromoCodeUsageMutation) 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 *PromoCodeUsageMutation) Fields() []string { + fields := make([]string, 0, 4) + if m.promo_code != nil { + fields = append(fields, promocodeusage.FieldPromoCodeID) + } + if m.user != nil { + fields = append(fields, promocodeusage.FieldUserID) + } + if m.bonus_amount != nil { + fields = append(fields, promocodeusage.FieldBonusAmount) + } + if m.used_at != nil { + fields = append(fields, promocodeusage.FieldUsedAt) + } + 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 *PromoCodeUsageMutation) Field(name string) (ent.Value, bool) { + switch name { + case promocodeusage.FieldPromoCodeID: + return m.PromoCodeID() + case promocodeusage.FieldUserID: + return m.UserID() + case promocodeusage.FieldBonusAmount: + return m.BonusAmount() + case promocodeusage.FieldUsedAt: + return m.UsedAt() + } + 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 *PromoCodeUsageMutation) OldField(ctx context.Context, name string) (ent.Value, error) { + switch name { + case promocodeusage.FieldPromoCodeID: + return m.OldPromoCodeID(ctx) + case promocodeusage.FieldUserID: + return m.OldUserID(ctx) + case promocodeusage.FieldBonusAmount: + return m.OldBonusAmount(ctx) + case promocodeusage.FieldUsedAt: + return m.OldUsedAt(ctx) + } + return nil, fmt.Errorf("unknown PromoCodeUsage 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 *PromoCodeUsageMutation) SetField(name string, value ent.Value) error { + switch name { + case promocodeusage.FieldPromoCodeID: + v, ok := value.(int64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetPromoCodeID(v) + return nil + case promocodeusage.FieldUserID: + v, ok := value.(int64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUserID(v) + return nil + case promocodeusage.FieldBonusAmount: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetBonusAmount(v) + return nil + case promocodeusage.FieldUsedAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUsedAt(v) + return nil + } + return fmt.Errorf("unknown PromoCodeUsage field %s", name) +} + +// AddedFields returns all numeric fields that were incremented/decremented during +// this mutation. +func (m *PromoCodeUsageMutation) AddedFields() []string { + var fields []string + if m.addbonus_amount != nil { + fields = append(fields, promocodeusage.FieldBonusAmount) + } + 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 *PromoCodeUsageMutation) AddedField(name string) (ent.Value, bool) { + switch name { + case promocodeusage.FieldBonusAmount: + return m.AddedBonusAmount() + } + 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 *PromoCodeUsageMutation) AddField(name string, value ent.Value) error { + switch name { + case promocodeusage.FieldBonusAmount: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddBonusAmount(v) + return nil + } + return fmt.Errorf("unknown PromoCodeUsage numeric field %s", name) +} + +// ClearedFields returns all nullable fields that were cleared during this +// mutation. +func (m *PromoCodeUsageMutation) ClearedFields() []string { + return nil +} + +// FieldCleared returns a boolean indicating if a field with the given name was +// cleared in this mutation. +func (m *PromoCodeUsageMutation) 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 *PromoCodeUsageMutation) ClearField(name string) error { + return fmt.Errorf("unknown PromoCodeUsage 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 *PromoCodeUsageMutation) ResetField(name string) error { + switch name { + case promocodeusage.FieldPromoCodeID: + m.ResetPromoCodeID() + return nil + case promocodeusage.FieldUserID: + m.ResetUserID() + return nil + case promocodeusage.FieldBonusAmount: + m.ResetBonusAmount() + return nil + case promocodeusage.FieldUsedAt: + m.ResetUsedAt() + return nil + } + return fmt.Errorf("unknown PromoCodeUsage field %s", name) +} + +// AddedEdges returns all edge names that were set/added in this mutation. +func (m *PromoCodeUsageMutation) AddedEdges() []string { + edges := make([]string, 0, 2) + if m.promo_code != nil { + edges = append(edges, promocodeusage.EdgePromoCode) + } + if m.user != nil { + edges = append(edges, promocodeusage.EdgeUser) + } + return edges +} + +// AddedIDs returns all IDs (to other nodes) that were added for the given edge +// name in this mutation. +func (m *PromoCodeUsageMutation) AddedIDs(name string) []ent.Value { + switch name { + case promocodeusage.EdgePromoCode: + if id := m.promo_code; id != nil { + return []ent.Value{*id} + } + case promocodeusage.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 *PromoCodeUsageMutation) 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 *PromoCodeUsageMutation) RemovedIDs(name string) []ent.Value { + return nil +} + +// ClearedEdges returns all edge names that were cleared in this mutation. +func (m *PromoCodeUsageMutation) ClearedEdges() []string { + edges := make([]string, 0, 2) + if m.clearedpromo_code { + edges = append(edges, promocodeusage.EdgePromoCode) + } + if m.cleareduser { + edges = append(edges, promocodeusage.EdgeUser) + } + return edges +} + +// EdgeCleared returns a boolean which indicates if the edge with the given name +// was cleared in this mutation. +func (m *PromoCodeUsageMutation) EdgeCleared(name string) bool { + switch name { + case promocodeusage.EdgePromoCode: + return m.clearedpromo_code + case promocodeusage.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 *PromoCodeUsageMutation) ClearEdge(name string) error { + switch name { + case promocodeusage.EdgePromoCode: + m.ClearPromoCode() + return nil + case promocodeusage.EdgeUser: + m.ClearUser() + return nil + } + return fmt.Errorf("unknown PromoCodeUsage 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 *PromoCodeUsageMutation) ResetEdge(name string) error { + switch name { + case promocodeusage.EdgePromoCode: + m.ResetPromoCode() + return nil + case promocodeusage.EdgeUser: + m.ResetUser() + return nil + } + return fmt.Errorf("unknown PromoCodeUsage edge %s", name) +} + // ProxyMutation represents an operation that mutates the Proxy nodes in the graph. type ProxyMutation struct { config @@ -8396,6 +10198,7 @@ type UsageLogMutation struct { first_token_ms *int addfirst_token_ms *int user_agent *string + ip_address *string image_count *int addimage_count *int image_size *string @@ -9801,6 +11604,55 @@ func (m *UsageLogMutation) ResetUserAgent() { delete(m.clearedFields, usagelog.FieldUserAgent) } +// SetIPAddress sets the "ip_address" field. +func (m *UsageLogMutation) SetIPAddress(s string) { + m.ip_address = &s +} + +// IPAddress returns the value of the "ip_address" field in the mutation. +func (m *UsageLogMutation) IPAddress() (r string, exists bool) { + v := m.ip_address + if v == nil { + return + } + return *v, true +} + +// OldIPAddress returns the old "ip_address" field's value of the UsageLog entity. +// If the UsageLog 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 *UsageLogMutation) OldIPAddress(ctx context.Context) (v *string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldIPAddress is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldIPAddress requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldIPAddress: %w", err) + } + return oldValue.IPAddress, nil +} + +// ClearIPAddress clears the value of the "ip_address" field. +func (m *UsageLogMutation) ClearIPAddress() { + m.ip_address = nil + m.clearedFields[usagelog.FieldIPAddress] = struct{}{} +} + +// IPAddressCleared returns if the "ip_address" field was cleared in this mutation. +func (m *UsageLogMutation) IPAddressCleared() bool { + _, ok := m.clearedFields[usagelog.FieldIPAddress] + return ok +} + +// ResetIPAddress resets all changes to the "ip_address" field. +func (m *UsageLogMutation) ResetIPAddress() { + m.ip_address = nil + delete(m.clearedFields, usagelog.FieldIPAddress) +} + // SetImageCount sets the "image_count" field. func (m *UsageLogMutation) SetImageCount(i int) { m.image_count = &i @@ -10111,7 +11963,7 @@ func (m *UsageLogMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *UsageLogMutation) Fields() []string { - fields := make([]string, 0, 28) + fields := make([]string, 0, 29) if m.user != nil { fields = append(fields, usagelog.FieldUserID) } @@ -10187,6 +12039,9 @@ func (m *UsageLogMutation) Fields() []string { if m.user_agent != nil { fields = append(fields, usagelog.FieldUserAgent) } + if m.ip_address != nil { + fields = append(fields, usagelog.FieldIPAddress) + } if m.image_count != nil { fields = append(fields, usagelog.FieldImageCount) } @@ -10254,6 +12109,8 @@ func (m *UsageLogMutation) Field(name string) (ent.Value, bool) { return m.FirstTokenMs() case usagelog.FieldUserAgent: return m.UserAgent() + case usagelog.FieldIPAddress: + return m.IPAddress() case usagelog.FieldImageCount: return m.ImageCount() case usagelog.FieldImageSize: @@ -10319,6 +12176,8 @@ func (m *UsageLogMutation) OldField(ctx context.Context, name string) (ent.Value return m.OldFirstTokenMs(ctx) case usagelog.FieldUserAgent: return m.OldUserAgent(ctx) + case usagelog.FieldIPAddress: + return m.OldIPAddress(ctx) case usagelog.FieldImageCount: return m.OldImageCount(ctx) case usagelog.FieldImageSize: @@ -10509,6 +12368,13 @@ func (m *UsageLogMutation) SetField(name string, value ent.Value) error { } m.SetUserAgent(v) return nil + case usagelog.FieldIPAddress: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetIPAddress(v) + return nil case usagelog.FieldImageCount: v, ok := value.(int) if !ok { @@ -10782,6 +12648,9 @@ func (m *UsageLogMutation) ClearedFields() []string { if m.FieldCleared(usagelog.FieldUserAgent) { fields = append(fields, usagelog.FieldUserAgent) } + if m.FieldCleared(usagelog.FieldIPAddress) { + fields = append(fields, usagelog.FieldIPAddress) + } if m.FieldCleared(usagelog.FieldImageSize) { fields = append(fields, usagelog.FieldImageSize) } @@ -10814,6 +12683,9 @@ func (m *UsageLogMutation) ClearField(name string) error { case usagelog.FieldUserAgent: m.ClearUserAgent() return nil + case usagelog.FieldIPAddress: + m.ClearIPAddress() + return nil case usagelog.FieldImageSize: m.ClearImageSize() return nil @@ -10900,6 +12772,9 @@ func (m *UsageLogMutation) ResetField(name string) error { case usagelog.FieldUserAgent: m.ResetUserAgent() return nil + case usagelog.FieldIPAddress: + m.ResetIPAddress() + return nil case usagelog.FieldImageCount: m.ResetImageCount() return nil @@ -11100,6 +12975,9 @@ type UserMutation struct { attribute_values map[int64]struct{} removedattribute_values map[int64]struct{} clearedattribute_values bool + promo_code_usages map[int64]struct{} + removedpromo_code_usages map[int64]struct{} + clearedpromo_code_usages bool done bool oldValue func(context.Context) (*User, error) predicates []predicate.User @@ -12030,6 +13908,60 @@ func (m *UserMutation) ResetAttributeValues() { m.removedattribute_values = nil } +// AddPromoCodeUsageIDs adds the "promo_code_usages" edge to the PromoCodeUsage entity by ids. +func (m *UserMutation) AddPromoCodeUsageIDs(ids ...int64) { + if m.promo_code_usages == nil { + m.promo_code_usages = make(map[int64]struct{}) + } + for i := range ids { + m.promo_code_usages[ids[i]] = struct{}{} + } +} + +// ClearPromoCodeUsages clears the "promo_code_usages" edge to the PromoCodeUsage entity. +func (m *UserMutation) ClearPromoCodeUsages() { + m.clearedpromo_code_usages = true +} + +// PromoCodeUsagesCleared reports if the "promo_code_usages" edge to the PromoCodeUsage entity was cleared. +func (m *UserMutation) PromoCodeUsagesCleared() bool { + return m.clearedpromo_code_usages +} + +// RemovePromoCodeUsageIDs removes the "promo_code_usages" edge to the PromoCodeUsage entity by IDs. +func (m *UserMutation) RemovePromoCodeUsageIDs(ids ...int64) { + if m.removedpromo_code_usages == nil { + m.removedpromo_code_usages = make(map[int64]struct{}) + } + for i := range ids { + delete(m.promo_code_usages, ids[i]) + m.removedpromo_code_usages[ids[i]] = struct{}{} + } +} + +// RemovedPromoCodeUsages returns the removed IDs of the "promo_code_usages" edge to the PromoCodeUsage entity. +func (m *UserMutation) RemovedPromoCodeUsagesIDs() (ids []int64) { + for id := range m.removedpromo_code_usages { + ids = append(ids, id) + } + return +} + +// PromoCodeUsagesIDs returns the "promo_code_usages" edge IDs in the mutation. +func (m *UserMutation) PromoCodeUsagesIDs() (ids []int64) { + for id := range m.promo_code_usages { + ids = append(ids, id) + } + return +} + +// ResetPromoCodeUsages resets all changes to the "promo_code_usages" edge. +func (m *UserMutation) ResetPromoCodeUsages() { + m.promo_code_usages = nil + m.clearedpromo_code_usages = false + m.removedpromo_code_usages = nil +} + // Where appends a list predicates to the UserMutation builder. func (m *UserMutation) Where(ps ...predicate.User) { m.predicates = append(m.predicates, ps...) @@ -12369,7 +14301,7 @@ func (m *UserMutation) ResetField(name string) error { // AddedEdges returns all edge names that were set/added in this mutation. func (m *UserMutation) AddedEdges() []string { - edges := make([]string, 0, 7) + edges := make([]string, 0, 8) if m.api_keys != nil { edges = append(edges, user.EdgeAPIKeys) } @@ -12391,6 +14323,9 @@ func (m *UserMutation) AddedEdges() []string { if m.attribute_values != nil { edges = append(edges, user.EdgeAttributeValues) } + if m.promo_code_usages != nil { + edges = append(edges, user.EdgePromoCodeUsages) + } return edges } @@ -12440,13 +14375,19 @@ func (m *UserMutation) AddedIDs(name string) []ent.Value { ids = append(ids, id) } return ids + case user.EdgePromoCodeUsages: + ids := make([]ent.Value, 0, len(m.promo_code_usages)) + for id := range m.promo_code_usages { + ids = append(ids, id) + } + return ids } return nil } // RemovedEdges returns all edge names that were removed in this mutation. func (m *UserMutation) RemovedEdges() []string { - edges := make([]string, 0, 7) + edges := make([]string, 0, 8) if m.removedapi_keys != nil { edges = append(edges, user.EdgeAPIKeys) } @@ -12468,6 +14409,9 @@ func (m *UserMutation) RemovedEdges() []string { if m.removedattribute_values != nil { edges = append(edges, user.EdgeAttributeValues) } + if m.removedpromo_code_usages != nil { + edges = append(edges, user.EdgePromoCodeUsages) + } return edges } @@ -12517,13 +14461,19 @@ func (m *UserMutation) RemovedIDs(name string) []ent.Value { ids = append(ids, id) } return ids + case user.EdgePromoCodeUsages: + ids := make([]ent.Value, 0, len(m.removedpromo_code_usages)) + for id := range m.removedpromo_code_usages { + ids = append(ids, id) + } + return ids } return nil } // ClearedEdges returns all edge names that were cleared in this mutation. func (m *UserMutation) ClearedEdges() []string { - edges := make([]string, 0, 7) + edges := make([]string, 0, 8) if m.clearedapi_keys { edges = append(edges, user.EdgeAPIKeys) } @@ -12545,6 +14495,9 @@ func (m *UserMutation) ClearedEdges() []string { if m.clearedattribute_values { edges = append(edges, user.EdgeAttributeValues) } + if m.clearedpromo_code_usages { + edges = append(edges, user.EdgePromoCodeUsages) + } return edges } @@ -12566,6 +14519,8 @@ func (m *UserMutation) EdgeCleared(name string) bool { return m.clearedusage_logs case user.EdgeAttributeValues: return m.clearedattribute_values + case user.EdgePromoCodeUsages: + return m.clearedpromo_code_usages } return false } @@ -12603,6 +14558,9 @@ func (m *UserMutation) ResetEdge(name string) error { case user.EdgeAttributeValues: m.ResetAttributeValues() return nil + case user.EdgePromoCodeUsages: + m.ResetPromoCodeUsages() + return nil } return fmt.Errorf("unknown User edge %s", name) } diff --git a/backend/ent/predicate/predicate.go b/backend/ent/predicate/predicate.go index 87c56902..7a443c5d 100644 --- a/backend/ent/predicate/predicate.go +++ b/backend/ent/predicate/predicate.go @@ -18,6 +18,12 @@ type AccountGroup func(*sql.Selector) // Group is the predicate function for group builders. type Group func(*sql.Selector) +// PromoCode is the predicate function for promocode builders. +type PromoCode func(*sql.Selector) + +// PromoCodeUsage is the predicate function for promocodeusage builders. +type PromoCodeUsage func(*sql.Selector) + // Proxy is the predicate function for proxy builders. type Proxy func(*sql.Selector) diff --git a/backend/ent/promocode.go b/backend/ent/promocode.go new file mode 100644 index 00000000..1123bbd6 --- /dev/null +++ b/backend/ent/promocode.go @@ -0,0 +1,228 @@ +// 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/promocode" +) + +// PromoCode is the model entity for the PromoCode schema. +type PromoCode struct { + config `json:"-"` + // ID of the ent. + ID int64 `json:"id,omitempty"` + // 优惠码 + Code string `json:"code,omitempty"` + // 赠送余额金额 + BonusAmount float64 `json:"bonus_amount,omitempty"` + // 最大使用次数,0表示无限制 + MaxUses int `json:"max_uses,omitempty"` + // 已使用次数 + UsedCount int `json:"used_count,omitempty"` + // 状态: active, disabled + Status string `json:"status,omitempty"` + // 过期时间,null表示永不过期 + ExpiresAt *time.Time `json:"expires_at,omitempty"` + // 备注 + Notes *string `json:"notes,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 PromoCodeQuery when eager-loading is set. + Edges PromoCodeEdges `json:"edges"` + selectValues sql.SelectValues +} + +// PromoCodeEdges holds the relations/edges for other nodes in the graph. +type PromoCodeEdges struct { + // UsageRecords holds the value of the usage_records edge. + UsageRecords []*PromoCodeUsage `json:"usage_records,omitempty"` + // loadedTypes holds the information for reporting if a + // type was loaded (or requested) in eager-loading or not. + loadedTypes [1]bool +} + +// UsageRecordsOrErr returns the UsageRecords value or an error if the edge +// was not loaded in eager-loading. +func (e PromoCodeEdges) UsageRecordsOrErr() ([]*PromoCodeUsage, error) { + if e.loadedTypes[0] { + return e.UsageRecords, nil + } + return nil, &NotLoadedError{edge: "usage_records"} +} + +// scanValues returns the types for scanning values from sql.Rows. +func (*PromoCode) scanValues(columns []string) ([]any, error) { + values := make([]any, len(columns)) + for i := range columns { + switch columns[i] { + case promocode.FieldBonusAmount: + values[i] = new(sql.NullFloat64) + case promocode.FieldID, promocode.FieldMaxUses, promocode.FieldUsedCount: + values[i] = new(sql.NullInt64) + case promocode.FieldCode, promocode.FieldStatus, promocode.FieldNotes: + values[i] = new(sql.NullString) + case promocode.FieldExpiresAt, promocode.FieldCreatedAt, promocode.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 PromoCode fields. +func (_m *PromoCode) 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 promocode.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 promocode.FieldCode: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field code", values[i]) + } else if value.Valid { + _m.Code = value.String + } + case promocode.FieldBonusAmount: + if value, ok := values[i].(*sql.NullFloat64); !ok { + return fmt.Errorf("unexpected type %T for field bonus_amount", values[i]) + } else if value.Valid { + _m.BonusAmount = value.Float64 + } + case promocode.FieldMaxUses: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field max_uses", values[i]) + } else if value.Valid { + _m.MaxUses = int(value.Int64) + } + case promocode.FieldUsedCount: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field used_count", values[i]) + } else if value.Valid { + _m.UsedCount = int(value.Int64) + } + case promocode.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 promocode.FieldExpiresAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field expires_at", values[i]) + } else if value.Valid { + _m.ExpiresAt = new(time.Time) + *_m.ExpiresAt = value.Time + } + case promocode.FieldNotes: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field notes", values[i]) + } else if value.Valid { + _m.Notes = new(string) + *_m.Notes = value.String + } + case promocode.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 promocode.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 PromoCode. +// This includes values selected through modifiers, order, etc. +func (_m *PromoCode) Value(name string) (ent.Value, error) { + return _m.selectValues.Get(name) +} + +// QueryUsageRecords queries the "usage_records" edge of the PromoCode entity. +func (_m *PromoCode) QueryUsageRecords() *PromoCodeUsageQuery { + return NewPromoCodeClient(_m.config).QueryUsageRecords(_m) +} + +// Update returns a builder for updating this PromoCode. +// Note that you need to call PromoCode.Unwrap() before calling this method if this PromoCode +// was returned from a transaction, and the transaction was committed or rolled back. +func (_m *PromoCode) Update() *PromoCodeUpdateOne { + return NewPromoCodeClient(_m.config).UpdateOne(_m) +} + +// Unwrap unwraps the PromoCode 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 *PromoCode) Unwrap() *PromoCode { + _tx, ok := _m.config.driver.(*txDriver) + if !ok { + panic("ent: PromoCode is not a transactional entity") + } + _m.config.driver = _tx.drv + return _m +} + +// String implements the fmt.Stringer. +func (_m *PromoCode) String() string { + var builder strings.Builder + builder.WriteString("PromoCode(") + builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID)) + builder.WriteString("code=") + builder.WriteString(_m.Code) + builder.WriteString(", ") + builder.WriteString("bonus_amount=") + builder.WriteString(fmt.Sprintf("%v", _m.BonusAmount)) + builder.WriteString(", ") + builder.WriteString("max_uses=") + builder.WriteString(fmt.Sprintf("%v", _m.MaxUses)) + builder.WriteString(", ") + builder.WriteString("used_count=") + builder.WriteString(fmt.Sprintf("%v", _m.UsedCount)) + builder.WriteString(", ") + builder.WriteString("status=") + builder.WriteString(_m.Status) + builder.WriteString(", ") + if v := _m.ExpiresAt; v != nil { + builder.WriteString("expires_at=") + builder.WriteString(v.Format(time.ANSIC)) + } + builder.WriteString(", ") + if v := _m.Notes; v != nil { + builder.WriteString("notes=") + builder.WriteString(*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() +} + +// PromoCodes is a parsable slice of PromoCode. +type PromoCodes []*PromoCode diff --git a/backend/ent/promocode/promocode.go b/backend/ent/promocode/promocode.go new file mode 100644 index 00000000..ba91658f --- /dev/null +++ b/backend/ent/promocode/promocode.go @@ -0,0 +1,165 @@ +// Code generated by ent, DO NOT EDIT. + +package promocode + +import ( + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" +) + +const ( + // Label holds the string label denoting the promocode type in the database. + Label = "promo_code" + // FieldID holds the string denoting the id field in the database. + FieldID = "id" + // FieldCode holds the string denoting the code field in the database. + FieldCode = "code" + // FieldBonusAmount holds the string denoting the bonus_amount field in the database. + FieldBonusAmount = "bonus_amount" + // FieldMaxUses holds the string denoting the max_uses field in the database. + FieldMaxUses = "max_uses" + // FieldUsedCount holds the string denoting the used_count field in the database. + FieldUsedCount = "used_count" + // FieldStatus holds the string denoting the status field in the database. + FieldStatus = "status" + // FieldExpiresAt holds the string denoting the expires_at field in the database. + FieldExpiresAt = "expires_at" + // FieldNotes holds the string denoting the notes field in the database. + FieldNotes = "notes" + // 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" + // EdgeUsageRecords holds the string denoting the usage_records edge name in mutations. + EdgeUsageRecords = "usage_records" + // Table holds the table name of the promocode in the database. + Table = "promo_codes" + // UsageRecordsTable is the table that holds the usage_records relation/edge. + UsageRecordsTable = "promo_code_usages" + // UsageRecordsInverseTable is the table name for the PromoCodeUsage entity. + // It exists in this package in order to avoid circular dependency with the "promocodeusage" package. + UsageRecordsInverseTable = "promo_code_usages" + // UsageRecordsColumn is the table column denoting the usage_records relation/edge. + UsageRecordsColumn = "promo_code_id" +) + +// Columns holds all SQL columns for promocode fields. +var Columns = []string{ + FieldID, + FieldCode, + FieldBonusAmount, + FieldMaxUses, + FieldUsedCount, + FieldStatus, + FieldExpiresAt, + FieldNotes, + 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 ( + // CodeValidator is a validator for the "code" field. It is called by the builders before save. + CodeValidator func(string) error + // DefaultBonusAmount holds the default value on creation for the "bonus_amount" field. + DefaultBonusAmount float64 + // DefaultMaxUses holds the default value on creation for the "max_uses" field. + DefaultMaxUses int + // DefaultUsedCount holds the default value on creation for the "used_count" field. + DefaultUsedCount int + // 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 PromoCode 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() +} + +// ByCode orders the results by the code field. +func ByCode(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldCode, opts...).ToFunc() +} + +// ByBonusAmount orders the results by the bonus_amount field. +func ByBonusAmount(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldBonusAmount, opts...).ToFunc() +} + +// ByMaxUses orders the results by the max_uses field. +func ByMaxUses(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldMaxUses, opts...).ToFunc() +} + +// ByUsedCount orders the results by the used_count field. +func ByUsedCount(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUsedCount, opts...).ToFunc() +} + +// ByStatus orders the results by the status field. +func ByStatus(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldStatus, opts...).ToFunc() +} + +// ByExpiresAt orders the results by the expires_at field. +func ByExpiresAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldExpiresAt, opts...).ToFunc() +} + +// ByNotes orders the results by the notes field. +func ByNotes(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldNotes, 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() +} + +// ByUsageRecordsCount orders the results by usage_records count. +func ByUsageRecordsCount(opts ...sql.OrderTermOption) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborsCount(s, newUsageRecordsStep(), opts...) + } +} + +// ByUsageRecords orders the results by usage_records terms. +func ByUsageRecords(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborTerms(s, newUsageRecordsStep(), append([]sql.OrderTerm{term}, terms...)...) + } +} +func newUsageRecordsStep() *sqlgraph.Step { + return sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.To(UsageRecordsInverseTable, FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, UsageRecordsTable, UsageRecordsColumn), + ) +} diff --git a/backend/ent/promocode/where.go b/backend/ent/promocode/where.go new file mode 100644 index 00000000..84b6460a --- /dev/null +++ b/backend/ent/promocode/where.go @@ -0,0 +1,594 @@ +// Code generated by ent, DO NOT EDIT. + +package promocode + +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.PromoCode { + return predicate.PromoCode(sql.FieldEQ(FieldID, id)) +} + +// IDEQ applies the EQ predicate on the ID field. +func IDEQ(id int64) predicate.PromoCode { + return predicate.PromoCode(sql.FieldEQ(FieldID, id)) +} + +// IDNEQ applies the NEQ predicate on the ID field. +func IDNEQ(id int64) predicate.PromoCode { + return predicate.PromoCode(sql.FieldNEQ(FieldID, id)) +} + +// IDIn applies the In predicate on the ID field. +func IDIn(ids ...int64) predicate.PromoCode { + return predicate.PromoCode(sql.FieldIn(FieldID, ids...)) +} + +// IDNotIn applies the NotIn predicate on the ID field. +func IDNotIn(ids ...int64) predicate.PromoCode { + return predicate.PromoCode(sql.FieldNotIn(FieldID, ids...)) +} + +// IDGT applies the GT predicate on the ID field. +func IDGT(id int64) predicate.PromoCode { + return predicate.PromoCode(sql.FieldGT(FieldID, id)) +} + +// IDGTE applies the GTE predicate on the ID field. +func IDGTE(id int64) predicate.PromoCode { + return predicate.PromoCode(sql.FieldGTE(FieldID, id)) +} + +// IDLT applies the LT predicate on the ID field. +func IDLT(id int64) predicate.PromoCode { + return predicate.PromoCode(sql.FieldLT(FieldID, id)) +} + +// IDLTE applies the LTE predicate on the ID field. +func IDLTE(id int64) predicate.PromoCode { + return predicate.PromoCode(sql.FieldLTE(FieldID, id)) +} + +// Code applies equality check predicate on the "code" field. It's identical to CodeEQ. +func Code(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldEQ(FieldCode, v)) +} + +// BonusAmount applies equality check predicate on the "bonus_amount" field. It's identical to BonusAmountEQ. +func BonusAmount(v float64) predicate.PromoCode { + return predicate.PromoCode(sql.FieldEQ(FieldBonusAmount, v)) +} + +// MaxUses applies equality check predicate on the "max_uses" field. It's identical to MaxUsesEQ. +func MaxUses(v int) predicate.PromoCode { + return predicate.PromoCode(sql.FieldEQ(FieldMaxUses, v)) +} + +// UsedCount applies equality check predicate on the "used_count" field. It's identical to UsedCountEQ. +func UsedCount(v int) predicate.PromoCode { + return predicate.PromoCode(sql.FieldEQ(FieldUsedCount, v)) +} + +// Status applies equality check predicate on the "status" field. It's identical to StatusEQ. +func Status(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldEQ(FieldStatus, v)) +} + +// ExpiresAt applies equality check predicate on the "expires_at" field. It's identical to ExpiresAtEQ. +func ExpiresAt(v time.Time) predicate.PromoCode { + return predicate.PromoCode(sql.FieldEQ(FieldExpiresAt, v)) +} + +// Notes applies equality check predicate on the "notes" field. It's identical to NotesEQ. +func Notes(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldEQ(FieldNotes, v)) +} + +// CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ. +func CreatedAt(v time.Time) predicate.PromoCode { + return predicate.PromoCode(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.PromoCode { + return predicate.PromoCode(sql.FieldEQ(FieldUpdatedAt, v)) +} + +// CodeEQ applies the EQ predicate on the "code" field. +func CodeEQ(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldEQ(FieldCode, v)) +} + +// CodeNEQ applies the NEQ predicate on the "code" field. +func CodeNEQ(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldNEQ(FieldCode, v)) +} + +// CodeIn applies the In predicate on the "code" field. +func CodeIn(vs ...string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldIn(FieldCode, vs...)) +} + +// CodeNotIn applies the NotIn predicate on the "code" field. +func CodeNotIn(vs ...string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldNotIn(FieldCode, vs...)) +} + +// CodeGT applies the GT predicate on the "code" field. +func CodeGT(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldGT(FieldCode, v)) +} + +// CodeGTE applies the GTE predicate on the "code" field. +func CodeGTE(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldGTE(FieldCode, v)) +} + +// CodeLT applies the LT predicate on the "code" field. +func CodeLT(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldLT(FieldCode, v)) +} + +// CodeLTE applies the LTE predicate on the "code" field. +func CodeLTE(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldLTE(FieldCode, v)) +} + +// CodeContains applies the Contains predicate on the "code" field. +func CodeContains(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldContains(FieldCode, v)) +} + +// CodeHasPrefix applies the HasPrefix predicate on the "code" field. +func CodeHasPrefix(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldHasPrefix(FieldCode, v)) +} + +// CodeHasSuffix applies the HasSuffix predicate on the "code" field. +func CodeHasSuffix(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldHasSuffix(FieldCode, v)) +} + +// CodeEqualFold applies the EqualFold predicate on the "code" field. +func CodeEqualFold(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldEqualFold(FieldCode, v)) +} + +// CodeContainsFold applies the ContainsFold predicate on the "code" field. +func CodeContainsFold(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldContainsFold(FieldCode, v)) +} + +// BonusAmountEQ applies the EQ predicate on the "bonus_amount" field. +func BonusAmountEQ(v float64) predicate.PromoCode { + return predicate.PromoCode(sql.FieldEQ(FieldBonusAmount, v)) +} + +// BonusAmountNEQ applies the NEQ predicate on the "bonus_amount" field. +func BonusAmountNEQ(v float64) predicate.PromoCode { + return predicate.PromoCode(sql.FieldNEQ(FieldBonusAmount, v)) +} + +// BonusAmountIn applies the In predicate on the "bonus_amount" field. +func BonusAmountIn(vs ...float64) predicate.PromoCode { + return predicate.PromoCode(sql.FieldIn(FieldBonusAmount, vs...)) +} + +// BonusAmountNotIn applies the NotIn predicate on the "bonus_amount" field. +func BonusAmountNotIn(vs ...float64) predicate.PromoCode { + return predicate.PromoCode(sql.FieldNotIn(FieldBonusAmount, vs...)) +} + +// BonusAmountGT applies the GT predicate on the "bonus_amount" field. +func BonusAmountGT(v float64) predicate.PromoCode { + return predicate.PromoCode(sql.FieldGT(FieldBonusAmount, v)) +} + +// BonusAmountGTE applies the GTE predicate on the "bonus_amount" field. +func BonusAmountGTE(v float64) predicate.PromoCode { + return predicate.PromoCode(sql.FieldGTE(FieldBonusAmount, v)) +} + +// BonusAmountLT applies the LT predicate on the "bonus_amount" field. +func BonusAmountLT(v float64) predicate.PromoCode { + return predicate.PromoCode(sql.FieldLT(FieldBonusAmount, v)) +} + +// BonusAmountLTE applies the LTE predicate on the "bonus_amount" field. +func BonusAmountLTE(v float64) predicate.PromoCode { + return predicate.PromoCode(sql.FieldLTE(FieldBonusAmount, v)) +} + +// MaxUsesEQ applies the EQ predicate on the "max_uses" field. +func MaxUsesEQ(v int) predicate.PromoCode { + return predicate.PromoCode(sql.FieldEQ(FieldMaxUses, v)) +} + +// MaxUsesNEQ applies the NEQ predicate on the "max_uses" field. +func MaxUsesNEQ(v int) predicate.PromoCode { + return predicate.PromoCode(sql.FieldNEQ(FieldMaxUses, v)) +} + +// MaxUsesIn applies the In predicate on the "max_uses" field. +func MaxUsesIn(vs ...int) predicate.PromoCode { + return predicate.PromoCode(sql.FieldIn(FieldMaxUses, vs...)) +} + +// MaxUsesNotIn applies the NotIn predicate on the "max_uses" field. +func MaxUsesNotIn(vs ...int) predicate.PromoCode { + return predicate.PromoCode(sql.FieldNotIn(FieldMaxUses, vs...)) +} + +// MaxUsesGT applies the GT predicate on the "max_uses" field. +func MaxUsesGT(v int) predicate.PromoCode { + return predicate.PromoCode(sql.FieldGT(FieldMaxUses, v)) +} + +// MaxUsesGTE applies the GTE predicate on the "max_uses" field. +func MaxUsesGTE(v int) predicate.PromoCode { + return predicate.PromoCode(sql.FieldGTE(FieldMaxUses, v)) +} + +// MaxUsesLT applies the LT predicate on the "max_uses" field. +func MaxUsesLT(v int) predicate.PromoCode { + return predicate.PromoCode(sql.FieldLT(FieldMaxUses, v)) +} + +// MaxUsesLTE applies the LTE predicate on the "max_uses" field. +func MaxUsesLTE(v int) predicate.PromoCode { + return predicate.PromoCode(sql.FieldLTE(FieldMaxUses, v)) +} + +// UsedCountEQ applies the EQ predicate on the "used_count" field. +func UsedCountEQ(v int) predicate.PromoCode { + return predicate.PromoCode(sql.FieldEQ(FieldUsedCount, v)) +} + +// UsedCountNEQ applies the NEQ predicate on the "used_count" field. +func UsedCountNEQ(v int) predicate.PromoCode { + return predicate.PromoCode(sql.FieldNEQ(FieldUsedCount, v)) +} + +// UsedCountIn applies the In predicate on the "used_count" field. +func UsedCountIn(vs ...int) predicate.PromoCode { + return predicate.PromoCode(sql.FieldIn(FieldUsedCount, vs...)) +} + +// UsedCountNotIn applies the NotIn predicate on the "used_count" field. +func UsedCountNotIn(vs ...int) predicate.PromoCode { + return predicate.PromoCode(sql.FieldNotIn(FieldUsedCount, vs...)) +} + +// UsedCountGT applies the GT predicate on the "used_count" field. +func UsedCountGT(v int) predicate.PromoCode { + return predicate.PromoCode(sql.FieldGT(FieldUsedCount, v)) +} + +// UsedCountGTE applies the GTE predicate on the "used_count" field. +func UsedCountGTE(v int) predicate.PromoCode { + return predicate.PromoCode(sql.FieldGTE(FieldUsedCount, v)) +} + +// UsedCountLT applies the LT predicate on the "used_count" field. +func UsedCountLT(v int) predicate.PromoCode { + return predicate.PromoCode(sql.FieldLT(FieldUsedCount, v)) +} + +// UsedCountLTE applies the LTE predicate on the "used_count" field. +func UsedCountLTE(v int) predicate.PromoCode { + return predicate.PromoCode(sql.FieldLTE(FieldUsedCount, v)) +} + +// StatusEQ applies the EQ predicate on the "status" field. +func StatusEQ(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldEQ(FieldStatus, v)) +} + +// StatusNEQ applies the NEQ predicate on the "status" field. +func StatusNEQ(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldNEQ(FieldStatus, v)) +} + +// StatusIn applies the In predicate on the "status" field. +func StatusIn(vs ...string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldIn(FieldStatus, vs...)) +} + +// StatusNotIn applies the NotIn predicate on the "status" field. +func StatusNotIn(vs ...string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldNotIn(FieldStatus, vs...)) +} + +// StatusGT applies the GT predicate on the "status" field. +func StatusGT(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldGT(FieldStatus, v)) +} + +// StatusGTE applies the GTE predicate on the "status" field. +func StatusGTE(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldGTE(FieldStatus, v)) +} + +// StatusLT applies the LT predicate on the "status" field. +func StatusLT(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldLT(FieldStatus, v)) +} + +// StatusLTE applies the LTE predicate on the "status" field. +func StatusLTE(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldLTE(FieldStatus, v)) +} + +// StatusContains applies the Contains predicate on the "status" field. +func StatusContains(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldContains(FieldStatus, v)) +} + +// StatusHasPrefix applies the HasPrefix predicate on the "status" field. +func StatusHasPrefix(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldHasPrefix(FieldStatus, v)) +} + +// StatusHasSuffix applies the HasSuffix predicate on the "status" field. +func StatusHasSuffix(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldHasSuffix(FieldStatus, v)) +} + +// StatusEqualFold applies the EqualFold predicate on the "status" field. +func StatusEqualFold(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldEqualFold(FieldStatus, v)) +} + +// StatusContainsFold applies the ContainsFold predicate on the "status" field. +func StatusContainsFold(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldContainsFold(FieldStatus, v)) +} + +// ExpiresAtEQ applies the EQ predicate on the "expires_at" field. +func ExpiresAtEQ(v time.Time) predicate.PromoCode { + return predicate.PromoCode(sql.FieldEQ(FieldExpiresAt, v)) +} + +// ExpiresAtNEQ applies the NEQ predicate on the "expires_at" field. +func ExpiresAtNEQ(v time.Time) predicate.PromoCode { + return predicate.PromoCode(sql.FieldNEQ(FieldExpiresAt, v)) +} + +// ExpiresAtIn applies the In predicate on the "expires_at" field. +func ExpiresAtIn(vs ...time.Time) predicate.PromoCode { + return predicate.PromoCode(sql.FieldIn(FieldExpiresAt, vs...)) +} + +// ExpiresAtNotIn applies the NotIn predicate on the "expires_at" field. +func ExpiresAtNotIn(vs ...time.Time) predicate.PromoCode { + return predicate.PromoCode(sql.FieldNotIn(FieldExpiresAt, vs...)) +} + +// ExpiresAtGT applies the GT predicate on the "expires_at" field. +func ExpiresAtGT(v time.Time) predicate.PromoCode { + return predicate.PromoCode(sql.FieldGT(FieldExpiresAt, v)) +} + +// ExpiresAtGTE applies the GTE predicate on the "expires_at" field. +func ExpiresAtGTE(v time.Time) predicate.PromoCode { + return predicate.PromoCode(sql.FieldGTE(FieldExpiresAt, v)) +} + +// ExpiresAtLT applies the LT predicate on the "expires_at" field. +func ExpiresAtLT(v time.Time) predicate.PromoCode { + return predicate.PromoCode(sql.FieldLT(FieldExpiresAt, v)) +} + +// ExpiresAtLTE applies the LTE predicate on the "expires_at" field. +func ExpiresAtLTE(v time.Time) predicate.PromoCode { + return predicate.PromoCode(sql.FieldLTE(FieldExpiresAt, v)) +} + +// ExpiresAtIsNil applies the IsNil predicate on the "expires_at" field. +func ExpiresAtIsNil() predicate.PromoCode { + return predicate.PromoCode(sql.FieldIsNull(FieldExpiresAt)) +} + +// ExpiresAtNotNil applies the NotNil predicate on the "expires_at" field. +func ExpiresAtNotNil() predicate.PromoCode { + return predicate.PromoCode(sql.FieldNotNull(FieldExpiresAt)) +} + +// NotesEQ applies the EQ predicate on the "notes" field. +func NotesEQ(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldEQ(FieldNotes, v)) +} + +// NotesNEQ applies the NEQ predicate on the "notes" field. +func NotesNEQ(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldNEQ(FieldNotes, v)) +} + +// NotesIn applies the In predicate on the "notes" field. +func NotesIn(vs ...string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldIn(FieldNotes, vs...)) +} + +// NotesNotIn applies the NotIn predicate on the "notes" field. +func NotesNotIn(vs ...string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldNotIn(FieldNotes, vs...)) +} + +// NotesGT applies the GT predicate on the "notes" field. +func NotesGT(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldGT(FieldNotes, v)) +} + +// NotesGTE applies the GTE predicate on the "notes" field. +func NotesGTE(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldGTE(FieldNotes, v)) +} + +// NotesLT applies the LT predicate on the "notes" field. +func NotesLT(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldLT(FieldNotes, v)) +} + +// NotesLTE applies the LTE predicate on the "notes" field. +func NotesLTE(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldLTE(FieldNotes, v)) +} + +// NotesContains applies the Contains predicate on the "notes" field. +func NotesContains(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldContains(FieldNotes, v)) +} + +// NotesHasPrefix applies the HasPrefix predicate on the "notes" field. +func NotesHasPrefix(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldHasPrefix(FieldNotes, v)) +} + +// NotesHasSuffix applies the HasSuffix predicate on the "notes" field. +func NotesHasSuffix(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldHasSuffix(FieldNotes, v)) +} + +// NotesIsNil applies the IsNil predicate on the "notes" field. +func NotesIsNil() predicate.PromoCode { + return predicate.PromoCode(sql.FieldIsNull(FieldNotes)) +} + +// NotesNotNil applies the NotNil predicate on the "notes" field. +func NotesNotNil() predicate.PromoCode { + return predicate.PromoCode(sql.FieldNotNull(FieldNotes)) +} + +// NotesEqualFold applies the EqualFold predicate on the "notes" field. +func NotesEqualFold(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldEqualFold(FieldNotes, v)) +} + +// NotesContainsFold applies the ContainsFold predicate on the "notes" field. +func NotesContainsFold(v string) predicate.PromoCode { + return predicate.PromoCode(sql.FieldContainsFold(FieldNotes, v)) +} + +// CreatedAtEQ applies the EQ predicate on the "created_at" field. +func CreatedAtEQ(v time.Time) predicate.PromoCode { + return predicate.PromoCode(sql.FieldEQ(FieldCreatedAt, v)) +} + +// CreatedAtNEQ applies the NEQ predicate on the "created_at" field. +func CreatedAtNEQ(v time.Time) predicate.PromoCode { + return predicate.PromoCode(sql.FieldNEQ(FieldCreatedAt, v)) +} + +// CreatedAtIn applies the In predicate on the "created_at" field. +func CreatedAtIn(vs ...time.Time) predicate.PromoCode { + return predicate.PromoCode(sql.FieldIn(FieldCreatedAt, vs...)) +} + +// CreatedAtNotIn applies the NotIn predicate on the "created_at" field. +func CreatedAtNotIn(vs ...time.Time) predicate.PromoCode { + return predicate.PromoCode(sql.FieldNotIn(FieldCreatedAt, vs...)) +} + +// CreatedAtGT applies the GT predicate on the "created_at" field. +func CreatedAtGT(v time.Time) predicate.PromoCode { + return predicate.PromoCode(sql.FieldGT(FieldCreatedAt, v)) +} + +// CreatedAtGTE applies the GTE predicate on the "created_at" field. +func CreatedAtGTE(v time.Time) predicate.PromoCode { + return predicate.PromoCode(sql.FieldGTE(FieldCreatedAt, v)) +} + +// CreatedAtLT applies the LT predicate on the "created_at" field. +func CreatedAtLT(v time.Time) predicate.PromoCode { + return predicate.PromoCode(sql.FieldLT(FieldCreatedAt, v)) +} + +// CreatedAtLTE applies the LTE predicate on the "created_at" field. +func CreatedAtLTE(v time.Time) predicate.PromoCode { + return predicate.PromoCode(sql.FieldLTE(FieldCreatedAt, v)) +} + +// UpdatedAtEQ applies the EQ predicate on the "updated_at" field. +func UpdatedAtEQ(v time.Time) predicate.PromoCode { + return predicate.PromoCode(sql.FieldEQ(FieldUpdatedAt, v)) +} + +// UpdatedAtNEQ applies the NEQ predicate on the "updated_at" field. +func UpdatedAtNEQ(v time.Time) predicate.PromoCode { + return predicate.PromoCode(sql.FieldNEQ(FieldUpdatedAt, v)) +} + +// UpdatedAtIn applies the In predicate on the "updated_at" field. +func UpdatedAtIn(vs ...time.Time) predicate.PromoCode { + return predicate.PromoCode(sql.FieldIn(FieldUpdatedAt, vs...)) +} + +// UpdatedAtNotIn applies the NotIn predicate on the "updated_at" field. +func UpdatedAtNotIn(vs ...time.Time) predicate.PromoCode { + return predicate.PromoCode(sql.FieldNotIn(FieldUpdatedAt, vs...)) +} + +// UpdatedAtGT applies the GT predicate on the "updated_at" field. +func UpdatedAtGT(v time.Time) predicate.PromoCode { + return predicate.PromoCode(sql.FieldGT(FieldUpdatedAt, v)) +} + +// UpdatedAtGTE applies the GTE predicate on the "updated_at" field. +func UpdatedAtGTE(v time.Time) predicate.PromoCode { + return predicate.PromoCode(sql.FieldGTE(FieldUpdatedAt, v)) +} + +// UpdatedAtLT applies the LT predicate on the "updated_at" field. +func UpdatedAtLT(v time.Time) predicate.PromoCode { + return predicate.PromoCode(sql.FieldLT(FieldUpdatedAt, v)) +} + +// UpdatedAtLTE applies the LTE predicate on the "updated_at" field. +func UpdatedAtLTE(v time.Time) predicate.PromoCode { + return predicate.PromoCode(sql.FieldLTE(FieldUpdatedAt, v)) +} + +// HasUsageRecords applies the HasEdge predicate on the "usage_records" edge. +func HasUsageRecords() predicate.PromoCode { + return predicate.PromoCode(func(s *sql.Selector) { + step := sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, UsageRecordsTable, UsageRecordsColumn), + ) + sqlgraph.HasNeighbors(s, step) + }) +} + +// HasUsageRecordsWith applies the HasEdge predicate on the "usage_records" edge with a given conditions (other predicates). +func HasUsageRecordsWith(preds ...predicate.PromoCodeUsage) predicate.PromoCode { + return predicate.PromoCode(func(s *sql.Selector) { + step := newUsageRecordsStep() + 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.PromoCode) predicate.PromoCode { + return predicate.PromoCode(sql.AndPredicates(predicates...)) +} + +// Or groups predicates with the OR operator between them. +func Or(predicates ...predicate.PromoCode) predicate.PromoCode { + return predicate.PromoCode(sql.OrPredicates(predicates...)) +} + +// Not applies the not operator on the given predicate. +func Not(p predicate.PromoCode) predicate.PromoCode { + return predicate.PromoCode(sql.NotPredicates(p)) +} diff --git a/backend/ent/promocode_create.go b/backend/ent/promocode_create.go new file mode 100644 index 00000000..4fd2c39c --- /dev/null +++ b/backend/ent/promocode_create.go @@ -0,0 +1,1081 @@ +// 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/promocode" + "github.com/Wei-Shaw/sub2api/ent/promocodeusage" +) + +// PromoCodeCreate is the builder for creating a PromoCode entity. +type PromoCodeCreate struct { + config + mutation *PromoCodeMutation + hooks []Hook + conflict []sql.ConflictOption +} + +// SetCode sets the "code" field. +func (_c *PromoCodeCreate) SetCode(v string) *PromoCodeCreate { + _c.mutation.SetCode(v) + return _c +} + +// SetBonusAmount sets the "bonus_amount" field. +func (_c *PromoCodeCreate) SetBonusAmount(v float64) *PromoCodeCreate { + _c.mutation.SetBonusAmount(v) + return _c +} + +// SetNillableBonusAmount sets the "bonus_amount" field if the given value is not nil. +func (_c *PromoCodeCreate) SetNillableBonusAmount(v *float64) *PromoCodeCreate { + if v != nil { + _c.SetBonusAmount(*v) + } + return _c +} + +// SetMaxUses sets the "max_uses" field. +func (_c *PromoCodeCreate) SetMaxUses(v int) *PromoCodeCreate { + _c.mutation.SetMaxUses(v) + return _c +} + +// SetNillableMaxUses sets the "max_uses" field if the given value is not nil. +func (_c *PromoCodeCreate) SetNillableMaxUses(v *int) *PromoCodeCreate { + if v != nil { + _c.SetMaxUses(*v) + } + return _c +} + +// SetUsedCount sets the "used_count" field. +func (_c *PromoCodeCreate) SetUsedCount(v int) *PromoCodeCreate { + _c.mutation.SetUsedCount(v) + return _c +} + +// SetNillableUsedCount sets the "used_count" field if the given value is not nil. +func (_c *PromoCodeCreate) SetNillableUsedCount(v *int) *PromoCodeCreate { + if v != nil { + _c.SetUsedCount(*v) + } + return _c +} + +// SetStatus sets the "status" field. +func (_c *PromoCodeCreate) SetStatus(v string) *PromoCodeCreate { + _c.mutation.SetStatus(v) + return _c +} + +// SetNillableStatus sets the "status" field if the given value is not nil. +func (_c *PromoCodeCreate) SetNillableStatus(v *string) *PromoCodeCreate { + if v != nil { + _c.SetStatus(*v) + } + return _c +} + +// SetExpiresAt sets the "expires_at" field. +func (_c *PromoCodeCreate) SetExpiresAt(v time.Time) *PromoCodeCreate { + _c.mutation.SetExpiresAt(v) + return _c +} + +// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil. +func (_c *PromoCodeCreate) SetNillableExpiresAt(v *time.Time) *PromoCodeCreate { + if v != nil { + _c.SetExpiresAt(*v) + } + return _c +} + +// SetNotes sets the "notes" field. +func (_c *PromoCodeCreate) SetNotes(v string) *PromoCodeCreate { + _c.mutation.SetNotes(v) + return _c +} + +// SetNillableNotes sets the "notes" field if the given value is not nil. +func (_c *PromoCodeCreate) SetNillableNotes(v *string) *PromoCodeCreate { + if v != nil { + _c.SetNotes(*v) + } + return _c +} + +// SetCreatedAt sets the "created_at" field. +func (_c *PromoCodeCreate) SetCreatedAt(v time.Time) *PromoCodeCreate { + _c.mutation.SetCreatedAt(v) + return _c +} + +// SetNillableCreatedAt sets the "created_at" field if the given value is not nil. +func (_c *PromoCodeCreate) SetNillableCreatedAt(v *time.Time) *PromoCodeCreate { + if v != nil { + _c.SetCreatedAt(*v) + } + return _c +} + +// SetUpdatedAt sets the "updated_at" field. +func (_c *PromoCodeCreate) SetUpdatedAt(v time.Time) *PromoCodeCreate { + _c.mutation.SetUpdatedAt(v) + return _c +} + +// SetNillableUpdatedAt sets the "updated_at" field if the given value is not nil. +func (_c *PromoCodeCreate) SetNillableUpdatedAt(v *time.Time) *PromoCodeCreate { + if v != nil { + _c.SetUpdatedAt(*v) + } + return _c +} + +// AddUsageRecordIDs adds the "usage_records" edge to the PromoCodeUsage entity by IDs. +func (_c *PromoCodeCreate) AddUsageRecordIDs(ids ...int64) *PromoCodeCreate { + _c.mutation.AddUsageRecordIDs(ids...) + return _c +} + +// AddUsageRecords adds the "usage_records" edges to the PromoCodeUsage entity. +func (_c *PromoCodeCreate) AddUsageRecords(v ...*PromoCodeUsage) *PromoCodeCreate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _c.AddUsageRecordIDs(ids...) +} + +// Mutation returns the PromoCodeMutation object of the builder. +func (_c *PromoCodeCreate) Mutation() *PromoCodeMutation { + return _c.mutation +} + +// Save creates the PromoCode in the database. +func (_c *PromoCodeCreate) Save(ctx context.Context) (*PromoCode, error) { + _c.defaults() + return withHooks(ctx, _c.sqlSave, _c.mutation, _c.hooks) +} + +// SaveX calls Save and panics if Save returns an error. +func (_c *PromoCodeCreate) SaveX(ctx context.Context) *PromoCode { + v, err := _c.Save(ctx) + if err != nil { + panic(err) + } + return v +} + +// Exec executes the query. +func (_c *PromoCodeCreate) Exec(ctx context.Context) error { + _, err := _c.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_c *PromoCodeCreate) 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 *PromoCodeCreate) defaults() { + if _, ok := _c.mutation.BonusAmount(); !ok { + v := promocode.DefaultBonusAmount + _c.mutation.SetBonusAmount(v) + } + if _, ok := _c.mutation.MaxUses(); !ok { + v := promocode.DefaultMaxUses + _c.mutation.SetMaxUses(v) + } + if _, ok := _c.mutation.UsedCount(); !ok { + v := promocode.DefaultUsedCount + _c.mutation.SetUsedCount(v) + } + if _, ok := _c.mutation.Status(); !ok { + v := promocode.DefaultStatus + _c.mutation.SetStatus(v) + } + if _, ok := _c.mutation.CreatedAt(); !ok { + v := promocode.DefaultCreatedAt() + _c.mutation.SetCreatedAt(v) + } + if _, ok := _c.mutation.UpdatedAt(); !ok { + v := promocode.DefaultUpdatedAt() + _c.mutation.SetUpdatedAt(v) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_c *PromoCodeCreate) check() error { + if _, ok := _c.mutation.Code(); !ok { + return &ValidationError{Name: "code", err: errors.New(`ent: missing required field "PromoCode.code"`)} + } + if v, ok := _c.mutation.Code(); ok { + if err := promocode.CodeValidator(v); err != nil { + return &ValidationError{Name: "code", err: fmt.Errorf(`ent: validator failed for field "PromoCode.code": %w`, err)} + } + } + if _, ok := _c.mutation.BonusAmount(); !ok { + return &ValidationError{Name: "bonus_amount", err: errors.New(`ent: missing required field "PromoCode.bonus_amount"`)} + } + if _, ok := _c.mutation.MaxUses(); !ok { + return &ValidationError{Name: "max_uses", err: errors.New(`ent: missing required field "PromoCode.max_uses"`)} + } + if _, ok := _c.mutation.UsedCount(); !ok { + return &ValidationError{Name: "used_count", err: errors.New(`ent: missing required field "PromoCode.used_count"`)} + } + if _, ok := _c.mutation.Status(); !ok { + return &ValidationError{Name: "status", err: errors.New(`ent: missing required field "PromoCode.status"`)} + } + if v, ok := _c.mutation.Status(); ok { + if err := promocode.StatusValidator(v); err != nil { + return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "PromoCode.status": %w`, err)} + } + } + if _, ok := _c.mutation.CreatedAt(); !ok { + return &ValidationError{Name: "created_at", err: errors.New(`ent: missing required field "PromoCode.created_at"`)} + } + if _, ok := _c.mutation.UpdatedAt(); !ok { + return &ValidationError{Name: "updated_at", err: errors.New(`ent: missing required field "PromoCode.updated_at"`)} + } + return nil +} + +func (_c *PromoCodeCreate) sqlSave(ctx context.Context) (*PromoCode, 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 *PromoCodeCreate) createSpec() (*PromoCode, *sqlgraph.CreateSpec) { + var ( + _node = &PromoCode{config: _c.config} + _spec = sqlgraph.NewCreateSpec(promocode.Table, sqlgraph.NewFieldSpec(promocode.FieldID, field.TypeInt64)) + ) + _spec.OnConflict = _c.conflict + if value, ok := _c.mutation.Code(); ok { + _spec.SetField(promocode.FieldCode, field.TypeString, value) + _node.Code = value + } + if value, ok := _c.mutation.BonusAmount(); ok { + _spec.SetField(promocode.FieldBonusAmount, field.TypeFloat64, value) + _node.BonusAmount = value + } + if value, ok := _c.mutation.MaxUses(); ok { + _spec.SetField(promocode.FieldMaxUses, field.TypeInt, value) + _node.MaxUses = value + } + if value, ok := _c.mutation.UsedCount(); ok { + _spec.SetField(promocode.FieldUsedCount, field.TypeInt, value) + _node.UsedCount = value + } + if value, ok := _c.mutation.Status(); ok { + _spec.SetField(promocode.FieldStatus, field.TypeString, value) + _node.Status = value + } + if value, ok := _c.mutation.ExpiresAt(); ok { + _spec.SetField(promocode.FieldExpiresAt, field.TypeTime, value) + _node.ExpiresAt = &value + } + if value, ok := _c.mutation.Notes(); ok { + _spec.SetField(promocode.FieldNotes, field.TypeString, value) + _node.Notes = &value + } + if value, ok := _c.mutation.CreatedAt(); ok { + _spec.SetField(promocode.FieldCreatedAt, field.TypeTime, value) + _node.CreatedAt = value + } + if value, ok := _c.mutation.UpdatedAt(); ok { + _spec.SetField(promocode.FieldUpdatedAt, field.TypeTime, value) + _node.UpdatedAt = value + } + if nodes := _c.mutation.UsageRecordsIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: promocode.UsageRecordsTable, + Columns: []string{promocode.UsageRecordsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(promocodeusage.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.PromoCode.Create(). +// SetCode(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.PromoCodeUpsert) { +// SetCode(v+v). +// }). +// Exec(ctx) +func (_c *PromoCodeCreate) OnConflict(opts ...sql.ConflictOption) *PromoCodeUpsertOne { + _c.conflict = opts + return &PromoCodeUpsertOne{ + create: _c, + } +} + +// OnConflictColumns calls `OnConflict` and configures the columns +// as conflict target. Using this option is equivalent to using: +// +// client.PromoCode.Create(). +// OnConflict(sql.ConflictColumns(columns...)). +// Exec(ctx) +func (_c *PromoCodeCreate) OnConflictColumns(columns ...string) *PromoCodeUpsertOne { + _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...)) + return &PromoCodeUpsertOne{ + create: _c, + } +} + +type ( + // PromoCodeUpsertOne is the builder for "upsert"-ing + // one PromoCode node. + PromoCodeUpsertOne struct { + create *PromoCodeCreate + } + + // PromoCodeUpsert is the "OnConflict" setter. + PromoCodeUpsert struct { + *sql.UpdateSet + } +) + +// SetCode sets the "code" field. +func (u *PromoCodeUpsert) SetCode(v string) *PromoCodeUpsert { + u.Set(promocode.FieldCode, v) + return u +} + +// UpdateCode sets the "code" field to the value that was provided on create. +func (u *PromoCodeUpsert) UpdateCode() *PromoCodeUpsert { + u.SetExcluded(promocode.FieldCode) + return u +} + +// SetBonusAmount sets the "bonus_amount" field. +func (u *PromoCodeUpsert) SetBonusAmount(v float64) *PromoCodeUpsert { + u.Set(promocode.FieldBonusAmount, v) + return u +} + +// UpdateBonusAmount sets the "bonus_amount" field to the value that was provided on create. +func (u *PromoCodeUpsert) UpdateBonusAmount() *PromoCodeUpsert { + u.SetExcluded(promocode.FieldBonusAmount) + return u +} + +// AddBonusAmount adds v to the "bonus_amount" field. +func (u *PromoCodeUpsert) AddBonusAmount(v float64) *PromoCodeUpsert { + u.Add(promocode.FieldBonusAmount, v) + return u +} + +// SetMaxUses sets the "max_uses" field. +func (u *PromoCodeUpsert) SetMaxUses(v int) *PromoCodeUpsert { + u.Set(promocode.FieldMaxUses, v) + return u +} + +// UpdateMaxUses sets the "max_uses" field to the value that was provided on create. +func (u *PromoCodeUpsert) UpdateMaxUses() *PromoCodeUpsert { + u.SetExcluded(promocode.FieldMaxUses) + return u +} + +// AddMaxUses adds v to the "max_uses" field. +func (u *PromoCodeUpsert) AddMaxUses(v int) *PromoCodeUpsert { + u.Add(promocode.FieldMaxUses, v) + return u +} + +// SetUsedCount sets the "used_count" field. +func (u *PromoCodeUpsert) SetUsedCount(v int) *PromoCodeUpsert { + u.Set(promocode.FieldUsedCount, v) + return u +} + +// UpdateUsedCount sets the "used_count" field to the value that was provided on create. +func (u *PromoCodeUpsert) UpdateUsedCount() *PromoCodeUpsert { + u.SetExcluded(promocode.FieldUsedCount) + return u +} + +// AddUsedCount adds v to the "used_count" field. +func (u *PromoCodeUpsert) AddUsedCount(v int) *PromoCodeUpsert { + u.Add(promocode.FieldUsedCount, v) + return u +} + +// SetStatus sets the "status" field. +func (u *PromoCodeUpsert) SetStatus(v string) *PromoCodeUpsert { + u.Set(promocode.FieldStatus, v) + return u +} + +// UpdateStatus sets the "status" field to the value that was provided on create. +func (u *PromoCodeUpsert) UpdateStatus() *PromoCodeUpsert { + u.SetExcluded(promocode.FieldStatus) + return u +} + +// SetExpiresAt sets the "expires_at" field. +func (u *PromoCodeUpsert) SetExpiresAt(v time.Time) *PromoCodeUpsert { + u.Set(promocode.FieldExpiresAt, v) + return u +} + +// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create. +func (u *PromoCodeUpsert) UpdateExpiresAt() *PromoCodeUpsert { + u.SetExcluded(promocode.FieldExpiresAt) + return u +} + +// ClearExpiresAt clears the value of the "expires_at" field. +func (u *PromoCodeUpsert) ClearExpiresAt() *PromoCodeUpsert { + u.SetNull(promocode.FieldExpiresAt) + return u +} + +// SetNotes sets the "notes" field. +func (u *PromoCodeUpsert) SetNotes(v string) *PromoCodeUpsert { + u.Set(promocode.FieldNotes, v) + return u +} + +// UpdateNotes sets the "notes" field to the value that was provided on create. +func (u *PromoCodeUpsert) UpdateNotes() *PromoCodeUpsert { + u.SetExcluded(promocode.FieldNotes) + return u +} + +// ClearNotes clears the value of the "notes" field. +func (u *PromoCodeUpsert) ClearNotes() *PromoCodeUpsert { + u.SetNull(promocode.FieldNotes) + return u +} + +// SetUpdatedAt sets the "updated_at" field. +func (u *PromoCodeUpsert) SetUpdatedAt(v time.Time) *PromoCodeUpsert { + u.Set(promocode.FieldUpdatedAt, v) + return u +} + +// UpdateUpdatedAt sets the "updated_at" field to the value that was provided on create. +func (u *PromoCodeUpsert) UpdateUpdatedAt() *PromoCodeUpsert { + u.SetExcluded(promocode.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.PromoCode.Create(). +// OnConflict( +// sql.ResolveWithNewValues(), +// ). +// Exec(ctx) +func (u *PromoCodeUpsertOne) UpdateNewValues() *PromoCodeUpsertOne { + 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(promocode.FieldCreatedAt) + } + })) + return u +} + +// Ignore sets each column to itself in case of conflict. +// Using this option is equivalent to using: +// +// client.PromoCode.Create(). +// OnConflict(sql.ResolveWithIgnore()). +// Exec(ctx) +func (u *PromoCodeUpsertOne) Ignore() *PromoCodeUpsertOne { + 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 *PromoCodeUpsertOne) DoNothing() *PromoCodeUpsertOne { + u.create.conflict = append(u.create.conflict, sql.DoNothing()) + return u +} + +// Update allows overriding fields `UPDATE` values. See the PromoCodeCreate.OnConflict +// documentation for more info. +func (u *PromoCodeUpsertOne) Update(set func(*PromoCodeUpsert)) *PromoCodeUpsertOne { + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) { + set(&PromoCodeUpsert{UpdateSet: update}) + })) + return u +} + +// SetCode sets the "code" field. +func (u *PromoCodeUpsertOne) SetCode(v string) *PromoCodeUpsertOne { + return u.Update(func(s *PromoCodeUpsert) { + s.SetCode(v) + }) +} + +// UpdateCode sets the "code" field to the value that was provided on create. +func (u *PromoCodeUpsertOne) UpdateCode() *PromoCodeUpsertOne { + return u.Update(func(s *PromoCodeUpsert) { + s.UpdateCode() + }) +} + +// SetBonusAmount sets the "bonus_amount" field. +func (u *PromoCodeUpsertOne) SetBonusAmount(v float64) *PromoCodeUpsertOne { + return u.Update(func(s *PromoCodeUpsert) { + s.SetBonusAmount(v) + }) +} + +// AddBonusAmount adds v to the "bonus_amount" field. +func (u *PromoCodeUpsertOne) AddBonusAmount(v float64) *PromoCodeUpsertOne { + return u.Update(func(s *PromoCodeUpsert) { + s.AddBonusAmount(v) + }) +} + +// UpdateBonusAmount sets the "bonus_amount" field to the value that was provided on create. +func (u *PromoCodeUpsertOne) UpdateBonusAmount() *PromoCodeUpsertOne { + return u.Update(func(s *PromoCodeUpsert) { + s.UpdateBonusAmount() + }) +} + +// SetMaxUses sets the "max_uses" field. +func (u *PromoCodeUpsertOne) SetMaxUses(v int) *PromoCodeUpsertOne { + return u.Update(func(s *PromoCodeUpsert) { + s.SetMaxUses(v) + }) +} + +// AddMaxUses adds v to the "max_uses" field. +func (u *PromoCodeUpsertOne) AddMaxUses(v int) *PromoCodeUpsertOne { + return u.Update(func(s *PromoCodeUpsert) { + s.AddMaxUses(v) + }) +} + +// UpdateMaxUses sets the "max_uses" field to the value that was provided on create. +func (u *PromoCodeUpsertOne) UpdateMaxUses() *PromoCodeUpsertOne { + return u.Update(func(s *PromoCodeUpsert) { + s.UpdateMaxUses() + }) +} + +// SetUsedCount sets the "used_count" field. +func (u *PromoCodeUpsertOne) SetUsedCount(v int) *PromoCodeUpsertOne { + return u.Update(func(s *PromoCodeUpsert) { + s.SetUsedCount(v) + }) +} + +// AddUsedCount adds v to the "used_count" field. +func (u *PromoCodeUpsertOne) AddUsedCount(v int) *PromoCodeUpsertOne { + return u.Update(func(s *PromoCodeUpsert) { + s.AddUsedCount(v) + }) +} + +// UpdateUsedCount sets the "used_count" field to the value that was provided on create. +func (u *PromoCodeUpsertOne) UpdateUsedCount() *PromoCodeUpsertOne { + return u.Update(func(s *PromoCodeUpsert) { + s.UpdateUsedCount() + }) +} + +// SetStatus sets the "status" field. +func (u *PromoCodeUpsertOne) SetStatus(v string) *PromoCodeUpsertOne { + return u.Update(func(s *PromoCodeUpsert) { + s.SetStatus(v) + }) +} + +// UpdateStatus sets the "status" field to the value that was provided on create. +func (u *PromoCodeUpsertOne) UpdateStatus() *PromoCodeUpsertOne { + return u.Update(func(s *PromoCodeUpsert) { + s.UpdateStatus() + }) +} + +// SetExpiresAt sets the "expires_at" field. +func (u *PromoCodeUpsertOne) SetExpiresAt(v time.Time) *PromoCodeUpsertOne { + return u.Update(func(s *PromoCodeUpsert) { + s.SetExpiresAt(v) + }) +} + +// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create. +func (u *PromoCodeUpsertOne) UpdateExpiresAt() *PromoCodeUpsertOne { + return u.Update(func(s *PromoCodeUpsert) { + s.UpdateExpiresAt() + }) +} + +// ClearExpiresAt clears the value of the "expires_at" field. +func (u *PromoCodeUpsertOne) ClearExpiresAt() *PromoCodeUpsertOne { + return u.Update(func(s *PromoCodeUpsert) { + s.ClearExpiresAt() + }) +} + +// SetNotes sets the "notes" field. +func (u *PromoCodeUpsertOne) SetNotes(v string) *PromoCodeUpsertOne { + return u.Update(func(s *PromoCodeUpsert) { + s.SetNotes(v) + }) +} + +// UpdateNotes sets the "notes" field to the value that was provided on create. +func (u *PromoCodeUpsertOne) UpdateNotes() *PromoCodeUpsertOne { + return u.Update(func(s *PromoCodeUpsert) { + s.UpdateNotes() + }) +} + +// ClearNotes clears the value of the "notes" field. +func (u *PromoCodeUpsertOne) ClearNotes() *PromoCodeUpsertOne { + return u.Update(func(s *PromoCodeUpsert) { + s.ClearNotes() + }) +} + +// SetUpdatedAt sets the "updated_at" field. +func (u *PromoCodeUpsertOne) SetUpdatedAt(v time.Time) *PromoCodeUpsertOne { + return u.Update(func(s *PromoCodeUpsert) { + s.SetUpdatedAt(v) + }) +} + +// UpdateUpdatedAt sets the "updated_at" field to the value that was provided on create. +func (u *PromoCodeUpsertOne) UpdateUpdatedAt() *PromoCodeUpsertOne { + return u.Update(func(s *PromoCodeUpsert) { + s.UpdateUpdatedAt() + }) +} + +// Exec executes the query. +func (u *PromoCodeUpsertOne) Exec(ctx context.Context) error { + if len(u.create.conflict) == 0 { + return errors.New("ent: missing options for PromoCodeCreate.OnConflict") + } + return u.create.Exec(ctx) +} + +// ExecX is like Exec, but panics if an error occurs. +func (u *PromoCodeUpsertOne) 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 *PromoCodeUpsertOne) 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 *PromoCodeUpsertOne) IDX(ctx context.Context) int64 { + id, err := u.ID(ctx) + if err != nil { + panic(err) + } + return id +} + +// PromoCodeCreateBulk is the builder for creating many PromoCode entities in bulk. +type PromoCodeCreateBulk struct { + config + err error + builders []*PromoCodeCreate + conflict []sql.ConflictOption +} + +// Save creates the PromoCode entities in the database. +func (_c *PromoCodeCreateBulk) Save(ctx context.Context) ([]*PromoCode, error) { + if _c.err != nil { + return nil, _c.err + } + specs := make([]*sqlgraph.CreateSpec, len(_c.builders)) + nodes := make([]*PromoCode, 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.(*PromoCodeMutation) + 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 *PromoCodeCreateBulk) SaveX(ctx context.Context) []*PromoCode { + v, err := _c.Save(ctx) + if err != nil { + panic(err) + } + return v +} + +// Exec executes the query. +func (_c *PromoCodeCreateBulk) Exec(ctx context.Context) error { + _, err := _c.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_c *PromoCodeCreateBulk) 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.PromoCode.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.PromoCodeUpsert) { +// SetCode(v+v). +// }). +// Exec(ctx) +func (_c *PromoCodeCreateBulk) OnConflict(opts ...sql.ConflictOption) *PromoCodeUpsertBulk { + _c.conflict = opts + return &PromoCodeUpsertBulk{ + create: _c, + } +} + +// OnConflictColumns calls `OnConflict` and configures the columns +// as conflict target. Using this option is equivalent to using: +// +// client.PromoCode.Create(). +// OnConflict(sql.ConflictColumns(columns...)). +// Exec(ctx) +func (_c *PromoCodeCreateBulk) OnConflictColumns(columns ...string) *PromoCodeUpsertBulk { + _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...)) + return &PromoCodeUpsertBulk{ + create: _c, + } +} + +// PromoCodeUpsertBulk is the builder for "upsert"-ing +// a bulk of PromoCode nodes. +type PromoCodeUpsertBulk struct { + create *PromoCodeCreateBulk +} + +// UpdateNewValues updates the mutable fields using the new values that +// were set on create. Using this option is equivalent to using: +// +// client.PromoCode.Create(). +// OnConflict( +// sql.ResolveWithNewValues(), +// ). +// Exec(ctx) +func (u *PromoCodeUpsertBulk) UpdateNewValues() *PromoCodeUpsertBulk { + 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(promocode.FieldCreatedAt) + } + } + })) + return u +} + +// Ignore sets each column to itself in case of conflict. +// Using this option is equivalent to using: +// +// client.PromoCode.Create(). +// OnConflict(sql.ResolveWithIgnore()). +// Exec(ctx) +func (u *PromoCodeUpsertBulk) Ignore() *PromoCodeUpsertBulk { + 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 *PromoCodeUpsertBulk) DoNothing() *PromoCodeUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.DoNothing()) + return u +} + +// Update allows overriding fields `UPDATE` values. See the PromoCodeCreateBulk.OnConflict +// documentation for more info. +func (u *PromoCodeUpsertBulk) Update(set func(*PromoCodeUpsert)) *PromoCodeUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) { + set(&PromoCodeUpsert{UpdateSet: update}) + })) + return u +} + +// SetCode sets the "code" field. +func (u *PromoCodeUpsertBulk) SetCode(v string) *PromoCodeUpsertBulk { + return u.Update(func(s *PromoCodeUpsert) { + s.SetCode(v) + }) +} + +// UpdateCode sets the "code" field to the value that was provided on create. +func (u *PromoCodeUpsertBulk) UpdateCode() *PromoCodeUpsertBulk { + return u.Update(func(s *PromoCodeUpsert) { + s.UpdateCode() + }) +} + +// SetBonusAmount sets the "bonus_amount" field. +func (u *PromoCodeUpsertBulk) SetBonusAmount(v float64) *PromoCodeUpsertBulk { + return u.Update(func(s *PromoCodeUpsert) { + s.SetBonusAmount(v) + }) +} + +// AddBonusAmount adds v to the "bonus_amount" field. +func (u *PromoCodeUpsertBulk) AddBonusAmount(v float64) *PromoCodeUpsertBulk { + return u.Update(func(s *PromoCodeUpsert) { + s.AddBonusAmount(v) + }) +} + +// UpdateBonusAmount sets the "bonus_amount" field to the value that was provided on create. +func (u *PromoCodeUpsertBulk) UpdateBonusAmount() *PromoCodeUpsertBulk { + return u.Update(func(s *PromoCodeUpsert) { + s.UpdateBonusAmount() + }) +} + +// SetMaxUses sets the "max_uses" field. +func (u *PromoCodeUpsertBulk) SetMaxUses(v int) *PromoCodeUpsertBulk { + return u.Update(func(s *PromoCodeUpsert) { + s.SetMaxUses(v) + }) +} + +// AddMaxUses adds v to the "max_uses" field. +func (u *PromoCodeUpsertBulk) AddMaxUses(v int) *PromoCodeUpsertBulk { + return u.Update(func(s *PromoCodeUpsert) { + s.AddMaxUses(v) + }) +} + +// UpdateMaxUses sets the "max_uses" field to the value that was provided on create. +func (u *PromoCodeUpsertBulk) UpdateMaxUses() *PromoCodeUpsertBulk { + return u.Update(func(s *PromoCodeUpsert) { + s.UpdateMaxUses() + }) +} + +// SetUsedCount sets the "used_count" field. +func (u *PromoCodeUpsertBulk) SetUsedCount(v int) *PromoCodeUpsertBulk { + return u.Update(func(s *PromoCodeUpsert) { + s.SetUsedCount(v) + }) +} + +// AddUsedCount adds v to the "used_count" field. +func (u *PromoCodeUpsertBulk) AddUsedCount(v int) *PromoCodeUpsertBulk { + return u.Update(func(s *PromoCodeUpsert) { + s.AddUsedCount(v) + }) +} + +// UpdateUsedCount sets the "used_count" field to the value that was provided on create. +func (u *PromoCodeUpsertBulk) UpdateUsedCount() *PromoCodeUpsertBulk { + return u.Update(func(s *PromoCodeUpsert) { + s.UpdateUsedCount() + }) +} + +// SetStatus sets the "status" field. +func (u *PromoCodeUpsertBulk) SetStatus(v string) *PromoCodeUpsertBulk { + return u.Update(func(s *PromoCodeUpsert) { + s.SetStatus(v) + }) +} + +// UpdateStatus sets the "status" field to the value that was provided on create. +func (u *PromoCodeUpsertBulk) UpdateStatus() *PromoCodeUpsertBulk { + return u.Update(func(s *PromoCodeUpsert) { + s.UpdateStatus() + }) +} + +// SetExpiresAt sets the "expires_at" field. +func (u *PromoCodeUpsertBulk) SetExpiresAt(v time.Time) *PromoCodeUpsertBulk { + return u.Update(func(s *PromoCodeUpsert) { + s.SetExpiresAt(v) + }) +} + +// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create. +func (u *PromoCodeUpsertBulk) UpdateExpiresAt() *PromoCodeUpsertBulk { + return u.Update(func(s *PromoCodeUpsert) { + s.UpdateExpiresAt() + }) +} + +// ClearExpiresAt clears the value of the "expires_at" field. +func (u *PromoCodeUpsertBulk) ClearExpiresAt() *PromoCodeUpsertBulk { + return u.Update(func(s *PromoCodeUpsert) { + s.ClearExpiresAt() + }) +} + +// SetNotes sets the "notes" field. +func (u *PromoCodeUpsertBulk) SetNotes(v string) *PromoCodeUpsertBulk { + return u.Update(func(s *PromoCodeUpsert) { + s.SetNotes(v) + }) +} + +// UpdateNotes sets the "notes" field to the value that was provided on create. +func (u *PromoCodeUpsertBulk) UpdateNotes() *PromoCodeUpsertBulk { + return u.Update(func(s *PromoCodeUpsert) { + s.UpdateNotes() + }) +} + +// ClearNotes clears the value of the "notes" field. +func (u *PromoCodeUpsertBulk) ClearNotes() *PromoCodeUpsertBulk { + return u.Update(func(s *PromoCodeUpsert) { + s.ClearNotes() + }) +} + +// SetUpdatedAt sets the "updated_at" field. +func (u *PromoCodeUpsertBulk) SetUpdatedAt(v time.Time) *PromoCodeUpsertBulk { + return u.Update(func(s *PromoCodeUpsert) { + s.SetUpdatedAt(v) + }) +} + +// UpdateUpdatedAt sets the "updated_at" field to the value that was provided on create. +func (u *PromoCodeUpsertBulk) UpdateUpdatedAt() *PromoCodeUpsertBulk { + return u.Update(func(s *PromoCodeUpsert) { + s.UpdateUpdatedAt() + }) +} + +// Exec executes the query. +func (u *PromoCodeUpsertBulk) 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 PromoCodeCreateBulk instead", i) + } + } + if len(u.create.conflict) == 0 { + return errors.New("ent: missing options for PromoCodeCreateBulk.OnConflict") + } + return u.create.Exec(ctx) +} + +// ExecX is like Exec, but panics if an error occurs. +func (u *PromoCodeUpsertBulk) ExecX(ctx context.Context) { + if err := u.create.Exec(ctx); err != nil { + panic(err) + } +} diff --git a/backend/ent/promocode_delete.go b/backend/ent/promocode_delete.go new file mode 100644 index 00000000..7e4fa3a6 --- /dev/null +++ b/backend/ent/promocode_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/predicate" + "github.com/Wei-Shaw/sub2api/ent/promocode" +) + +// PromoCodeDelete is the builder for deleting a PromoCode entity. +type PromoCodeDelete struct { + config + hooks []Hook + mutation *PromoCodeMutation +} + +// Where appends a list predicates to the PromoCodeDelete builder. +func (_d *PromoCodeDelete) Where(ps ...predicate.PromoCode) *PromoCodeDelete { + _d.mutation.Where(ps...) + return _d +} + +// Exec executes the deletion query and returns how many vertices were deleted. +func (_d *PromoCodeDelete) 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 *PromoCodeDelete) ExecX(ctx context.Context) int { + n, err := _d.Exec(ctx) + if err != nil { + panic(err) + } + return n +} + +func (_d *PromoCodeDelete) sqlExec(ctx context.Context) (int, error) { + _spec := sqlgraph.NewDeleteSpec(promocode.Table, sqlgraph.NewFieldSpec(promocode.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 +} + +// PromoCodeDeleteOne is the builder for deleting a single PromoCode entity. +type PromoCodeDeleteOne struct { + _d *PromoCodeDelete +} + +// Where appends a list predicates to the PromoCodeDelete builder. +func (_d *PromoCodeDeleteOne) Where(ps ...predicate.PromoCode) *PromoCodeDeleteOne { + _d._d.mutation.Where(ps...) + return _d +} + +// Exec executes the deletion query. +func (_d *PromoCodeDeleteOne) Exec(ctx context.Context) error { + n, err := _d._d.Exec(ctx) + switch { + case err != nil: + return err + case n == 0: + return &NotFoundError{promocode.Label} + default: + return nil + } +} + +// ExecX is like Exec, but panics if an error occurs. +func (_d *PromoCodeDeleteOne) ExecX(ctx context.Context) { + if err := _d.Exec(ctx); err != nil { + panic(err) + } +} diff --git a/backend/ent/promocode_query.go b/backend/ent/promocode_query.go new file mode 100644 index 00000000..2156b0f0 --- /dev/null +++ b/backend/ent/promocode_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/predicate" + "github.com/Wei-Shaw/sub2api/ent/promocode" + "github.com/Wei-Shaw/sub2api/ent/promocodeusage" +) + +// PromoCodeQuery is the builder for querying PromoCode entities. +type PromoCodeQuery struct { + config + ctx *QueryContext + order []promocode.OrderOption + inters []Interceptor + predicates []predicate.PromoCode + withUsageRecords *PromoCodeUsageQuery + 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 PromoCodeQuery builder. +func (_q *PromoCodeQuery) Where(ps ...predicate.PromoCode) *PromoCodeQuery { + _q.predicates = append(_q.predicates, ps...) + return _q +} + +// Limit the number of records to be returned by this query. +func (_q *PromoCodeQuery) Limit(limit int) *PromoCodeQuery { + _q.ctx.Limit = &limit + return _q +} + +// Offset to start from. +func (_q *PromoCodeQuery) Offset(offset int) *PromoCodeQuery { + _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 *PromoCodeQuery) Unique(unique bool) *PromoCodeQuery { + _q.ctx.Unique = &unique + return _q +} + +// Order specifies how the records should be ordered. +func (_q *PromoCodeQuery) Order(o ...promocode.OrderOption) *PromoCodeQuery { + _q.order = append(_q.order, o...) + return _q +} + +// QueryUsageRecords chains the current query on the "usage_records" edge. +func (_q *PromoCodeQuery) QueryUsageRecords() *PromoCodeUsageQuery { + query := (&PromoCodeUsageClient{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(promocode.Table, promocode.FieldID, selector), + sqlgraph.To(promocodeusage.Table, promocodeusage.FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, promocode.UsageRecordsTable, promocode.UsageRecordsColumn), + ) + fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step) + return fromU, nil + } + return query +} + +// First returns the first PromoCode entity from the query. +// Returns a *NotFoundError when no PromoCode was found. +func (_q *PromoCodeQuery) First(ctx context.Context) (*PromoCode, 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{promocode.Label} + } + return nodes[0], nil +} + +// FirstX is like First, but panics if an error occurs. +func (_q *PromoCodeQuery) FirstX(ctx context.Context) *PromoCode { + node, err := _q.First(ctx) + if err != nil && !IsNotFound(err) { + panic(err) + } + return node +} + +// FirstID returns the first PromoCode ID from the query. +// Returns a *NotFoundError when no PromoCode ID was found. +func (_q *PromoCodeQuery) 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{promocode.Label} + return + } + return ids[0], nil +} + +// FirstIDX is like FirstID, but panics if an error occurs. +func (_q *PromoCodeQuery) FirstIDX(ctx context.Context) int64 { + id, err := _q.FirstID(ctx) + if err != nil && !IsNotFound(err) { + panic(err) + } + return id +} + +// Only returns a single PromoCode entity found by the query, ensuring it only returns one. +// Returns a *NotSingularError when more than one PromoCode entity is found. +// Returns a *NotFoundError when no PromoCode entities are found. +func (_q *PromoCodeQuery) Only(ctx context.Context) (*PromoCode, 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{promocode.Label} + default: + return nil, &NotSingularError{promocode.Label} + } +} + +// OnlyX is like Only, but panics if an error occurs. +func (_q *PromoCodeQuery) OnlyX(ctx context.Context) *PromoCode { + node, err := _q.Only(ctx) + if err != nil { + panic(err) + } + return node +} + +// OnlyID is like Only, but returns the only PromoCode ID in the query. +// Returns a *NotSingularError when more than one PromoCode ID is found. +// Returns a *NotFoundError when no entities are found. +func (_q *PromoCodeQuery) 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{promocode.Label} + default: + err = &NotSingularError{promocode.Label} + } + return +} + +// OnlyIDX is like OnlyID, but panics if an error occurs. +func (_q *PromoCodeQuery) 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 PromoCodes. +func (_q *PromoCodeQuery) All(ctx context.Context) ([]*PromoCode, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryAll) + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + qr := querierAll[[]*PromoCode, *PromoCodeQuery]() + return withInterceptors[[]*PromoCode](ctx, _q, qr, _q.inters) +} + +// AllX is like All, but panics if an error occurs. +func (_q *PromoCodeQuery) AllX(ctx context.Context) []*PromoCode { + nodes, err := _q.All(ctx) + if err != nil { + panic(err) + } + return nodes +} + +// IDs executes the query and returns a list of PromoCode IDs. +func (_q *PromoCodeQuery) 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(promocode.FieldID).Scan(ctx, &ids); err != nil { + return nil, err + } + return ids, nil +} + +// IDsX is like IDs, but panics if an error occurs. +func (_q *PromoCodeQuery) 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 *PromoCodeQuery) 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[*PromoCodeQuery](), _q.inters) +} + +// CountX is like Count, but panics if an error occurs. +func (_q *PromoCodeQuery) 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 *PromoCodeQuery) 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 *PromoCodeQuery) ExistX(ctx context.Context) bool { + exist, err := _q.Exist(ctx) + if err != nil { + panic(err) + } + return exist +} + +// Clone returns a duplicate of the PromoCodeQuery 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 *PromoCodeQuery) Clone() *PromoCodeQuery { + if _q == nil { + return nil + } + return &PromoCodeQuery{ + config: _q.config, + ctx: _q.ctx.Clone(), + order: append([]promocode.OrderOption{}, _q.order...), + inters: append([]Interceptor{}, _q.inters...), + predicates: append([]predicate.PromoCode{}, _q.predicates...), + withUsageRecords: _q.withUsageRecords.Clone(), + // clone intermediate query. + sql: _q.sql.Clone(), + path: _q.path, + } +} + +// WithUsageRecords tells the query-builder to eager-load the nodes that are connected to +// the "usage_records" edge. The optional arguments are used to configure the query builder of the edge. +func (_q *PromoCodeQuery) WithUsageRecords(opts ...func(*PromoCodeUsageQuery)) *PromoCodeQuery { + query := (&PromoCodeUsageClient{config: _q.config}).Query() + for _, opt := range opts { + opt(query) + } + _q.withUsageRecords = 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 { +// Code string `json:"code,omitempty"` +// Count int `json:"count,omitempty"` +// } +// +// client.PromoCode.Query(). +// GroupBy(promocode.FieldCode). +// Aggregate(ent.Count()). +// Scan(ctx, &v) +func (_q *PromoCodeQuery) GroupBy(field string, fields ...string) *PromoCodeGroupBy { + _q.ctx.Fields = append([]string{field}, fields...) + grbuild := &PromoCodeGroupBy{build: _q} + grbuild.flds = &_q.ctx.Fields + grbuild.label = promocode.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 { +// Code string `json:"code,omitempty"` +// } +// +// client.PromoCode.Query(). +// Select(promocode.FieldCode). +// Scan(ctx, &v) +func (_q *PromoCodeQuery) Select(fields ...string) *PromoCodeSelect { + _q.ctx.Fields = append(_q.ctx.Fields, fields...) + sbuild := &PromoCodeSelect{PromoCodeQuery: _q} + sbuild.label = promocode.Label + sbuild.flds, sbuild.scan = &_q.ctx.Fields, sbuild.Scan + return sbuild +} + +// Aggregate returns a PromoCodeSelect configured with the given aggregations. +func (_q *PromoCodeQuery) Aggregate(fns ...AggregateFunc) *PromoCodeSelect { + return _q.Select().Aggregate(fns...) +} + +func (_q *PromoCodeQuery) 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 !promocode.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 *PromoCodeQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*PromoCode, error) { + var ( + nodes = []*PromoCode{} + _spec = _q.querySpec() + loadedTypes = [1]bool{ + _q.withUsageRecords != nil, + } + ) + _spec.ScanValues = func(columns []string) ([]any, error) { + return (*PromoCode).scanValues(nil, columns) + } + _spec.Assign = func(columns []string, values []any) error { + node := &PromoCode{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.withUsageRecords; query != nil { + if err := _q.loadUsageRecords(ctx, query, nodes, + func(n *PromoCode) { n.Edges.UsageRecords = []*PromoCodeUsage{} }, + func(n *PromoCode, e *PromoCodeUsage) { n.Edges.UsageRecords = append(n.Edges.UsageRecords, e) }); err != nil { + return nil, err + } + } + return nodes, nil +} + +func (_q *PromoCodeQuery) loadUsageRecords(ctx context.Context, query *PromoCodeUsageQuery, nodes []*PromoCode, init func(*PromoCode), assign func(*PromoCode, *PromoCodeUsage)) error { + fks := make([]driver.Value, 0, len(nodes)) + nodeids := make(map[int64]*PromoCode) + 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(promocodeusage.FieldPromoCodeID) + } + query.Where(predicate.PromoCodeUsage(func(s *sql.Selector) { + s.Where(sql.InValues(s.C(promocode.UsageRecordsColumn), fks...)) + })) + neighbors, err := query.All(ctx) + if err != nil { + return err + } + for _, n := range neighbors { + fk := n.PromoCodeID + node, ok := nodeids[fk] + if !ok { + return fmt.Errorf(`unexpected referenced foreign-key "promo_code_id" returned %v for node %v`, fk, n.ID) + } + assign(node, n) + } + return nil +} + +func (_q *PromoCodeQuery) 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 *PromoCodeQuery) querySpec() *sqlgraph.QuerySpec { + _spec := sqlgraph.NewQuerySpec(promocode.Table, promocode.Columns, sqlgraph.NewFieldSpec(promocode.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, promocode.FieldID) + for i := range fields { + if fields[i] != promocode.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 *PromoCodeQuery) sqlQuery(ctx context.Context) *sql.Selector { + builder := sql.Dialect(_q.driver.Dialect()) + t1 := builder.Table(promocode.Table) + columns := _q.ctx.Fields + if len(columns) == 0 { + columns = promocode.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 *PromoCodeQuery) ForUpdate(opts ...sql.LockOption) *PromoCodeQuery { + 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 *PromoCodeQuery) ForShare(opts ...sql.LockOption) *PromoCodeQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForShare(opts...) + }) + return _q +} + +// PromoCodeGroupBy is the group-by builder for PromoCode entities. +type PromoCodeGroupBy struct { + selector + build *PromoCodeQuery +} + +// Aggregate adds the given aggregation functions to the group-by query. +func (_g *PromoCodeGroupBy) Aggregate(fns ...AggregateFunc) *PromoCodeGroupBy { + _g.fns = append(_g.fns, fns...) + return _g +} + +// Scan applies the selector query and scans the result into the given value. +func (_g *PromoCodeGroupBy) 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[*PromoCodeQuery, *PromoCodeGroupBy](ctx, _g.build, _g, _g.build.inters, v) +} + +func (_g *PromoCodeGroupBy) sqlScan(ctx context.Context, root *PromoCodeQuery, 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) +} + +// PromoCodeSelect is the builder for selecting fields of PromoCode entities. +type PromoCodeSelect struct { + *PromoCodeQuery + selector +} + +// Aggregate adds the given aggregation functions to the selector query. +func (_s *PromoCodeSelect) Aggregate(fns ...AggregateFunc) *PromoCodeSelect { + _s.fns = append(_s.fns, fns...) + return _s +} + +// Scan applies the selector query and scans the result into the given value. +func (_s *PromoCodeSelect) 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[*PromoCodeQuery, *PromoCodeSelect](ctx, _s.PromoCodeQuery, _s, _s.inters, v) +} + +func (_s *PromoCodeSelect) sqlScan(ctx context.Context, root *PromoCodeQuery, 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/promocode_update.go b/backend/ent/promocode_update.go new file mode 100644 index 00000000..1a7481c8 --- /dev/null +++ b/backend/ent/promocode_update.go @@ -0,0 +1,745 @@ +// 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/predicate" + "github.com/Wei-Shaw/sub2api/ent/promocode" + "github.com/Wei-Shaw/sub2api/ent/promocodeusage" +) + +// PromoCodeUpdate is the builder for updating PromoCode entities. +type PromoCodeUpdate struct { + config + hooks []Hook + mutation *PromoCodeMutation +} + +// Where appends a list predicates to the PromoCodeUpdate builder. +func (_u *PromoCodeUpdate) Where(ps ...predicate.PromoCode) *PromoCodeUpdate { + _u.mutation.Where(ps...) + return _u +} + +// SetCode sets the "code" field. +func (_u *PromoCodeUpdate) SetCode(v string) *PromoCodeUpdate { + _u.mutation.SetCode(v) + return _u +} + +// SetNillableCode sets the "code" field if the given value is not nil. +func (_u *PromoCodeUpdate) SetNillableCode(v *string) *PromoCodeUpdate { + if v != nil { + _u.SetCode(*v) + } + return _u +} + +// SetBonusAmount sets the "bonus_amount" field. +func (_u *PromoCodeUpdate) SetBonusAmount(v float64) *PromoCodeUpdate { + _u.mutation.ResetBonusAmount() + _u.mutation.SetBonusAmount(v) + return _u +} + +// SetNillableBonusAmount sets the "bonus_amount" field if the given value is not nil. +func (_u *PromoCodeUpdate) SetNillableBonusAmount(v *float64) *PromoCodeUpdate { + if v != nil { + _u.SetBonusAmount(*v) + } + return _u +} + +// AddBonusAmount adds value to the "bonus_amount" field. +func (_u *PromoCodeUpdate) AddBonusAmount(v float64) *PromoCodeUpdate { + _u.mutation.AddBonusAmount(v) + return _u +} + +// SetMaxUses sets the "max_uses" field. +func (_u *PromoCodeUpdate) SetMaxUses(v int) *PromoCodeUpdate { + _u.mutation.ResetMaxUses() + _u.mutation.SetMaxUses(v) + return _u +} + +// SetNillableMaxUses sets the "max_uses" field if the given value is not nil. +func (_u *PromoCodeUpdate) SetNillableMaxUses(v *int) *PromoCodeUpdate { + if v != nil { + _u.SetMaxUses(*v) + } + return _u +} + +// AddMaxUses adds value to the "max_uses" field. +func (_u *PromoCodeUpdate) AddMaxUses(v int) *PromoCodeUpdate { + _u.mutation.AddMaxUses(v) + return _u +} + +// SetUsedCount sets the "used_count" field. +func (_u *PromoCodeUpdate) SetUsedCount(v int) *PromoCodeUpdate { + _u.mutation.ResetUsedCount() + _u.mutation.SetUsedCount(v) + return _u +} + +// SetNillableUsedCount sets the "used_count" field if the given value is not nil. +func (_u *PromoCodeUpdate) SetNillableUsedCount(v *int) *PromoCodeUpdate { + if v != nil { + _u.SetUsedCount(*v) + } + return _u +} + +// AddUsedCount adds value to the "used_count" field. +func (_u *PromoCodeUpdate) AddUsedCount(v int) *PromoCodeUpdate { + _u.mutation.AddUsedCount(v) + return _u +} + +// SetStatus sets the "status" field. +func (_u *PromoCodeUpdate) SetStatus(v string) *PromoCodeUpdate { + _u.mutation.SetStatus(v) + return _u +} + +// SetNillableStatus sets the "status" field if the given value is not nil. +func (_u *PromoCodeUpdate) SetNillableStatus(v *string) *PromoCodeUpdate { + if v != nil { + _u.SetStatus(*v) + } + return _u +} + +// SetExpiresAt sets the "expires_at" field. +func (_u *PromoCodeUpdate) SetExpiresAt(v time.Time) *PromoCodeUpdate { + _u.mutation.SetExpiresAt(v) + return _u +} + +// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil. +func (_u *PromoCodeUpdate) SetNillableExpiresAt(v *time.Time) *PromoCodeUpdate { + if v != nil { + _u.SetExpiresAt(*v) + } + return _u +} + +// ClearExpiresAt clears the value of the "expires_at" field. +func (_u *PromoCodeUpdate) ClearExpiresAt() *PromoCodeUpdate { + _u.mutation.ClearExpiresAt() + return _u +} + +// SetNotes sets the "notes" field. +func (_u *PromoCodeUpdate) SetNotes(v string) *PromoCodeUpdate { + _u.mutation.SetNotes(v) + return _u +} + +// SetNillableNotes sets the "notes" field if the given value is not nil. +func (_u *PromoCodeUpdate) SetNillableNotes(v *string) *PromoCodeUpdate { + if v != nil { + _u.SetNotes(*v) + } + return _u +} + +// ClearNotes clears the value of the "notes" field. +func (_u *PromoCodeUpdate) ClearNotes() *PromoCodeUpdate { + _u.mutation.ClearNotes() + return _u +} + +// SetUpdatedAt sets the "updated_at" field. +func (_u *PromoCodeUpdate) SetUpdatedAt(v time.Time) *PromoCodeUpdate { + _u.mutation.SetUpdatedAt(v) + return _u +} + +// AddUsageRecordIDs adds the "usage_records" edge to the PromoCodeUsage entity by IDs. +func (_u *PromoCodeUpdate) AddUsageRecordIDs(ids ...int64) *PromoCodeUpdate { + _u.mutation.AddUsageRecordIDs(ids...) + return _u +} + +// AddUsageRecords adds the "usage_records" edges to the PromoCodeUsage entity. +func (_u *PromoCodeUpdate) AddUsageRecords(v ...*PromoCodeUsage) *PromoCodeUpdate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.AddUsageRecordIDs(ids...) +} + +// Mutation returns the PromoCodeMutation object of the builder. +func (_u *PromoCodeUpdate) Mutation() *PromoCodeMutation { + return _u.mutation +} + +// ClearUsageRecords clears all "usage_records" edges to the PromoCodeUsage entity. +func (_u *PromoCodeUpdate) ClearUsageRecords() *PromoCodeUpdate { + _u.mutation.ClearUsageRecords() + return _u +} + +// RemoveUsageRecordIDs removes the "usage_records" edge to PromoCodeUsage entities by IDs. +func (_u *PromoCodeUpdate) RemoveUsageRecordIDs(ids ...int64) *PromoCodeUpdate { + _u.mutation.RemoveUsageRecordIDs(ids...) + return _u +} + +// RemoveUsageRecords removes "usage_records" edges to PromoCodeUsage entities. +func (_u *PromoCodeUpdate) RemoveUsageRecords(v ...*PromoCodeUsage) *PromoCodeUpdate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.RemoveUsageRecordIDs(ids...) +} + +// Save executes the query and returns the number of nodes affected by the update operation. +func (_u *PromoCodeUpdate) 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 *PromoCodeUpdate) SaveX(ctx context.Context) int { + affected, err := _u.Save(ctx) + if err != nil { + panic(err) + } + return affected +} + +// Exec executes the query. +func (_u *PromoCodeUpdate) Exec(ctx context.Context) error { + _, err := _u.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_u *PromoCodeUpdate) 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 *PromoCodeUpdate) defaults() { + if _, ok := _u.mutation.UpdatedAt(); !ok { + v := promocode.UpdateDefaultUpdatedAt() + _u.mutation.SetUpdatedAt(v) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_u *PromoCodeUpdate) check() error { + if v, ok := _u.mutation.Code(); ok { + if err := promocode.CodeValidator(v); err != nil { + return &ValidationError{Name: "code", err: fmt.Errorf(`ent: validator failed for field "PromoCode.code": %w`, err)} + } + } + if v, ok := _u.mutation.Status(); ok { + if err := promocode.StatusValidator(v); err != nil { + return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "PromoCode.status": %w`, err)} + } + } + return nil +} + +func (_u *PromoCodeUpdate) sqlSave(ctx context.Context) (_node int, err error) { + if err := _u.check(); err != nil { + return _node, err + } + _spec := sqlgraph.NewUpdateSpec(promocode.Table, promocode.Columns, sqlgraph.NewFieldSpec(promocode.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.Code(); ok { + _spec.SetField(promocode.FieldCode, field.TypeString, value) + } + if value, ok := _u.mutation.BonusAmount(); ok { + _spec.SetField(promocode.FieldBonusAmount, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedBonusAmount(); ok { + _spec.AddField(promocode.FieldBonusAmount, field.TypeFloat64, value) + } + if value, ok := _u.mutation.MaxUses(); ok { + _spec.SetField(promocode.FieldMaxUses, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedMaxUses(); ok { + _spec.AddField(promocode.FieldMaxUses, field.TypeInt, value) + } + if value, ok := _u.mutation.UsedCount(); ok { + _spec.SetField(promocode.FieldUsedCount, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedUsedCount(); ok { + _spec.AddField(promocode.FieldUsedCount, field.TypeInt, value) + } + if value, ok := _u.mutation.Status(); ok { + _spec.SetField(promocode.FieldStatus, field.TypeString, value) + } + if value, ok := _u.mutation.ExpiresAt(); ok { + _spec.SetField(promocode.FieldExpiresAt, field.TypeTime, value) + } + if _u.mutation.ExpiresAtCleared() { + _spec.ClearField(promocode.FieldExpiresAt, field.TypeTime) + } + if value, ok := _u.mutation.Notes(); ok { + _spec.SetField(promocode.FieldNotes, field.TypeString, value) + } + if _u.mutation.NotesCleared() { + _spec.ClearField(promocode.FieldNotes, field.TypeString) + } + if value, ok := _u.mutation.UpdatedAt(); ok { + _spec.SetField(promocode.FieldUpdatedAt, field.TypeTime, value) + } + if _u.mutation.UsageRecordsCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: promocode.UsageRecordsTable, + Columns: []string{promocode.UsageRecordsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(promocodeusage.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.RemovedUsageRecordsIDs(); len(nodes) > 0 && !_u.mutation.UsageRecordsCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: promocode.UsageRecordsTable, + Columns: []string{promocode.UsageRecordsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(promocodeusage.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.UsageRecordsIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: promocode.UsageRecordsTable, + Columns: []string{promocode.UsageRecordsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(promocodeusage.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{promocode.Label} + } else if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return 0, err + } + _u.mutation.done = true + return _node, nil +} + +// PromoCodeUpdateOne is the builder for updating a single PromoCode entity. +type PromoCodeUpdateOne struct { + config + fields []string + hooks []Hook + mutation *PromoCodeMutation +} + +// SetCode sets the "code" field. +func (_u *PromoCodeUpdateOne) SetCode(v string) *PromoCodeUpdateOne { + _u.mutation.SetCode(v) + return _u +} + +// SetNillableCode sets the "code" field if the given value is not nil. +func (_u *PromoCodeUpdateOne) SetNillableCode(v *string) *PromoCodeUpdateOne { + if v != nil { + _u.SetCode(*v) + } + return _u +} + +// SetBonusAmount sets the "bonus_amount" field. +func (_u *PromoCodeUpdateOne) SetBonusAmount(v float64) *PromoCodeUpdateOne { + _u.mutation.ResetBonusAmount() + _u.mutation.SetBonusAmount(v) + return _u +} + +// SetNillableBonusAmount sets the "bonus_amount" field if the given value is not nil. +func (_u *PromoCodeUpdateOne) SetNillableBonusAmount(v *float64) *PromoCodeUpdateOne { + if v != nil { + _u.SetBonusAmount(*v) + } + return _u +} + +// AddBonusAmount adds value to the "bonus_amount" field. +func (_u *PromoCodeUpdateOne) AddBonusAmount(v float64) *PromoCodeUpdateOne { + _u.mutation.AddBonusAmount(v) + return _u +} + +// SetMaxUses sets the "max_uses" field. +func (_u *PromoCodeUpdateOne) SetMaxUses(v int) *PromoCodeUpdateOne { + _u.mutation.ResetMaxUses() + _u.mutation.SetMaxUses(v) + return _u +} + +// SetNillableMaxUses sets the "max_uses" field if the given value is not nil. +func (_u *PromoCodeUpdateOne) SetNillableMaxUses(v *int) *PromoCodeUpdateOne { + if v != nil { + _u.SetMaxUses(*v) + } + return _u +} + +// AddMaxUses adds value to the "max_uses" field. +func (_u *PromoCodeUpdateOne) AddMaxUses(v int) *PromoCodeUpdateOne { + _u.mutation.AddMaxUses(v) + return _u +} + +// SetUsedCount sets the "used_count" field. +func (_u *PromoCodeUpdateOne) SetUsedCount(v int) *PromoCodeUpdateOne { + _u.mutation.ResetUsedCount() + _u.mutation.SetUsedCount(v) + return _u +} + +// SetNillableUsedCount sets the "used_count" field if the given value is not nil. +func (_u *PromoCodeUpdateOne) SetNillableUsedCount(v *int) *PromoCodeUpdateOne { + if v != nil { + _u.SetUsedCount(*v) + } + return _u +} + +// AddUsedCount adds value to the "used_count" field. +func (_u *PromoCodeUpdateOne) AddUsedCount(v int) *PromoCodeUpdateOne { + _u.mutation.AddUsedCount(v) + return _u +} + +// SetStatus sets the "status" field. +func (_u *PromoCodeUpdateOne) SetStatus(v string) *PromoCodeUpdateOne { + _u.mutation.SetStatus(v) + return _u +} + +// SetNillableStatus sets the "status" field if the given value is not nil. +func (_u *PromoCodeUpdateOne) SetNillableStatus(v *string) *PromoCodeUpdateOne { + if v != nil { + _u.SetStatus(*v) + } + return _u +} + +// SetExpiresAt sets the "expires_at" field. +func (_u *PromoCodeUpdateOne) SetExpiresAt(v time.Time) *PromoCodeUpdateOne { + _u.mutation.SetExpiresAt(v) + return _u +} + +// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil. +func (_u *PromoCodeUpdateOne) SetNillableExpiresAt(v *time.Time) *PromoCodeUpdateOne { + if v != nil { + _u.SetExpiresAt(*v) + } + return _u +} + +// ClearExpiresAt clears the value of the "expires_at" field. +func (_u *PromoCodeUpdateOne) ClearExpiresAt() *PromoCodeUpdateOne { + _u.mutation.ClearExpiresAt() + return _u +} + +// SetNotes sets the "notes" field. +func (_u *PromoCodeUpdateOne) SetNotes(v string) *PromoCodeUpdateOne { + _u.mutation.SetNotes(v) + return _u +} + +// SetNillableNotes sets the "notes" field if the given value is not nil. +func (_u *PromoCodeUpdateOne) SetNillableNotes(v *string) *PromoCodeUpdateOne { + if v != nil { + _u.SetNotes(*v) + } + return _u +} + +// ClearNotes clears the value of the "notes" field. +func (_u *PromoCodeUpdateOne) ClearNotes() *PromoCodeUpdateOne { + _u.mutation.ClearNotes() + return _u +} + +// SetUpdatedAt sets the "updated_at" field. +func (_u *PromoCodeUpdateOne) SetUpdatedAt(v time.Time) *PromoCodeUpdateOne { + _u.mutation.SetUpdatedAt(v) + return _u +} + +// AddUsageRecordIDs adds the "usage_records" edge to the PromoCodeUsage entity by IDs. +func (_u *PromoCodeUpdateOne) AddUsageRecordIDs(ids ...int64) *PromoCodeUpdateOne { + _u.mutation.AddUsageRecordIDs(ids...) + return _u +} + +// AddUsageRecords adds the "usage_records" edges to the PromoCodeUsage entity. +func (_u *PromoCodeUpdateOne) AddUsageRecords(v ...*PromoCodeUsage) *PromoCodeUpdateOne { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.AddUsageRecordIDs(ids...) +} + +// Mutation returns the PromoCodeMutation object of the builder. +func (_u *PromoCodeUpdateOne) Mutation() *PromoCodeMutation { + return _u.mutation +} + +// ClearUsageRecords clears all "usage_records" edges to the PromoCodeUsage entity. +func (_u *PromoCodeUpdateOne) ClearUsageRecords() *PromoCodeUpdateOne { + _u.mutation.ClearUsageRecords() + return _u +} + +// RemoveUsageRecordIDs removes the "usage_records" edge to PromoCodeUsage entities by IDs. +func (_u *PromoCodeUpdateOne) RemoveUsageRecordIDs(ids ...int64) *PromoCodeUpdateOne { + _u.mutation.RemoveUsageRecordIDs(ids...) + return _u +} + +// RemoveUsageRecords removes "usage_records" edges to PromoCodeUsage entities. +func (_u *PromoCodeUpdateOne) RemoveUsageRecords(v ...*PromoCodeUsage) *PromoCodeUpdateOne { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.RemoveUsageRecordIDs(ids...) +} + +// Where appends a list predicates to the PromoCodeUpdate builder. +func (_u *PromoCodeUpdateOne) Where(ps ...predicate.PromoCode) *PromoCodeUpdateOne { + _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 *PromoCodeUpdateOne) Select(field string, fields ...string) *PromoCodeUpdateOne { + _u.fields = append([]string{field}, fields...) + return _u +} + +// Save executes the query and returns the updated PromoCode entity. +func (_u *PromoCodeUpdateOne) Save(ctx context.Context) (*PromoCode, error) { + _u.defaults() + return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks) +} + +// SaveX is like Save, but panics if an error occurs. +func (_u *PromoCodeUpdateOne) SaveX(ctx context.Context) *PromoCode { + node, err := _u.Save(ctx) + if err != nil { + panic(err) + } + return node +} + +// Exec executes the query on the entity. +func (_u *PromoCodeUpdateOne) Exec(ctx context.Context) error { + _, err := _u.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_u *PromoCodeUpdateOne) 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 *PromoCodeUpdateOne) defaults() { + if _, ok := _u.mutation.UpdatedAt(); !ok { + v := promocode.UpdateDefaultUpdatedAt() + _u.mutation.SetUpdatedAt(v) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_u *PromoCodeUpdateOne) check() error { + if v, ok := _u.mutation.Code(); ok { + if err := promocode.CodeValidator(v); err != nil { + return &ValidationError{Name: "code", err: fmt.Errorf(`ent: validator failed for field "PromoCode.code": %w`, err)} + } + } + if v, ok := _u.mutation.Status(); ok { + if err := promocode.StatusValidator(v); err != nil { + return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "PromoCode.status": %w`, err)} + } + } + return nil +} + +func (_u *PromoCodeUpdateOne) sqlSave(ctx context.Context) (_node *PromoCode, err error) { + if err := _u.check(); err != nil { + return _node, err + } + _spec := sqlgraph.NewUpdateSpec(promocode.Table, promocode.Columns, sqlgraph.NewFieldSpec(promocode.FieldID, field.TypeInt64)) + id, ok := _u.mutation.ID() + if !ok { + return nil, &ValidationError{Name: "id", err: errors.New(`ent: missing "PromoCode.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, promocode.FieldID) + for _, f := range fields { + if !promocode.ValidColumn(f) { + return nil, &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)} + } + if f != promocode.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.Code(); ok { + _spec.SetField(promocode.FieldCode, field.TypeString, value) + } + if value, ok := _u.mutation.BonusAmount(); ok { + _spec.SetField(promocode.FieldBonusAmount, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedBonusAmount(); ok { + _spec.AddField(promocode.FieldBonusAmount, field.TypeFloat64, value) + } + if value, ok := _u.mutation.MaxUses(); ok { + _spec.SetField(promocode.FieldMaxUses, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedMaxUses(); ok { + _spec.AddField(promocode.FieldMaxUses, field.TypeInt, value) + } + if value, ok := _u.mutation.UsedCount(); ok { + _spec.SetField(promocode.FieldUsedCount, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedUsedCount(); ok { + _spec.AddField(promocode.FieldUsedCount, field.TypeInt, value) + } + if value, ok := _u.mutation.Status(); ok { + _spec.SetField(promocode.FieldStatus, field.TypeString, value) + } + if value, ok := _u.mutation.ExpiresAt(); ok { + _spec.SetField(promocode.FieldExpiresAt, field.TypeTime, value) + } + if _u.mutation.ExpiresAtCleared() { + _spec.ClearField(promocode.FieldExpiresAt, field.TypeTime) + } + if value, ok := _u.mutation.Notes(); ok { + _spec.SetField(promocode.FieldNotes, field.TypeString, value) + } + if _u.mutation.NotesCleared() { + _spec.ClearField(promocode.FieldNotes, field.TypeString) + } + if value, ok := _u.mutation.UpdatedAt(); ok { + _spec.SetField(promocode.FieldUpdatedAt, field.TypeTime, value) + } + if _u.mutation.UsageRecordsCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: promocode.UsageRecordsTable, + Columns: []string{promocode.UsageRecordsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(promocodeusage.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.RemovedUsageRecordsIDs(); len(nodes) > 0 && !_u.mutation.UsageRecordsCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: promocode.UsageRecordsTable, + Columns: []string{promocode.UsageRecordsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(promocodeusage.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.UsageRecordsIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: promocode.UsageRecordsTable, + Columns: []string{promocode.UsageRecordsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(promocodeusage.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } + _node = &PromoCode{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{promocode.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/promocodeusage.go b/backend/ent/promocodeusage.go new file mode 100644 index 00000000..1ba3a8bf --- /dev/null +++ b/backend/ent/promocodeusage.go @@ -0,0 +1,187 @@ +// 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/promocode" + "github.com/Wei-Shaw/sub2api/ent/promocodeusage" + "github.com/Wei-Shaw/sub2api/ent/user" +) + +// PromoCodeUsage is the model entity for the PromoCodeUsage schema. +type PromoCodeUsage struct { + config `json:"-"` + // ID of the ent. + ID int64 `json:"id,omitempty"` + // 优惠码ID + PromoCodeID int64 `json:"promo_code_id,omitempty"` + // 使用用户ID + UserID int64 `json:"user_id,omitempty"` + // 实际赠送金额 + BonusAmount float64 `json:"bonus_amount,omitempty"` + // 使用时间 + UsedAt time.Time `json:"used_at,omitempty"` + // Edges holds the relations/edges for other nodes in the graph. + // The values are being populated by the PromoCodeUsageQuery when eager-loading is set. + Edges PromoCodeUsageEdges `json:"edges"` + selectValues sql.SelectValues +} + +// PromoCodeUsageEdges holds the relations/edges for other nodes in the graph. +type PromoCodeUsageEdges struct { + // PromoCode holds the value of the promo_code edge. + PromoCode *PromoCode `json:"promo_code,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 +} + +// PromoCodeOrErr returns the PromoCode value or an error if the edge +// was not loaded in eager-loading, or loaded but was not found. +func (e PromoCodeUsageEdges) PromoCodeOrErr() (*PromoCode, error) { + if e.PromoCode != nil { + return e.PromoCode, nil + } else if e.loadedTypes[0] { + return nil, &NotFoundError{label: promocode.Label} + } + return nil, &NotLoadedError{edge: "promo_code"} +} + +// 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 PromoCodeUsageEdges) 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 (*PromoCodeUsage) scanValues(columns []string) ([]any, error) { + values := make([]any, len(columns)) + for i := range columns { + switch columns[i] { + case promocodeusage.FieldBonusAmount: + values[i] = new(sql.NullFloat64) + case promocodeusage.FieldID, promocodeusage.FieldPromoCodeID, promocodeusage.FieldUserID: + values[i] = new(sql.NullInt64) + case promocodeusage.FieldUsedAt: + 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 PromoCodeUsage fields. +func (_m *PromoCodeUsage) 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 promocodeusage.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 promocodeusage.FieldPromoCodeID: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field promo_code_id", values[i]) + } else if value.Valid { + _m.PromoCodeID = value.Int64 + } + case promocodeusage.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 promocodeusage.FieldBonusAmount: + if value, ok := values[i].(*sql.NullFloat64); !ok { + return fmt.Errorf("unexpected type %T for field bonus_amount", values[i]) + } else if value.Valid { + _m.BonusAmount = value.Float64 + } + case promocodeusage.FieldUsedAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field used_at", values[i]) + } else if value.Valid { + _m.UsedAt = 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 PromoCodeUsage. +// This includes values selected through modifiers, order, etc. +func (_m *PromoCodeUsage) Value(name string) (ent.Value, error) { + return _m.selectValues.Get(name) +} + +// QueryPromoCode queries the "promo_code" edge of the PromoCodeUsage entity. +func (_m *PromoCodeUsage) QueryPromoCode() *PromoCodeQuery { + return NewPromoCodeUsageClient(_m.config).QueryPromoCode(_m) +} + +// QueryUser queries the "user" edge of the PromoCodeUsage entity. +func (_m *PromoCodeUsage) QueryUser() *UserQuery { + return NewPromoCodeUsageClient(_m.config).QueryUser(_m) +} + +// Update returns a builder for updating this PromoCodeUsage. +// Note that you need to call PromoCodeUsage.Unwrap() before calling this method if this PromoCodeUsage +// was returned from a transaction, and the transaction was committed or rolled back. +func (_m *PromoCodeUsage) Update() *PromoCodeUsageUpdateOne { + return NewPromoCodeUsageClient(_m.config).UpdateOne(_m) +} + +// Unwrap unwraps the PromoCodeUsage 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 *PromoCodeUsage) Unwrap() *PromoCodeUsage { + _tx, ok := _m.config.driver.(*txDriver) + if !ok { + panic("ent: PromoCodeUsage is not a transactional entity") + } + _m.config.driver = _tx.drv + return _m +} + +// String implements the fmt.Stringer. +func (_m *PromoCodeUsage) String() string { + var builder strings.Builder + builder.WriteString("PromoCodeUsage(") + builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID)) + builder.WriteString("promo_code_id=") + builder.WriteString(fmt.Sprintf("%v", _m.PromoCodeID)) + builder.WriteString(", ") + builder.WriteString("user_id=") + builder.WriteString(fmt.Sprintf("%v", _m.UserID)) + builder.WriteString(", ") + builder.WriteString("bonus_amount=") + builder.WriteString(fmt.Sprintf("%v", _m.BonusAmount)) + builder.WriteString(", ") + builder.WriteString("used_at=") + builder.WriteString(_m.UsedAt.Format(time.ANSIC)) + builder.WriteByte(')') + return builder.String() +} + +// PromoCodeUsages is a parsable slice of PromoCodeUsage. +type PromoCodeUsages []*PromoCodeUsage diff --git a/backend/ent/promocodeusage/promocodeusage.go b/backend/ent/promocodeusage/promocodeusage.go new file mode 100644 index 00000000..f4e05970 --- /dev/null +++ b/backend/ent/promocodeusage/promocodeusage.go @@ -0,0 +1,125 @@ +// Code generated by ent, DO NOT EDIT. + +package promocodeusage + +import ( + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" +) + +const ( + // Label holds the string label denoting the promocodeusage type in the database. + Label = "promo_code_usage" + // FieldID holds the string denoting the id field in the database. + FieldID = "id" + // FieldPromoCodeID holds the string denoting the promo_code_id field in the database. + FieldPromoCodeID = "promo_code_id" + // FieldUserID holds the string denoting the user_id field in the database. + FieldUserID = "user_id" + // FieldBonusAmount holds the string denoting the bonus_amount field in the database. + FieldBonusAmount = "bonus_amount" + // FieldUsedAt holds the string denoting the used_at field in the database. + FieldUsedAt = "used_at" + // EdgePromoCode holds the string denoting the promo_code edge name in mutations. + EdgePromoCode = "promo_code" + // EdgeUser holds the string denoting the user edge name in mutations. + EdgeUser = "user" + // Table holds the table name of the promocodeusage in the database. + Table = "promo_code_usages" + // PromoCodeTable is the table that holds the promo_code relation/edge. + PromoCodeTable = "promo_code_usages" + // PromoCodeInverseTable is the table name for the PromoCode entity. + // It exists in this package in order to avoid circular dependency with the "promocode" package. + PromoCodeInverseTable = "promo_codes" + // PromoCodeColumn is the table column denoting the promo_code relation/edge. + PromoCodeColumn = "promo_code_id" + // UserTable is the table that holds the user relation/edge. + UserTable = "promo_code_usages" + // 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 promocodeusage fields. +var Columns = []string{ + FieldID, + FieldPromoCodeID, + FieldUserID, + FieldBonusAmount, + FieldUsedAt, +} + +// 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 ( + // DefaultUsedAt holds the default value on creation for the "used_at" field. + DefaultUsedAt func() time.Time +) + +// OrderOption defines the ordering options for the PromoCodeUsage 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() +} + +// ByPromoCodeID orders the results by the promo_code_id field. +func ByPromoCodeID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldPromoCodeID, opts...).ToFunc() +} + +// ByUserID orders the results by the user_id field. +func ByUserID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUserID, opts...).ToFunc() +} + +// ByBonusAmount orders the results by the bonus_amount field. +func ByBonusAmount(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldBonusAmount, opts...).ToFunc() +} + +// ByUsedAt orders the results by the used_at field. +func ByUsedAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUsedAt, opts...).ToFunc() +} + +// ByPromoCodeField orders the results by promo_code field. +func ByPromoCodeField(field string, opts ...sql.OrderTermOption) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborTerms(s, newPromoCodeStep(), 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 newPromoCodeStep() *sqlgraph.Step { + return sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.To(PromoCodeInverseTable, FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, PromoCodeTable, PromoCodeColumn), + ) +} +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/promocodeusage/where.go b/backend/ent/promocodeusage/where.go new file mode 100644 index 00000000..fe657fd4 --- /dev/null +++ b/backend/ent/promocodeusage/where.go @@ -0,0 +1,257 @@ +// Code generated by ent, DO NOT EDIT. + +package promocodeusage + +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.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldEQ(FieldID, id)) +} + +// IDEQ applies the EQ predicate on the ID field. +func IDEQ(id int64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldEQ(FieldID, id)) +} + +// IDNEQ applies the NEQ predicate on the ID field. +func IDNEQ(id int64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldNEQ(FieldID, id)) +} + +// IDIn applies the In predicate on the ID field. +func IDIn(ids ...int64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldIn(FieldID, ids...)) +} + +// IDNotIn applies the NotIn predicate on the ID field. +func IDNotIn(ids ...int64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldNotIn(FieldID, ids...)) +} + +// IDGT applies the GT predicate on the ID field. +func IDGT(id int64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldGT(FieldID, id)) +} + +// IDGTE applies the GTE predicate on the ID field. +func IDGTE(id int64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldGTE(FieldID, id)) +} + +// IDLT applies the LT predicate on the ID field. +func IDLT(id int64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldLT(FieldID, id)) +} + +// IDLTE applies the LTE predicate on the ID field. +func IDLTE(id int64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldLTE(FieldID, id)) +} + +// PromoCodeID applies equality check predicate on the "promo_code_id" field. It's identical to PromoCodeIDEQ. +func PromoCodeID(v int64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldEQ(FieldPromoCodeID, v)) +} + +// UserID applies equality check predicate on the "user_id" field. It's identical to UserIDEQ. +func UserID(v int64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldEQ(FieldUserID, v)) +} + +// BonusAmount applies equality check predicate on the "bonus_amount" field. It's identical to BonusAmountEQ. +func BonusAmount(v float64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldEQ(FieldBonusAmount, v)) +} + +// UsedAt applies equality check predicate on the "used_at" field. It's identical to UsedAtEQ. +func UsedAt(v time.Time) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldEQ(FieldUsedAt, v)) +} + +// PromoCodeIDEQ applies the EQ predicate on the "promo_code_id" field. +func PromoCodeIDEQ(v int64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldEQ(FieldPromoCodeID, v)) +} + +// PromoCodeIDNEQ applies the NEQ predicate on the "promo_code_id" field. +func PromoCodeIDNEQ(v int64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldNEQ(FieldPromoCodeID, v)) +} + +// PromoCodeIDIn applies the In predicate on the "promo_code_id" field. +func PromoCodeIDIn(vs ...int64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldIn(FieldPromoCodeID, vs...)) +} + +// PromoCodeIDNotIn applies the NotIn predicate on the "promo_code_id" field. +func PromoCodeIDNotIn(vs ...int64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldNotIn(FieldPromoCodeID, vs...)) +} + +// UserIDEQ applies the EQ predicate on the "user_id" field. +func UserIDEQ(v int64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldEQ(FieldUserID, v)) +} + +// UserIDNEQ applies the NEQ predicate on the "user_id" field. +func UserIDNEQ(v int64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldNEQ(FieldUserID, v)) +} + +// UserIDIn applies the In predicate on the "user_id" field. +func UserIDIn(vs ...int64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldIn(FieldUserID, vs...)) +} + +// UserIDNotIn applies the NotIn predicate on the "user_id" field. +func UserIDNotIn(vs ...int64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldNotIn(FieldUserID, vs...)) +} + +// BonusAmountEQ applies the EQ predicate on the "bonus_amount" field. +func BonusAmountEQ(v float64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldEQ(FieldBonusAmount, v)) +} + +// BonusAmountNEQ applies the NEQ predicate on the "bonus_amount" field. +func BonusAmountNEQ(v float64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldNEQ(FieldBonusAmount, v)) +} + +// BonusAmountIn applies the In predicate on the "bonus_amount" field. +func BonusAmountIn(vs ...float64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldIn(FieldBonusAmount, vs...)) +} + +// BonusAmountNotIn applies the NotIn predicate on the "bonus_amount" field. +func BonusAmountNotIn(vs ...float64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldNotIn(FieldBonusAmount, vs...)) +} + +// BonusAmountGT applies the GT predicate on the "bonus_amount" field. +func BonusAmountGT(v float64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldGT(FieldBonusAmount, v)) +} + +// BonusAmountGTE applies the GTE predicate on the "bonus_amount" field. +func BonusAmountGTE(v float64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldGTE(FieldBonusAmount, v)) +} + +// BonusAmountLT applies the LT predicate on the "bonus_amount" field. +func BonusAmountLT(v float64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldLT(FieldBonusAmount, v)) +} + +// BonusAmountLTE applies the LTE predicate on the "bonus_amount" field. +func BonusAmountLTE(v float64) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldLTE(FieldBonusAmount, v)) +} + +// UsedAtEQ applies the EQ predicate on the "used_at" field. +func UsedAtEQ(v time.Time) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldEQ(FieldUsedAt, v)) +} + +// UsedAtNEQ applies the NEQ predicate on the "used_at" field. +func UsedAtNEQ(v time.Time) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldNEQ(FieldUsedAt, v)) +} + +// UsedAtIn applies the In predicate on the "used_at" field. +func UsedAtIn(vs ...time.Time) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldIn(FieldUsedAt, vs...)) +} + +// UsedAtNotIn applies the NotIn predicate on the "used_at" field. +func UsedAtNotIn(vs ...time.Time) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldNotIn(FieldUsedAt, vs...)) +} + +// UsedAtGT applies the GT predicate on the "used_at" field. +func UsedAtGT(v time.Time) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldGT(FieldUsedAt, v)) +} + +// UsedAtGTE applies the GTE predicate on the "used_at" field. +func UsedAtGTE(v time.Time) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldGTE(FieldUsedAt, v)) +} + +// UsedAtLT applies the LT predicate on the "used_at" field. +func UsedAtLT(v time.Time) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldLT(FieldUsedAt, v)) +} + +// UsedAtLTE applies the LTE predicate on the "used_at" field. +func UsedAtLTE(v time.Time) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.FieldLTE(FieldUsedAt, v)) +} + +// HasPromoCode applies the HasEdge predicate on the "promo_code" edge. +func HasPromoCode() predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(func(s *sql.Selector) { + step := sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, PromoCodeTable, PromoCodeColumn), + ) + sqlgraph.HasNeighbors(s, step) + }) +} + +// HasPromoCodeWith applies the HasEdge predicate on the "promo_code" edge with a given conditions (other predicates). +func HasPromoCodeWith(preds ...predicate.PromoCode) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(func(s *sql.Selector) { + step := newPromoCodeStep() + 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.PromoCodeUsage { + return predicate.PromoCodeUsage(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.PromoCodeUsage { + return predicate.PromoCodeUsage(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.PromoCodeUsage) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.AndPredicates(predicates...)) +} + +// Or groups predicates with the OR operator between them. +func Or(predicates ...predicate.PromoCodeUsage) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.OrPredicates(predicates...)) +} + +// Not applies the not operator on the given predicate. +func Not(p predicate.PromoCodeUsage) predicate.PromoCodeUsage { + return predicate.PromoCodeUsage(sql.NotPredicates(p)) +} diff --git a/backend/ent/promocodeusage_create.go b/backend/ent/promocodeusage_create.go new file mode 100644 index 00000000..79d9c768 --- /dev/null +++ b/backend/ent/promocodeusage_create.go @@ -0,0 +1,696 @@ +// 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/promocode" + "github.com/Wei-Shaw/sub2api/ent/promocodeusage" + "github.com/Wei-Shaw/sub2api/ent/user" +) + +// PromoCodeUsageCreate is the builder for creating a PromoCodeUsage entity. +type PromoCodeUsageCreate struct { + config + mutation *PromoCodeUsageMutation + hooks []Hook + conflict []sql.ConflictOption +} + +// SetPromoCodeID sets the "promo_code_id" field. +func (_c *PromoCodeUsageCreate) SetPromoCodeID(v int64) *PromoCodeUsageCreate { + _c.mutation.SetPromoCodeID(v) + return _c +} + +// SetUserID sets the "user_id" field. +func (_c *PromoCodeUsageCreate) SetUserID(v int64) *PromoCodeUsageCreate { + _c.mutation.SetUserID(v) + return _c +} + +// SetBonusAmount sets the "bonus_amount" field. +func (_c *PromoCodeUsageCreate) SetBonusAmount(v float64) *PromoCodeUsageCreate { + _c.mutation.SetBonusAmount(v) + return _c +} + +// SetUsedAt sets the "used_at" field. +func (_c *PromoCodeUsageCreate) SetUsedAt(v time.Time) *PromoCodeUsageCreate { + _c.mutation.SetUsedAt(v) + return _c +} + +// SetNillableUsedAt sets the "used_at" field if the given value is not nil. +func (_c *PromoCodeUsageCreate) SetNillableUsedAt(v *time.Time) *PromoCodeUsageCreate { + if v != nil { + _c.SetUsedAt(*v) + } + return _c +} + +// SetPromoCode sets the "promo_code" edge to the PromoCode entity. +func (_c *PromoCodeUsageCreate) SetPromoCode(v *PromoCode) *PromoCodeUsageCreate { + return _c.SetPromoCodeID(v.ID) +} + +// SetUser sets the "user" edge to the User entity. +func (_c *PromoCodeUsageCreate) SetUser(v *User) *PromoCodeUsageCreate { + return _c.SetUserID(v.ID) +} + +// Mutation returns the PromoCodeUsageMutation object of the builder. +func (_c *PromoCodeUsageCreate) Mutation() *PromoCodeUsageMutation { + return _c.mutation +} + +// Save creates the PromoCodeUsage in the database. +func (_c *PromoCodeUsageCreate) Save(ctx context.Context) (*PromoCodeUsage, error) { + _c.defaults() + return withHooks(ctx, _c.sqlSave, _c.mutation, _c.hooks) +} + +// SaveX calls Save and panics if Save returns an error. +func (_c *PromoCodeUsageCreate) SaveX(ctx context.Context) *PromoCodeUsage { + v, err := _c.Save(ctx) + if err != nil { + panic(err) + } + return v +} + +// Exec executes the query. +func (_c *PromoCodeUsageCreate) Exec(ctx context.Context) error { + _, err := _c.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_c *PromoCodeUsageCreate) 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 *PromoCodeUsageCreate) defaults() { + if _, ok := _c.mutation.UsedAt(); !ok { + v := promocodeusage.DefaultUsedAt() + _c.mutation.SetUsedAt(v) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_c *PromoCodeUsageCreate) check() error { + if _, ok := _c.mutation.PromoCodeID(); !ok { + return &ValidationError{Name: "promo_code_id", err: errors.New(`ent: missing required field "PromoCodeUsage.promo_code_id"`)} + } + if _, ok := _c.mutation.UserID(); !ok { + return &ValidationError{Name: "user_id", err: errors.New(`ent: missing required field "PromoCodeUsage.user_id"`)} + } + if _, ok := _c.mutation.BonusAmount(); !ok { + return &ValidationError{Name: "bonus_amount", err: errors.New(`ent: missing required field "PromoCodeUsage.bonus_amount"`)} + } + if _, ok := _c.mutation.UsedAt(); !ok { + return &ValidationError{Name: "used_at", err: errors.New(`ent: missing required field "PromoCodeUsage.used_at"`)} + } + if len(_c.mutation.PromoCodeIDs()) == 0 { + return &ValidationError{Name: "promo_code", err: errors.New(`ent: missing required edge "PromoCodeUsage.promo_code"`)} + } + if len(_c.mutation.UserIDs()) == 0 { + return &ValidationError{Name: "user", err: errors.New(`ent: missing required edge "PromoCodeUsage.user"`)} + } + return nil +} + +func (_c *PromoCodeUsageCreate) sqlSave(ctx context.Context) (*PromoCodeUsage, 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 *PromoCodeUsageCreate) createSpec() (*PromoCodeUsage, *sqlgraph.CreateSpec) { + var ( + _node = &PromoCodeUsage{config: _c.config} + _spec = sqlgraph.NewCreateSpec(promocodeusage.Table, sqlgraph.NewFieldSpec(promocodeusage.FieldID, field.TypeInt64)) + ) + _spec.OnConflict = _c.conflict + if value, ok := _c.mutation.BonusAmount(); ok { + _spec.SetField(promocodeusage.FieldBonusAmount, field.TypeFloat64, value) + _node.BonusAmount = value + } + if value, ok := _c.mutation.UsedAt(); ok { + _spec.SetField(promocodeusage.FieldUsedAt, field.TypeTime, value) + _node.UsedAt = value + } + if nodes := _c.mutation.PromoCodeIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: promocodeusage.PromoCodeTable, + Columns: []string{promocodeusage.PromoCodeColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(promocode.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _node.PromoCodeID = 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: promocodeusage.UserTable, + Columns: []string{promocodeusage.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.PromoCodeUsage.Create(). +// SetPromoCodeID(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.PromoCodeUsageUpsert) { +// SetPromoCodeID(v+v). +// }). +// Exec(ctx) +func (_c *PromoCodeUsageCreate) OnConflict(opts ...sql.ConflictOption) *PromoCodeUsageUpsertOne { + _c.conflict = opts + return &PromoCodeUsageUpsertOne{ + create: _c, + } +} + +// OnConflictColumns calls `OnConflict` and configures the columns +// as conflict target. Using this option is equivalent to using: +// +// client.PromoCodeUsage.Create(). +// OnConflict(sql.ConflictColumns(columns...)). +// Exec(ctx) +func (_c *PromoCodeUsageCreate) OnConflictColumns(columns ...string) *PromoCodeUsageUpsertOne { + _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...)) + return &PromoCodeUsageUpsertOne{ + create: _c, + } +} + +type ( + // PromoCodeUsageUpsertOne is the builder for "upsert"-ing + // one PromoCodeUsage node. + PromoCodeUsageUpsertOne struct { + create *PromoCodeUsageCreate + } + + // PromoCodeUsageUpsert is the "OnConflict" setter. + PromoCodeUsageUpsert struct { + *sql.UpdateSet + } +) + +// SetPromoCodeID sets the "promo_code_id" field. +func (u *PromoCodeUsageUpsert) SetPromoCodeID(v int64) *PromoCodeUsageUpsert { + u.Set(promocodeusage.FieldPromoCodeID, v) + return u +} + +// UpdatePromoCodeID sets the "promo_code_id" field to the value that was provided on create. +func (u *PromoCodeUsageUpsert) UpdatePromoCodeID() *PromoCodeUsageUpsert { + u.SetExcluded(promocodeusage.FieldPromoCodeID) + return u +} + +// SetUserID sets the "user_id" field. +func (u *PromoCodeUsageUpsert) SetUserID(v int64) *PromoCodeUsageUpsert { + u.Set(promocodeusage.FieldUserID, v) + return u +} + +// UpdateUserID sets the "user_id" field to the value that was provided on create. +func (u *PromoCodeUsageUpsert) UpdateUserID() *PromoCodeUsageUpsert { + u.SetExcluded(promocodeusage.FieldUserID) + return u +} + +// SetBonusAmount sets the "bonus_amount" field. +func (u *PromoCodeUsageUpsert) SetBonusAmount(v float64) *PromoCodeUsageUpsert { + u.Set(promocodeusage.FieldBonusAmount, v) + return u +} + +// UpdateBonusAmount sets the "bonus_amount" field to the value that was provided on create. +func (u *PromoCodeUsageUpsert) UpdateBonusAmount() *PromoCodeUsageUpsert { + u.SetExcluded(promocodeusage.FieldBonusAmount) + return u +} + +// AddBonusAmount adds v to the "bonus_amount" field. +func (u *PromoCodeUsageUpsert) AddBonusAmount(v float64) *PromoCodeUsageUpsert { + u.Add(promocodeusage.FieldBonusAmount, v) + return u +} + +// SetUsedAt sets the "used_at" field. +func (u *PromoCodeUsageUpsert) SetUsedAt(v time.Time) *PromoCodeUsageUpsert { + u.Set(promocodeusage.FieldUsedAt, v) + return u +} + +// UpdateUsedAt sets the "used_at" field to the value that was provided on create. +func (u *PromoCodeUsageUpsert) UpdateUsedAt() *PromoCodeUsageUpsert { + u.SetExcluded(promocodeusage.FieldUsedAt) + return u +} + +// UpdateNewValues updates the mutable fields using the new values that were set on create. +// Using this option is equivalent to using: +// +// client.PromoCodeUsage.Create(). +// OnConflict( +// sql.ResolveWithNewValues(), +// ). +// Exec(ctx) +func (u *PromoCodeUsageUpsertOne) UpdateNewValues() *PromoCodeUsageUpsertOne { + u.create.conflict = append(u.create.conflict, sql.ResolveWithNewValues()) + return u +} + +// Ignore sets each column to itself in case of conflict. +// Using this option is equivalent to using: +// +// client.PromoCodeUsage.Create(). +// OnConflict(sql.ResolveWithIgnore()). +// Exec(ctx) +func (u *PromoCodeUsageUpsertOne) Ignore() *PromoCodeUsageUpsertOne { + 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 *PromoCodeUsageUpsertOne) DoNothing() *PromoCodeUsageUpsertOne { + u.create.conflict = append(u.create.conflict, sql.DoNothing()) + return u +} + +// Update allows overriding fields `UPDATE` values. See the PromoCodeUsageCreate.OnConflict +// documentation for more info. +func (u *PromoCodeUsageUpsertOne) Update(set func(*PromoCodeUsageUpsert)) *PromoCodeUsageUpsertOne { + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) { + set(&PromoCodeUsageUpsert{UpdateSet: update}) + })) + return u +} + +// SetPromoCodeID sets the "promo_code_id" field. +func (u *PromoCodeUsageUpsertOne) SetPromoCodeID(v int64) *PromoCodeUsageUpsertOne { + return u.Update(func(s *PromoCodeUsageUpsert) { + s.SetPromoCodeID(v) + }) +} + +// UpdatePromoCodeID sets the "promo_code_id" field to the value that was provided on create. +func (u *PromoCodeUsageUpsertOne) UpdatePromoCodeID() *PromoCodeUsageUpsertOne { + return u.Update(func(s *PromoCodeUsageUpsert) { + s.UpdatePromoCodeID() + }) +} + +// SetUserID sets the "user_id" field. +func (u *PromoCodeUsageUpsertOne) SetUserID(v int64) *PromoCodeUsageUpsertOne { + return u.Update(func(s *PromoCodeUsageUpsert) { + s.SetUserID(v) + }) +} + +// UpdateUserID sets the "user_id" field to the value that was provided on create. +func (u *PromoCodeUsageUpsertOne) UpdateUserID() *PromoCodeUsageUpsertOne { + return u.Update(func(s *PromoCodeUsageUpsert) { + s.UpdateUserID() + }) +} + +// SetBonusAmount sets the "bonus_amount" field. +func (u *PromoCodeUsageUpsertOne) SetBonusAmount(v float64) *PromoCodeUsageUpsertOne { + return u.Update(func(s *PromoCodeUsageUpsert) { + s.SetBonusAmount(v) + }) +} + +// AddBonusAmount adds v to the "bonus_amount" field. +func (u *PromoCodeUsageUpsertOne) AddBonusAmount(v float64) *PromoCodeUsageUpsertOne { + return u.Update(func(s *PromoCodeUsageUpsert) { + s.AddBonusAmount(v) + }) +} + +// UpdateBonusAmount sets the "bonus_amount" field to the value that was provided on create. +func (u *PromoCodeUsageUpsertOne) UpdateBonusAmount() *PromoCodeUsageUpsertOne { + return u.Update(func(s *PromoCodeUsageUpsert) { + s.UpdateBonusAmount() + }) +} + +// SetUsedAt sets the "used_at" field. +func (u *PromoCodeUsageUpsertOne) SetUsedAt(v time.Time) *PromoCodeUsageUpsertOne { + return u.Update(func(s *PromoCodeUsageUpsert) { + s.SetUsedAt(v) + }) +} + +// UpdateUsedAt sets the "used_at" field to the value that was provided on create. +func (u *PromoCodeUsageUpsertOne) UpdateUsedAt() *PromoCodeUsageUpsertOne { + return u.Update(func(s *PromoCodeUsageUpsert) { + s.UpdateUsedAt() + }) +} + +// Exec executes the query. +func (u *PromoCodeUsageUpsertOne) Exec(ctx context.Context) error { + if len(u.create.conflict) == 0 { + return errors.New("ent: missing options for PromoCodeUsageCreate.OnConflict") + } + return u.create.Exec(ctx) +} + +// ExecX is like Exec, but panics if an error occurs. +func (u *PromoCodeUsageUpsertOne) 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 *PromoCodeUsageUpsertOne) 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 *PromoCodeUsageUpsertOne) IDX(ctx context.Context) int64 { + id, err := u.ID(ctx) + if err != nil { + panic(err) + } + return id +} + +// PromoCodeUsageCreateBulk is the builder for creating many PromoCodeUsage entities in bulk. +type PromoCodeUsageCreateBulk struct { + config + err error + builders []*PromoCodeUsageCreate + conflict []sql.ConflictOption +} + +// Save creates the PromoCodeUsage entities in the database. +func (_c *PromoCodeUsageCreateBulk) Save(ctx context.Context) ([]*PromoCodeUsage, error) { + if _c.err != nil { + return nil, _c.err + } + specs := make([]*sqlgraph.CreateSpec, len(_c.builders)) + nodes := make([]*PromoCodeUsage, 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.(*PromoCodeUsageMutation) + 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 *PromoCodeUsageCreateBulk) SaveX(ctx context.Context) []*PromoCodeUsage { + v, err := _c.Save(ctx) + if err != nil { + panic(err) + } + return v +} + +// Exec executes the query. +func (_c *PromoCodeUsageCreateBulk) Exec(ctx context.Context) error { + _, err := _c.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_c *PromoCodeUsageCreateBulk) 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.PromoCodeUsage.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.PromoCodeUsageUpsert) { +// SetPromoCodeID(v+v). +// }). +// Exec(ctx) +func (_c *PromoCodeUsageCreateBulk) OnConflict(opts ...sql.ConflictOption) *PromoCodeUsageUpsertBulk { + _c.conflict = opts + return &PromoCodeUsageUpsertBulk{ + create: _c, + } +} + +// OnConflictColumns calls `OnConflict` and configures the columns +// as conflict target. Using this option is equivalent to using: +// +// client.PromoCodeUsage.Create(). +// OnConflict(sql.ConflictColumns(columns...)). +// Exec(ctx) +func (_c *PromoCodeUsageCreateBulk) OnConflictColumns(columns ...string) *PromoCodeUsageUpsertBulk { + _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...)) + return &PromoCodeUsageUpsertBulk{ + create: _c, + } +} + +// PromoCodeUsageUpsertBulk is the builder for "upsert"-ing +// a bulk of PromoCodeUsage nodes. +type PromoCodeUsageUpsertBulk struct { + create *PromoCodeUsageCreateBulk +} + +// UpdateNewValues updates the mutable fields using the new values that +// were set on create. Using this option is equivalent to using: +// +// client.PromoCodeUsage.Create(). +// OnConflict( +// sql.ResolveWithNewValues(), +// ). +// Exec(ctx) +func (u *PromoCodeUsageUpsertBulk) UpdateNewValues() *PromoCodeUsageUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.ResolveWithNewValues()) + return u +} + +// Ignore sets each column to itself in case of conflict. +// Using this option is equivalent to using: +// +// client.PromoCodeUsage.Create(). +// OnConflict(sql.ResolveWithIgnore()). +// Exec(ctx) +func (u *PromoCodeUsageUpsertBulk) Ignore() *PromoCodeUsageUpsertBulk { + 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 *PromoCodeUsageUpsertBulk) DoNothing() *PromoCodeUsageUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.DoNothing()) + return u +} + +// Update allows overriding fields `UPDATE` values. See the PromoCodeUsageCreateBulk.OnConflict +// documentation for more info. +func (u *PromoCodeUsageUpsertBulk) Update(set func(*PromoCodeUsageUpsert)) *PromoCodeUsageUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) { + set(&PromoCodeUsageUpsert{UpdateSet: update}) + })) + return u +} + +// SetPromoCodeID sets the "promo_code_id" field. +func (u *PromoCodeUsageUpsertBulk) SetPromoCodeID(v int64) *PromoCodeUsageUpsertBulk { + return u.Update(func(s *PromoCodeUsageUpsert) { + s.SetPromoCodeID(v) + }) +} + +// UpdatePromoCodeID sets the "promo_code_id" field to the value that was provided on create. +func (u *PromoCodeUsageUpsertBulk) UpdatePromoCodeID() *PromoCodeUsageUpsertBulk { + return u.Update(func(s *PromoCodeUsageUpsert) { + s.UpdatePromoCodeID() + }) +} + +// SetUserID sets the "user_id" field. +func (u *PromoCodeUsageUpsertBulk) SetUserID(v int64) *PromoCodeUsageUpsertBulk { + return u.Update(func(s *PromoCodeUsageUpsert) { + s.SetUserID(v) + }) +} + +// UpdateUserID sets the "user_id" field to the value that was provided on create. +func (u *PromoCodeUsageUpsertBulk) UpdateUserID() *PromoCodeUsageUpsertBulk { + return u.Update(func(s *PromoCodeUsageUpsert) { + s.UpdateUserID() + }) +} + +// SetBonusAmount sets the "bonus_amount" field. +func (u *PromoCodeUsageUpsertBulk) SetBonusAmount(v float64) *PromoCodeUsageUpsertBulk { + return u.Update(func(s *PromoCodeUsageUpsert) { + s.SetBonusAmount(v) + }) +} + +// AddBonusAmount adds v to the "bonus_amount" field. +func (u *PromoCodeUsageUpsertBulk) AddBonusAmount(v float64) *PromoCodeUsageUpsertBulk { + return u.Update(func(s *PromoCodeUsageUpsert) { + s.AddBonusAmount(v) + }) +} + +// UpdateBonusAmount sets the "bonus_amount" field to the value that was provided on create. +func (u *PromoCodeUsageUpsertBulk) UpdateBonusAmount() *PromoCodeUsageUpsertBulk { + return u.Update(func(s *PromoCodeUsageUpsert) { + s.UpdateBonusAmount() + }) +} + +// SetUsedAt sets the "used_at" field. +func (u *PromoCodeUsageUpsertBulk) SetUsedAt(v time.Time) *PromoCodeUsageUpsertBulk { + return u.Update(func(s *PromoCodeUsageUpsert) { + s.SetUsedAt(v) + }) +} + +// UpdateUsedAt sets the "used_at" field to the value that was provided on create. +func (u *PromoCodeUsageUpsertBulk) UpdateUsedAt() *PromoCodeUsageUpsertBulk { + return u.Update(func(s *PromoCodeUsageUpsert) { + s.UpdateUsedAt() + }) +} + +// Exec executes the query. +func (u *PromoCodeUsageUpsertBulk) 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 PromoCodeUsageCreateBulk instead", i) + } + } + if len(u.create.conflict) == 0 { + return errors.New("ent: missing options for PromoCodeUsageCreateBulk.OnConflict") + } + return u.create.Exec(ctx) +} + +// ExecX is like Exec, but panics if an error occurs. +func (u *PromoCodeUsageUpsertBulk) ExecX(ctx context.Context) { + if err := u.create.Exec(ctx); err != nil { + panic(err) + } +} diff --git a/backend/ent/promocodeusage_delete.go b/backend/ent/promocodeusage_delete.go new file mode 100644 index 00000000..bd3fa5e1 --- /dev/null +++ b/backend/ent/promocodeusage_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/predicate" + "github.com/Wei-Shaw/sub2api/ent/promocodeusage" +) + +// PromoCodeUsageDelete is the builder for deleting a PromoCodeUsage entity. +type PromoCodeUsageDelete struct { + config + hooks []Hook + mutation *PromoCodeUsageMutation +} + +// Where appends a list predicates to the PromoCodeUsageDelete builder. +func (_d *PromoCodeUsageDelete) Where(ps ...predicate.PromoCodeUsage) *PromoCodeUsageDelete { + _d.mutation.Where(ps...) + return _d +} + +// Exec executes the deletion query and returns how many vertices were deleted. +func (_d *PromoCodeUsageDelete) 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 *PromoCodeUsageDelete) ExecX(ctx context.Context) int { + n, err := _d.Exec(ctx) + if err != nil { + panic(err) + } + return n +} + +func (_d *PromoCodeUsageDelete) sqlExec(ctx context.Context) (int, error) { + _spec := sqlgraph.NewDeleteSpec(promocodeusage.Table, sqlgraph.NewFieldSpec(promocodeusage.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 +} + +// PromoCodeUsageDeleteOne is the builder for deleting a single PromoCodeUsage entity. +type PromoCodeUsageDeleteOne struct { + _d *PromoCodeUsageDelete +} + +// Where appends a list predicates to the PromoCodeUsageDelete builder. +func (_d *PromoCodeUsageDeleteOne) Where(ps ...predicate.PromoCodeUsage) *PromoCodeUsageDeleteOne { + _d._d.mutation.Where(ps...) + return _d +} + +// Exec executes the deletion query. +func (_d *PromoCodeUsageDeleteOne) Exec(ctx context.Context) error { + n, err := _d._d.Exec(ctx) + switch { + case err != nil: + return err + case n == 0: + return &NotFoundError{promocodeusage.Label} + default: + return nil + } +} + +// ExecX is like Exec, but panics if an error occurs. +func (_d *PromoCodeUsageDeleteOne) ExecX(ctx context.Context) { + if err := _d.Exec(ctx); err != nil { + panic(err) + } +} diff --git a/backend/ent/promocodeusage_query.go b/backend/ent/promocodeusage_query.go new file mode 100644 index 00000000..95b02a16 --- /dev/null +++ b/backend/ent/promocodeusage_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/predicate" + "github.com/Wei-Shaw/sub2api/ent/promocode" + "github.com/Wei-Shaw/sub2api/ent/promocodeusage" + "github.com/Wei-Shaw/sub2api/ent/user" +) + +// PromoCodeUsageQuery is the builder for querying PromoCodeUsage entities. +type PromoCodeUsageQuery struct { + config + ctx *QueryContext + order []promocodeusage.OrderOption + inters []Interceptor + predicates []predicate.PromoCodeUsage + withPromoCode *PromoCodeQuery + 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 PromoCodeUsageQuery builder. +func (_q *PromoCodeUsageQuery) Where(ps ...predicate.PromoCodeUsage) *PromoCodeUsageQuery { + _q.predicates = append(_q.predicates, ps...) + return _q +} + +// Limit the number of records to be returned by this query. +func (_q *PromoCodeUsageQuery) Limit(limit int) *PromoCodeUsageQuery { + _q.ctx.Limit = &limit + return _q +} + +// Offset to start from. +func (_q *PromoCodeUsageQuery) Offset(offset int) *PromoCodeUsageQuery { + _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 *PromoCodeUsageQuery) Unique(unique bool) *PromoCodeUsageQuery { + _q.ctx.Unique = &unique + return _q +} + +// Order specifies how the records should be ordered. +func (_q *PromoCodeUsageQuery) Order(o ...promocodeusage.OrderOption) *PromoCodeUsageQuery { + _q.order = append(_q.order, o...) + return _q +} + +// QueryPromoCode chains the current query on the "promo_code" edge. +func (_q *PromoCodeUsageQuery) QueryPromoCode() *PromoCodeQuery { + query := (&PromoCodeClient{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(promocodeusage.Table, promocodeusage.FieldID, selector), + sqlgraph.To(promocode.Table, promocode.FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, promocodeusage.PromoCodeTable, promocodeusage.PromoCodeColumn), + ) + fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step) + return fromU, nil + } + return query +} + +// QueryUser chains the current query on the "user" edge. +func (_q *PromoCodeUsageQuery) 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(promocodeusage.Table, promocodeusage.FieldID, selector), + sqlgraph.To(user.Table, user.FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, promocodeusage.UserTable, promocodeusage.UserColumn), + ) + fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step) + return fromU, nil + } + return query +} + +// First returns the first PromoCodeUsage entity from the query. +// Returns a *NotFoundError when no PromoCodeUsage was found. +func (_q *PromoCodeUsageQuery) First(ctx context.Context) (*PromoCodeUsage, 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{promocodeusage.Label} + } + return nodes[0], nil +} + +// FirstX is like First, but panics if an error occurs. +func (_q *PromoCodeUsageQuery) FirstX(ctx context.Context) *PromoCodeUsage { + node, err := _q.First(ctx) + if err != nil && !IsNotFound(err) { + panic(err) + } + return node +} + +// FirstID returns the first PromoCodeUsage ID from the query. +// Returns a *NotFoundError when no PromoCodeUsage ID was found. +func (_q *PromoCodeUsageQuery) 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{promocodeusage.Label} + return + } + return ids[0], nil +} + +// FirstIDX is like FirstID, but panics if an error occurs. +func (_q *PromoCodeUsageQuery) FirstIDX(ctx context.Context) int64 { + id, err := _q.FirstID(ctx) + if err != nil && !IsNotFound(err) { + panic(err) + } + return id +} + +// Only returns a single PromoCodeUsage entity found by the query, ensuring it only returns one. +// Returns a *NotSingularError when more than one PromoCodeUsage entity is found. +// Returns a *NotFoundError when no PromoCodeUsage entities are found. +func (_q *PromoCodeUsageQuery) Only(ctx context.Context) (*PromoCodeUsage, 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{promocodeusage.Label} + default: + return nil, &NotSingularError{promocodeusage.Label} + } +} + +// OnlyX is like Only, but panics if an error occurs. +func (_q *PromoCodeUsageQuery) OnlyX(ctx context.Context) *PromoCodeUsage { + node, err := _q.Only(ctx) + if err != nil { + panic(err) + } + return node +} + +// OnlyID is like Only, but returns the only PromoCodeUsage ID in the query. +// Returns a *NotSingularError when more than one PromoCodeUsage ID is found. +// Returns a *NotFoundError when no entities are found. +func (_q *PromoCodeUsageQuery) 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{promocodeusage.Label} + default: + err = &NotSingularError{promocodeusage.Label} + } + return +} + +// OnlyIDX is like OnlyID, but panics if an error occurs. +func (_q *PromoCodeUsageQuery) 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 PromoCodeUsages. +func (_q *PromoCodeUsageQuery) All(ctx context.Context) ([]*PromoCodeUsage, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryAll) + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + qr := querierAll[[]*PromoCodeUsage, *PromoCodeUsageQuery]() + return withInterceptors[[]*PromoCodeUsage](ctx, _q, qr, _q.inters) +} + +// AllX is like All, but panics if an error occurs. +func (_q *PromoCodeUsageQuery) AllX(ctx context.Context) []*PromoCodeUsage { + nodes, err := _q.All(ctx) + if err != nil { + panic(err) + } + return nodes +} + +// IDs executes the query and returns a list of PromoCodeUsage IDs. +func (_q *PromoCodeUsageQuery) 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(promocodeusage.FieldID).Scan(ctx, &ids); err != nil { + return nil, err + } + return ids, nil +} + +// IDsX is like IDs, but panics if an error occurs. +func (_q *PromoCodeUsageQuery) 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 *PromoCodeUsageQuery) 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[*PromoCodeUsageQuery](), _q.inters) +} + +// CountX is like Count, but panics if an error occurs. +func (_q *PromoCodeUsageQuery) 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 *PromoCodeUsageQuery) 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 *PromoCodeUsageQuery) ExistX(ctx context.Context) bool { + exist, err := _q.Exist(ctx) + if err != nil { + panic(err) + } + return exist +} + +// Clone returns a duplicate of the PromoCodeUsageQuery 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 *PromoCodeUsageQuery) Clone() *PromoCodeUsageQuery { + if _q == nil { + return nil + } + return &PromoCodeUsageQuery{ + config: _q.config, + ctx: _q.ctx.Clone(), + order: append([]promocodeusage.OrderOption{}, _q.order...), + inters: append([]Interceptor{}, _q.inters...), + predicates: append([]predicate.PromoCodeUsage{}, _q.predicates...), + withPromoCode: _q.withPromoCode.Clone(), + withUser: _q.withUser.Clone(), + // clone intermediate query. + sql: _q.sql.Clone(), + path: _q.path, + } +} + +// WithPromoCode tells the query-builder to eager-load the nodes that are connected to +// the "promo_code" edge. The optional arguments are used to configure the query builder of the edge. +func (_q *PromoCodeUsageQuery) WithPromoCode(opts ...func(*PromoCodeQuery)) *PromoCodeUsageQuery { + query := (&PromoCodeClient{config: _q.config}).Query() + for _, opt := range opts { + opt(query) + } + _q.withPromoCode = 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 *PromoCodeUsageQuery) WithUser(opts ...func(*UserQuery)) *PromoCodeUsageQuery { + 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 { +// PromoCodeID int64 `json:"promo_code_id,omitempty"` +// Count int `json:"count,omitempty"` +// } +// +// client.PromoCodeUsage.Query(). +// GroupBy(promocodeusage.FieldPromoCodeID). +// Aggregate(ent.Count()). +// Scan(ctx, &v) +func (_q *PromoCodeUsageQuery) GroupBy(field string, fields ...string) *PromoCodeUsageGroupBy { + _q.ctx.Fields = append([]string{field}, fields...) + grbuild := &PromoCodeUsageGroupBy{build: _q} + grbuild.flds = &_q.ctx.Fields + grbuild.label = promocodeusage.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 { +// PromoCodeID int64 `json:"promo_code_id,omitempty"` +// } +// +// client.PromoCodeUsage.Query(). +// Select(promocodeusage.FieldPromoCodeID). +// Scan(ctx, &v) +func (_q *PromoCodeUsageQuery) Select(fields ...string) *PromoCodeUsageSelect { + _q.ctx.Fields = append(_q.ctx.Fields, fields...) + sbuild := &PromoCodeUsageSelect{PromoCodeUsageQuery: _q} + sbuild.label = promocodeusage.Label + sbuild.flds, sbuild.scan = &_q.ctx.Fields, sbuild.Scan + return sbuild +} + +// Aggregate returns a PromoCodeUsageSelect configured with the given aggregations. +func (_q *PromoCodeUsageQuery) Aggregate(fns ...AggregateFunc) *PromoCodeUsageSelect { + return _q.Select().Aggregate(fns...) +} + +func (_q *PromoCodeUsageQuery) 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 !promocodeusage.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 *PromoCodeUsageQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*PromoCodeUsage, error) { + var ( + nodes = []*PromoCodeUsage{} + _spec = _q.querySpec() + loadedTypes = [2]bool{ + _q.withPromoCode != nil, + _q.withUser != nil, + } + ) + _spec.ScanValues = func(columns []string) ([]any, error) { + return (*PromoCodeUsage).scanValues(nil, columns) + } + _spec.Assign = func(columns []string, values []any) error { + node := &PromoCodeUsage{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.withPromoCode; query != nil { + if err := _q.loadPromoCode(ctx, query, nodes, nil, + func(n *PromoCodeUsage, e *PromoCode) { n.Edges.PromoCode = e }); err != nil { + return nil, err + } + } + if query := _q.withUser; query != nil { + if err := _q.loadUser(ctx, query, nodes, nil, + func(n *PromoCodeUsage, e *User) { n.Edges.User = e }); err != nil { + return nil, err + } + } + return nodes, nil +} + +func (_q *PromoCodeUsageQuery) loadPromoCode(ctx context.Context, query *PromoCodeQuery, nodes []*PromoCodeUsage, init func(*PromoCodeUsage), assign func(*PromoCodeUsage, *PromoCode)) error { + ids := make([]int64, 0, len(nodes)) + nodeids := make(map[int64][]*PromoCodeUsage) + for i := range nodes { + fk := nodes[i].PromoCodeID + if _, ok := nodeids[fk]; !ok { + ids = append(ids, fk) + } + nodeids[fk] = append(nodeids[fk], nodes[i]) + } + if len(ids) == 0 { + return nil + } + query.Where(promocode.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 "promo_code_id" returned %v`, n.ID) + } + for i := range nodes { + assign(nodes[i], n) + } + } + return nil +} +func (_q *PromoCodeUsageQuery) loadUser(ctx context.Context, query *UserQuery, nodes []*PromoCodeUsage, init func(*PromoCodeUsage), assign func(*PromoCodeUsage, *User)) error { + ids := make([]int64, 0, len(nodes)) + nodeids := make(map[int64][]*PromoCodeUsage) + 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 *PromoCodeUsageQuery) 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 *PromoCodeUsageQuery) querySpec() *sqlgraph.QuerySpec { + _spec := sqlgraph.NewQuerySpec(promocodeusage.Table, promocodeusage.Columns, sqlgraph.NewFieldSpec(promocodeusage.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, promocodeusage.FieldID) + for i := range fields { + if fields[i] != promocodeusage.FieldID { + _spec.Node.Columns = append(_spec.Node.Columns, fields[i]) + } + } + if _q.withPromoCode != nil { + _spec.Node.AddColumnOnce(promocodeusage.FieldPromoCodeID) + } + if _q.withUser != nil { + _spec.Node.AddColumnOnce(promocodeusage.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 *PromoCodeUsageQuery) sqlQuery(ctx context.Context) *sql.Selector { + builder := sql.Dialect(_q.driver.Dialect()) + t1 := builder.Table(promocodeusage.Table) + columns := _q.ctx.Fields + if len(columns) == 0 { + columns = promocodeusage.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 *PromoCodeUsageQuery) ForUpdate(opts ...sql.LockOption) *PromoCodeUsageQuery { + 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 *PromoCodeUsageQuery) ForShare(opts ...sql.LockOption) *PromoCodeUsageQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForShare(opts...) + }) + return _q +} + +// PromoCodeUsageGroupBy is the group-by builder for PromoCodeUsage entities. +type PromoCodeUsageGroupBy struct { + selector + build *PromoCodeUsageQuery +} + +// Aggregate adds the given aggregation functions to the group-by query. +func (_g *PromoCodeUsageGroupBy) Aggregate(fns ...AggregateFunc) *PromoCodeUsageGroupBy { + _g.fns = append(_g.fns, fns...) + return _g +} + +// Scan applies the selector query and scans the result into the given value. +func (_g *PromoCodeUsageGroupBy) 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[*PromoCodeUsageQuery, *PromoCodeUsageGroupBy](ctx, _g.build, _g, _g.build.inters, v) +} + +func (_g *PromoCodeUsageGroupBy) sqlScan(ctx context.Context, root *PromoCodeUsageQuery, 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) +} + +// PromoCodeUsageSelect is the builder for selecting fields of PromoCodeUsage entities. +type PromoCodeUsageSelect struct { + *PromoCodeUsageQuery + selector +} + +// Aggregate adds the given aggregation functions to the selector query. +func (_s *PromoCodeUsageSelect) Aggregate(fns ...AggregateFunc) *PromoCodeUsageSelect { + _s.fns = append(_s.fns, fns...) + return _s +} + +// Scan applies the selector query and scans the result into the given value. +func (_s *PromoCodeUsageSelect) 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[*PromoCodeUsageQuery, *PromoCodeUsageSelect](ctx, _s.PromoCodeUsageQuery, _s, _s.inters, v) +} + +func (_s *PromoCodeUsageSelect) sqlScan(ctx context.Context, root *PromoCodeUsageQuery, 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/promocodeusage_update.go b/backend/ent/promocodeusage_update.go new file mode 100644 index 00000000..d91a1f10 --- /dev/null +++ b/backend/ent/promocodeusage_update.go @@ -0,0 +1,510 @@ +// 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/predicate" + "github.com/Wei-Shaw/sub2api/ent/promocode" + "github.com/Wei-Shaw/sub2api/ent/promocodeusage" + "github.com/Wei-Shaw/sub2api/ent/user" +) + +// PromoCodeUsageUpdate is the builder for updating PromoCodeUsage entities. +type PromoCodeUsageUpdate struct { + config + hooks []Hook + mutation *PromoCodeUsageMutation +} + +// Where appends a list predicates to the PromoCodeUsageUpdate builder. +func (_u *PromoCodeUsageUpdate) Where(ps ...predicate.PromoCodeUsage) *PromoCodeUsageUpdate { + _u.mutation.Where(ps...) + return _u +} + +// SetPromoCodeID sets the "promo_code_id" field. +func (_u *PromoCodeUsageUpdate) SetPromoCodeID(v int64) *PromoCodeUsageUpdate { + _u.mutation.SetPromoCodeID(v) + return _u +} + +// SetNillablePromoCodeID sets the "promo_code_id" field if the given value is not nil. +func (_u *PromoCodeUsageUpdate) SetNillablePromoCodeID(v *int64) *PromoCodeUsageUpdate { + if v != nil { + _u.SetPromoCodeID(*v) + } + return _u +} + +// SetUserID sets the "user_id" field. +func (_u *PromoCodeUsageUpdate) SetUserID(v int64) *PromoCodeUsageUpdate { + _u.mutation.SetUserID(v) + return _u +} + +// SetNillableUserID sets the "user_id" field if the given value is not nil. +func (_u *PromoCodeUsageUpdate) SetNillableUserID(v *int64) *PromoCodeUsageUpdate { + if v != nil { + _u.SetUserID(*v) + } + return _u +} + +// SetBonusAmount sets the "bonus_amount" field. +func (_u *PromoCodeUsageUpdate) SetBonusAmount(v float64) *PromoCodeUsageUpdate { + _u.mutation.ResetBonusAmount() + _u.mutation.SetBonusAmount(v) + return _u +} + +// SetNillableBonusAmount sets the "bonus_amount" field if the given value is not nil. +func (_u *PromoCodeUsageUpdate) SetNillableBonusAmount(v *float64) *PromoCodeUsageUpdate { + if v != nil { + _u.SetBonusAmount(*v) + } + return _u +} + +// AddBonusAmount adds value to the "bonus_amount" field. +func (_u *PromoCodeUsageUpdate) AddBonusAmount(v float64) *PromoCodeUsageUpdate { + _u.mutation.AddBonusAmount(v) + return _u +} + +// SetUsedAt sets the "used_at" field. +func (_u *PromoCodeUsageUpdate) SetUsedAt(v time.Time) *PromoCodeUsageUpdate { + _u.mutation.SetUsedAt(v) + return _u +} + +// SetNillableUsedAt sets the "used_at" field if the given value is not nil. +func (_u *PromoCodeUsageUpdate) SetNillableUsedAt(v *time.Time) *PromoCodeUsageUpdate { + if v != nil { + _u.SetUsedAt(*v) + } + return _u +} + +// SetPromoCode sets the "promo_code" edge to the PromoCode entity. +func (_u *PromoCodeUsageUpdate) SetPromoCode(v *PromoCode) *PromoCodeUsageUpdate { + return _u.SetPromoCodeID(v.ID) +} + +// SetUser sets the "user" edge to the User entity. +func (_u *PromoCodeUsageUpdate) SetUser(v *User) *PromoCodeUsageUpdate { + return _u.SetUserID(v.ID) +} + +// Mutation returns the PromoCodeUsageMutation object of the builder. +func (_u *PromoCodeUsageUpdate) Mutation() *PromoCodeUsageMutation { + return _u.mutation +} + +// ClearPromoCode clears the "promo_code" edge to the PromoCode entity. +func (_u *PromoCodeUsageUpdate) ClearPromoCode() *PromoCodeUsageUpdate { + _u.mutation.ClearPromoCode() + return _u +} + +// ClearUser clears the "user" edge to the User entity. +func (_u *PromoCodeUsageUpdate) ClearUser() *PromoCodeUsageUpdate { + _u.mutation.ClearUser() + return _u +} + +// Save executes the query and returns the number of nodes affected by the update operation. +func (_u *PromoCodeUsageUpdate) 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 *PromoCodeUsageUpdate) SaveX(ctx context.Context) int { + affected, err := _u.Save(ctx) + if err != nil { + panic(err) + } + return affected +} + +// Exec executes the query. +func (_u *PromoCodeUsageUpdate) Exec(ctx context.Context) error { + _, err := _u.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_u *PromoCodeUsageUpdate) 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 *PromoCodeUsageUpdate) check() error { + if _u.mutation.PromoCodeCleared() && len(_u.mutation.PromoCodeIDs()) > 0 { + return errors.New(`ent: clearing a required unique edge "PromoCodeUsage.promo_code"`) + } + if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 { + return errors.New(`ent: clearing a required unique edge "PromoCodeUsage.user"`) + } + return nil +} + +func (_u *PromoCodeUsageUpdate) sqlSave(ctx context.Context) (_node int, err error) { + if err := _u.check(); err != nil { + return _node, err + } + _spec := sqlgraph.NewUpdateSpec(promocodeusage.Table, promocodeusage.Columns, sqlgraph.NewFieldSpec(promocodeusage.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.BonusAmount(); ok { + _spec.SetField(promocodeusage.FieldBonusAmount, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedBonusAmount(); ok { + _spec.AddField(promocodeusage.FieldBonusAmount, field.TypeFloat64, value) + } + if value, ok := _u.mutation.UsedAt(); ok { + _spec.SetField(promocodeusage.FieldUsedAt, field.TypeTime, value) + } + if _u.mutation.PromoCodeCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: promocodeusage.PromoCodeTable, + Columns: []string{promocodeusage.PromoCodeColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(promocode.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.PromoCodeIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: promocodeusage.PromoCodeTable, + Columns: []string{promocodeusage.PromoCodeColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(promocode.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: promocodeusage.UserTable, + Columns: []string{promocodeusage.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: promocodeusage.UserTable, + Columns: []string{promocodeusage.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{promocodeusage.Label} + } else if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return 0, err + } + _u.mutation.done = true + return _node, nil +} + +// PromoCodeUsageUpdateOne is the builder for updating a single PromoCodeUsage entity. +type PromoCodeUsageUpdateOne struct { + config + fields []string + hooks []Hook + mutation *PromoCodeUsageMutation +} + +// SetPromoCodeID sets the "promo_code_id" field. +func (_u *PromoCodeUsageUpdateOne) SetPromoCodeID(v int64) *PromoCodeUsageUpdateOne { + _u.mutation.SetPromoCodeID(v) + return _u +} + +// SetNillablePromoCodeID sets the "promo_code_id" field if the given value is not nil. +func (_u *PromoCodeUsageUpdateOne) SetNillablePromoCodeID(v *int64) *PromoCodeUsageUpdateOne { + if v != nil { + _u.SetPromoCodeID(*v) + } + return _u +} + +// SetUserID sets the "user_id" field. +func (_u *PromoCodeUsageUpdateOne) SetUserID(v int64) *PromoCodeUsageUpdateOne { + _u.mutation.SetUserID(v) + return _u +} + +// SetNillableUserID sets the "user_id" field if the given value is not nil. +func (_u *PromoCodeUsageUpdateOne) SetNillableUserID(v *int64) *PromoCodeUsageUpdateOne { + if v != nil { + _u.SetUserID(*v) + } + return _u +} + +// SetBonusAmount sets the "bonus_amount" field. +func (_u *PromoCodeUsageUpdateOne) SetBonusAmount(v float64) *PromoCodeUsageUpdateOne { + _u.mutation.ResetBonusAmount() + _u.mutation.SetBonusAmount(v) + return _u +} + +// SetNillableBonusAmount sets the "bonus_amount" field if the given value is not nil. +func (_u *PromoCodeUsageUpdateOne) SetNillableBonusAmount(v *float64) *PromoCodeUsageUpdateOne { + if v != nil { + _u.SetBonusAmount(*v) + } + return _u +} + +// AddBonusAmount adds value to the "bonus_amount" field. +func (_u *PromoCodeUsageUpdateOne) AddBonusAmount(v float64) *PromoCodeUsageUpdateOne { + _u.mutation.AddBonusAmount(v) + return _u +} + +// SetUsedAt sets the "used_at" field. +func (_u *PromoCodeUsageUpdateOne) SetUsedAt(v time.Time) *PromoCodeUsageUpdateOne { + _u.mutation.SetUsedAt(v) + return _u +} + +// SetNillableUsedAt sets the "used_at" field if the given value is not nil. +func (_u *PromoCodeUsageUpdateOne) SetNillableUsedAt(v *time.Time) *PromoCodeUsageUpdateOne { + if v != nil { + _u.SetUsedAt(*v) + } + return _u +} + +// SetPromoCode sets the "promo_code" edge to the PromoCode entity. +func (_u *PromoCodeUsageUpdateOne) SetPromoCode(v *PromoCode) *PromoCodeUsageUpdateOne { + return _u.SetPromoCodeID(v.ID) +} + +// SetUser sets the "user" edge to the User entity. +func (_u *PromoCodeUsageUpdateOne) SetUser(v *User) *PromoCodeUsageUpdateOne { + return _u.SetUserID(v.ID) +} + +// Mutation returns the PromoCodeUsageMutation object of the builder. +func (_u *PromoCodeUsageUpdateOne) Mutation() *PromoCodeUsageMutation { + return _u.mutation +} + +// ClearPromoCode clears the "promo_code" edge to the PromoCode entity. +func (_u *PromoCodeUsageUpdateOne) ClearPromoCode() *PromoCodeUsageUpdateOne { + _u.mutation.ClearPromoCode() + return _u +} + +// ClearUser clears the "user" edge to the User entity. +func (_u *PromoCodeUsageUpdateOne) ClearUser() *PromoCodeUsageUpdateOne { + _u.mutation.ClearUser() + return _u +} + +// Where appends a list predicates to the PromoCodeUsageUpdate builder. +func (_u *PromoCodeUsageUpdateOne) Where(ps ...predicate.PromoCodeUsage) *PromoCodeUsageUpdateOne { + _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 *PromoCodeUsageUpdateOne) Select(field string, fields ...string) *PromoCodeUsageUpdateOne { + _u.fields = append([]string{field}, fields...) + return _u +} + +// Save executes the query and returns the updated PromoCodeUsage entity. +func (_u *PromoCodeUsageUpdateOne) Save(ctx context.Context) (*PromoCodeUsage, error) { + return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks) +} + +// SaveX is like Save, but panics if an error occurs. +func (_u *PromoCodeUsageUpdateOne) SaveX(ctx context.Context) *PromoCodeUsage { + node, err := _u.Save(ctx) + if err != nil { + panic(err) + } + return node +} + +// Exec executes the query on the entity. +func (_u *PromoCodeUsageUpdateOne) Exec(ctx context.Context) error { + _, err := _u.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_u *PromoCodeUsageUpdateOne) 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 *PromoCodeUsageUpdateOne) check() error { + if _u.mutation.PromoCodeCleared() && len(_u.mutation.PromoCodeIDs()) > 0 { + return errors.New(`ent: clearing a required unique edge "PromoCodeUsage.promo_code"`) + } + if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 { + return errors.New(`ent: clearing a required unique edge "PromoCodeUsage.user"`) + } + return nil +} + +func (_u *PromoCodeUsageUpdateOne) sqlSave(ctx context.Context) (_node *PromoCodeUsage, err error) { + if err := _u.check(); err != nil { + return _node, err + } + _spec := sqlgraph.NewUpdateSpec(promocodeusage.Table, promocodeusage.Columns, sqlgraph.NewFieldSpec(promocodeusage.FieldID, field.TypeInt64)) + id, ok := _u.mutation.ID() + if !ok { + return nil, &ValidationError{Name: "id", err: errors.New(`ent: missing "PromoCodeUsage.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, promocodeusage.FieldID) + for _, f := range fields { + if !promocodeusage.ValidColumn(f) { + return nil, &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)} + } + if f != promocodeusage.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.BonusAmount(); ok { + _spec.SetField(promocodeusage.FieldBonusAmount, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedBonusAmount(); ok { + _spec.AddField(promocodeusage.FieldBonusAmount, field.TypeFloat64, value) + } + if value, ok := _u.mutation.UsedAt(); ok { + _spec.SetField(promocodeusage.FieldUsedAt, field.TypeTime, value) + } + if _u.mutation.PromoCodeCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: promocodeusage.PromoCodeTable, + Columns: []string{promocodeusage.PromoCodeColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(promocode.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.PromoCodeIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: promocodeusage.PromoCodeTable, + Columns: []string{promocodeusage.PromoCodeColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(promocode.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: promocodeusage.UserTable, + Columns: []string{promocodeusage.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: promocodeusage.UserTable, + Columns: []string{promocodeusage.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 = &PromoCodeUsage{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{promocodeusage.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/proxy_query.go b/backend/ent/proxy_query.go index 1358eed2..b817d139 100644 --- a/backend/ent/proxy_query.go +++ b/backend/ent/proxy_query.go @@ -9,6 +9,7 @@ import ( "math" "entgo.io/ent" + "entgo.io/ent/dialect" "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" @@ -25,6 +26,7 @@ type ProxyQuery struct { inters []Interceptor predicates []predicate.Proxy withAccounts *AccountQuery + modifiers []func(*sql.Selector) // intermediate query (i.e. traversal path). sql *sql.Selector path func(context.Context) (*sql.Selector, error) @@ -384,6 +386,9 @@ func (_q *ProxyQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Proxy, 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) } @@ -439,6 +444,9 @@ func (_q *ProxyQuery) loadAccounts(ctx context.Context, query *AccountQuery, nod func (_q *ProxyQuery) 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 @@ -501,6 +509,9 @@ func (_q *ProxyQuery) sqlQuery(ctx context.Context) *sql.Selector { if _q.ctx.Unique != nil && *_q.ctx.Unique { selector.Distinct() } + for _, m := range _q.modifiers { + m(selector) + } for _, p := range _q.predicates { p(selector) } @@ -518,6 +529,32 @@ func (_q *ProxyQuery) sqlQuery(ctx context.Context) *sql.Selector { 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 *ProxyQuery) ForUpdate(opts ...sql.LockOption) *ProxyQuery { + 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 *ProxyQuery) ForShare(opts ...sql.LockOption) *ProxyQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForShare(opts...) + }) + return _q +} + // ProxyGroupBy is the group-by builder for Proxy entities. type ProxyGroupBy struct { selector diff --git a/backend/ent/redeemcode_query.go b/backend/ent/redeemcode_query.go index 442bfe81..f5b8baef 100644 --- a/backend/ent/redeemcode_query.go +++ b/backend/ent/redeemcode_query.go @@ -8,6 +8,7 @@ import ( "math" "entgo.io/ent" + "entgo.io/ent/dialect" "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" @@ -26,6 +27,7 @@ type RedeemCodeQuery struct { predicates []predicate.RedeemCode withUser *UserQuery withGroup *GroupQuery + modifiers []func(*sql.Selector) // intermediate query (i.e. traversal path). sql *sql.Selector path func(context.Context) (*sql.Selector, error) @@ -420,6 +422,9 @@ func (_q *RedeemCodeQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*R 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) } @@ -511,6 +516,9 @@ func (_q *RedeemCodeQuery) loadGroup(ctx context.Context, query *GroupQuery, nod func (_q *RedeemCodeQuery) 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 @@ -579,6 +587,9 @@ func (_q *RedeemCodeQuery) sqlQuery(ctx context.Context) *sql.Selector { if _q.ctx.Unique != nil && *_q.ctx.Unique { selector.Distinct() } + for _, m := range _q.modifiers { + m(selector) + } for _, p := range _q.predicates { p(selector) } @@ -596,6 +607,32 @@ func (_q *RedeemCodeQuery) sqlQuery(ctx context.Context) *sql.Selector { 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 *RedeemCodeQuery) ForUpdate(opts ...sql.LockOption) *RedeemCodeQuery { + 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 *RedeemCodeQuery) ForShare(opts ...sql.LockOption) *RedeemCodeQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForShare(opts...) + }) + return _q +} + // RedeemCodeGroupBy is the group-by builder for RedeemCode entities. type RedeemCodeGroupBy struct { selector diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index fb1c948c..ad1aa626 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -9,6 +9,8 @@ import ( "github.com/Wei-Shaw/sub2api/ent/accountgroup" "github.com/Wei-Shaw/sub2api/ent/apikey" "github.com/Wei-Shaw/sub2api/ent/group" + "github.com/Wei-Shaw/sub2api/ent/promocode" + "github.com/Wei-Shaw/sub2api/ent/promocodeusage" "github.com/Wei-Shaw/sub2api/ent/proxy" "github.com/Wei-Shaw/sub2api/ent/redeemcode" "github.com/Wei-Shaw/sub2api/ent/schema" @@ -274,6 +276,60 @@ func init() { groupDescClaudeCodeOnly := groupFields[14].Descriptor() // group.DefaultClaudeCodeOnly holds the default value on creation for the claude_code_only field. group.DefaultClaudeCodeOnly = groupDescClaudeCodeOnly.Default.(bool) + promocodeFields := schema.PromoCode{}.Fields() + _ = promocodeFields + // promocodeDescCode is the schema descriptor for code field. + promocodeDescCode := promocodeFields[0].Descriptor() + // promocode.CodeValidator is a validator for the "code" field. It is called by the builders before save. + promocode.CodeValidator = func() func(string) error { + validators := promocodeDescCode.Validators + fns := [...]func(string) error{ + validators[0].(func(string) error), + validators[1].(func(string) error), + } + return func(code string) error { + for _, fn := range fns { + if err := fn(code); err != nil { + return err + } + } + return nil + } + }() + // promocodeDescBonusAmount is the schema descriptor for bonus_amount field. + promocodeDescBonusAmount := promocodeFields[1].Descriptor() + // promocode.DefaultBonusAmount holds the default value on creation for the bonus_amount field. + promocode.DefaultBonusAmount = promocodeDescBonusAmount.Default.(float64) + // promocodeDescMaxUses is the schema descriptor for max_uses field. + promocodeDescMaxUses := promocodeFields[2].Descriptor() + // promocode.DefaultMaxUses holds the default value on creation for the max_uses field. + promocode.DefaultMaxUses = promocodeDescMaxUses.Default.(int) + // promocodeDescUsedCount is the schema descriptor for used_count field. + promocodeDescUsedCount := promocodeFields[3].Descriptor() + // promocode.DefaultUsedCount holds the default value on creation for the used_count field. + promocode.DefaultUsedCount = promocodeDescUsedCount.Default.(int) + // promocodeDescStatus is the schema descriptor for status field. + promocodeDescStatus := promocodeFields[4].Descriptor() + // promocode.DefaultStatus holds the default value on creation for the status field. + promocode.DefaultStatus = promocodeDescStatus.Default.(string) + // promocode.StatusValidator is a validator for the "status" field. It is called by the builders before save. + promocode.StatusValidator = promocodeDescStatus.Validators[0].(func(string) error) + // promocodeDescCreatedAt is the schema descriptor for created_at field. + promocodeDescCreatedAt := promocodeFields[7].Descriptor() + // promocode.DefaultCreatedAt holds the default value on creation for the created_at field. + promocode.DefaultCreatedAt = promocodeDescCreatedAt.Default.(func() time.Time) + // promocodeDescUpdatedAt is the schema descriptor for updated_at field. + promocodeDescUpdatedAt := promocodeFields[8].Descriptor() + // promocode.DefaultUpdatedAt holds the default value on creation for the updated_at field. + promocode.DefaultUpdatedAt = promocodeDescUpdatedAt.Default.(func() time.Time) + // promocode.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field. + promocode.UpdateDefaultUpdatedAt = promocodeDescUpdatedAt.UpdateDefault.(func() time.Time) + promocodeusageFields := schema.PromoCodeUsage{}.Fields() + _ = promocodeusageFields + // promocodeusageDescUsedAt is the schema descriptor for used_at field. + promocodeusageDescUsedAt := promocodeusageFields[3].Descriptor() + // promocodeusage.DefaultUsedAt holds the default value on creation for the used_at field. + promocodeusage.DefaultUsedAt = promocodeusageDescUsedAt.Default.(func() time.Time) proxyMixin := schema.Proxy{}.Mixin() proxyMixinHooks1 := proxyMixin[1].Hooks() proxy.Hooks[0] = proxyMixinHooks1[0] @@ -533,16 +589,20 @@ func init() { usagelogDescUserAgent := usagelogFields[24].Descriptor() // usagelog.UserAgentValidator is a validator for the "user_agent" field. It is called by the builders before save. usagelog.UserAgentValidator = usagelogDescUserAgent.Validators[0].(func(string) error) + // usagelogDescIPAddress is the schema descriptor for ip_address field. + usagelogDescIPAddress := usagelogFields[25].Descriptor() + // usagelog.IPAddressValidator is a validator for the "ip_address" field. It is called by the builders before save. + usagelog.IPAddressValidator = usagelogDescIPAddress.Validators[0].(func(string) error) // usagelogDescImageCount is the schema descriptor for image_count field. - usagelogDescImageCount := usagelogFields[25].Descriptor() + usagelogDescImageCount := usagelogFields[26].Descriptor() // usagelog.DefaultImageCount holds the default value on creation for the image_count field. usagelog.DefaultImageCount = usagelogDescImageCount.Default.(int) // usagelogDescImageSize is the schema descriptor for image_size field. - usagelogDescImageSize := usagelogFields[26].Descriptor() + usagelogDescImageSize := usagelogFields[27].Descriptor() // usagelog.ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save. usagelog.ImageSizeValidator = usagelogDescImageSize.Validators[0].(func(string) error) // usagelogDescCreatedAt is the schema descriptor for created_at field. - usagelogDescCreatedAt := usagelogFields[27].Descriptor() + usagelogDescCreatedAt := usagelogFields[28].Descriptor() // usagelog.DefaultCreatedAt holds the default value on creation for the created_at field. usagelog.DefaultCreatedAt = usagelogDescCreatedAt.Default.(func() time.Time) userMixin := schema.User{}.Mixin() diff --git a/backend/ent/schema/api_key.go b/backend/ent/schema/api_key.go index 94e572c5..1b206089 100644 --- a/backend/ent/schema/api_key.go +++ b/backend/ent/schema/api_key.go @@ -46,6 +46,12 @@ func (APIKey) Fields() []ent.Field { field.String("status"). MaxLen(20). Default(service.StatusActive), + field.JSON("ip_whitelist", []string{}). + Optional(). + Comment("Allowed IPs/CIDRs, e.g. [\"192.168.1.100\", \"10.0.0.0/8\"]"), + field.JSON("ip_blacklist", []string{}). + Optional(). + Comment("Blocked IPs/CIDRs"), } } diff --git a/backend/ent/schema/promo_code.go b/backend/ent/schema/promo_code.go new file mode 100644 index 00000000..c3bb824b --- /dev/null +++ b/backend/ent/schema/promo_code.go @@ -0,0 +1,87 @@ +package schema + +import ( + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + + "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" +) + +// PromoCode holds the schema definition for the PromoCode entity. +// +// 注册优惠码:用户注册时使用,可获得赠送余额 +// 与 RedeemCode 不同,PromoCode 支持多次使用(有使用次数限制) +// +// 删除策略:硬删除 +type PromoCode struct { + ent.Schema +} + +func (PromoCode) Annotations() []schema.Annotation { + return []schema.Annotation{ + entsql.Annotation{Table: "promo_codes"}, + } +} + +func (PromoCode) Fields() []ent.Field { + return []ent.Field{ + field.String("code"). + MaxLen(32). + NotEmpty(). + Unique(). + Comment("优惠码"), + field.Float("bonus_amount"). + SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}). + Default(0). + Comment("赠送余额金额"), + field.Int("max_uses"). + Default(0). + Comment("最大使用次数,0表示无限制"), + field.Int("used_count"). + Default(0). + Comment("已使用次数"), + field.String("status"). + MaxLen(20). + Default(service.PromoCodeStatusActive). + Comment("状态: active, disabled"), + field.Time("expires_at"). + Optional(). + Nillable(). + SchemaType(map[string]string{dialect.Postgres: "timestamptz"}). + Comment("过期时间,null表示永不过期"), + field.String("notes"). + Optional(). + Nillable(). + SchemaType(map[string]string{dialect.Postgres: "text"}). + Comment("备注"), + 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 (PromoCode) Edges() []ent.Edge { + return []ent.Edge{ + edge.To("usage_records", PromoCodeUsage.Type), + } +} + +func (PromoCode) Indexes() []ent.Index { + return []ent.Index{ + // code 字段已在 Fields() 中声明 Unique(),无需重复索引 + index.Fields("status"), + index.Fields("expires_at"), + } +} diff --git a/backend/ent/schema/promo_code_usage.go b/backend/ent/schema/promo_code_usage.go new file mode 100644 index 00000000..28fbabea --- /dev/null +++ b/backend/ent/schema/promo_code_usage.go @@ -0,0 +1,66 @@ +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" +) + +// PromoCodeUsage holds the schema definition for the PromoCodeUsage entity. +// +// 优惠码使用记录:记录每个用户使用优惠码的情况 +type PromoCodeUsage struct { + ent.Schema +} + +func (PromoCodeUsage) Annotations() []schema.Annotation { + return []schema.Annotation{ + entsql.Annotation{Table: "promo_code_usages"}, + } +} + +func (PromoCodeUsage) Fields() []ent.Field { + return []ent.Field{ + field.Int64("promo_code_id"). + Comment("优惠码ID"), + field.Int64("user_id"). + Comment("使用用户ID"), + field.Float("bonus_amount"). + SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}). + Comment("实际赠送金额"), + field.Time("used_at"). + Default(time.Now). + SchemaType(map[string]string{dialect.Postgres: "timestamptz"}). + Comment("使用时间"), + } +} + +func (PromoCodeUsage) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("promo_code", PromoCode.Type). + Ref("usage_records"). + Field("promo_code_id"). + Required(). + Unique(), + edge.From("user", User.Type). + Ref("promo_code_usages"). + Field("user_id"). + Required(). + Unique(), + } +} + +func (PromoCodeUsage) Indexes() []ent.Index { + return []ent.Index{ + index.Fields("promo_code_id"), + index.Fields("user_id"), + // 每个用户每个优惠码只能使用一次 + index.Fields("promo_code_id", "user_id").Unique(), + } +} diff --git a/backend/ent/schema/usage_log.go b/backend/ent/schema/usage_log.go index df955181..264a4087 100644 --- a/backend/ent/schema/usage_log.go +++ b/backend/ent/schema/usage_log.go @@ -100,6 +100,10 @@ func (UsageLog) Fields() []ent.Field { MaxLen(512). Optional(). Nillable(), + field.String("ip_address"). + MaxLen(45). // 支持 IPv6 + Optional(). + Nillable(), // 图片生成字段(仅 gemini-3-pro-image 等图片模型使用) field.Int("image_count"). diff --git a/backend/ent/schema/user.go b/backend/ent/schema/user.go index 11fecdfd..79dc2286 100644 --- a/backend/ent/schema/user.go +++ b/backend/ent/schema/user.go @@ -74,6 +74,7 @@ func (User) Edges() []ent.Edge { Through("user_allowed_groups", UserAllowedGroup.Type), edge.To("usage_logs", UsageLog.Type), edge.To("attribute_values", UserAttributeValue.Type), + edge.To("promo_code_usages", PromoCodeUsage.Type), } } diff --git a/backend/ent/setting_query.go b/backend/ent/setting_query.go index e9dda6f5..38eb9462 100644 --- a/backend/ent/setting_query.go +++ b/backend/ent/setting_query.go @@ -8,6 +8,7 @@ import ( "math" "entgo.io/ent" + "entgo.io/ent/dialect" "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" @@ -22,6 +23,7 @@ type SettingQuery struct { order []setting.OrderOption inters []Interceptor predicates []predicate.Setting + modifiers []func(*sql.Selector) // intermediate query (i.e. traversal path). sql *sql.Selector path func(context.Context) (*sql.Selector, error) @@ -343,6 +345,9 @@ func (_q *SettingQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Sett nodes = append(nodes, node) return node.assignValues(columns, values) } + if len(_q.modifiers) > 0 { + _spec.Modifiers = _q.modifiers + } for i := range hooks { hooks[i](ctx, _spec) } @@ -357,6 +362,9 @@ func (_q *SettingQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Sett func (_q *SettingQuery) 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 @@ -419,6 +427,9 @@ func (_q *SettingQuery) sqlQuery(ctx context.Context) *sql.Selector { if _q.ctx.Unique != nil && *_q.ctx.Unique { selector.Distinct() } + for _, m := range _q.modifiers { + m(selector) + } for _, p := range _q.predicates { p(selector) } @@ -436,6 +447,32 @@ func (_q *SettingQuery) sqlQuery(ctx context.Context) *sql.Selector { 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 *SettingQuery) ForUpdate(opts ...sql.LockOption) *SettingQuery { + 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 *SettingQuery) ForShare(opts ...sql.LockOption) *SettingQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForShare(opts...) + }) + return _q +} + // SettingGroupBy is the group-by builder for Setting entities. type SettingGroupBy struct { selector diff --git a/backend/ent/tx.go b/backend/ent/tx.go index e45204c0..56df121a 100644 --- a/backend/ent/tx.go +++ b/backend/ent/tx.go @@ -22,6 +22,10 @@ type Tx struct { AccountGroup *AccountGroupClient // Group is the client for interacting with the Group builders. Group *GroupClient + // PromoCode is the client for interacting with the PromoCode builders. + PromoCode *PromoCodeClient + // PromoCodeUsage is the client for interacting with the PromoCodeUsage builders. + PromoCodeUsage *PromoCodeUsageClient // Proxy is the client for interacting with the Proxy builders. Proxy *ProxyClient // RedeemCode is the client for interacting with the RedeemCode builders. @@ -175,6 +179,8 @@ func (tx *Tx) init() { tx.Account = NewAccountClient(tx.config) tx.AccountGroup = NewAccountGroupClient(tx.config) tx.Group = NewGroupClient(tx.config) + tx.PromoCode = NewPromoCodeClient(tx.config) + tx.PromoCodeUsage = NewPromoCodeUsageClient(tx.config) tx.Proxy = NewProxyClient(tx.config) tx.RedeemCode = NewRedeemCodeClient(tx.config) tx.Setting = NewSettingClient(tx.config) diff --git a/backend/ent/usagelog.go b/backend/ent/usagelog.go index 798f3a9f..cd576466 100644 --- a/backend/ent/usagelog.go +++ b/backend/ent/usagelog.go @@ -72,6 +72,8 @@ type UsageLog struct { FirstTokenMs *int `json:"first_token_ms,omitempty"` // UserAgent holds the value of the "user_agent" field. UserAgent *string `json:"user_agent,omitempty"` + // IPAddress holds the value of the "ip_address" field. + IPAddress *string `json:"ip_address,omitempty"` // ImageCount holds the value of the "image_count" field. ImageCount int `json:"image_count,omitempty"` // ImageSize holds the value of the "image_size" field. @@ -167,7 +169,7 @@ func (*UsageLog) scanValues(columns []string) ([]any, error) { values[i] = new(sql.NullFloat64) case usagelog.FieldID, usagelog.FieldUserID, usagelog.FieldAPIKeyID, usagelog.FieldAccountID, usagelog.FieldGroupID, usagelog.FieldSubscriptionID, usagelog.FieldInputTokens, usagelog.FieldOutputTokens, usagelog.FieldCacheCreationTokens, usagelog.FieldCacheReadTokens, usagelog.FieldCacheCreation5mTokens, usagelog.FieldCacheCreation1hTokens, usagelog.FieldBillingType, usagelog.FieldDurationMs, usagelog.FieldFirstTokenMs, usagelog.FieldImageCount: values[i] = new(sql.NullInt64) - case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldUserAgent, usagelog.FieldImageSize: + case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldUserAgent, usagelog.FieldIPAddress, usagelog.FieldImageSize: values[i] = new(sql.NullString) case usagelog.FieldCreatedAt: values[i] = new(sql.NullTime) @@ -347,6 +349,13 @@ func (_m *UsageLog) assignValues(columns []string, values []any) error { _m.UserAgent = new(string) *_m.UserAgent = value.String } + case usagelog.FieldIPAddress: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field ip_address", values[i]) + } else if value.Valid { + _m.IPAddress = new(string) + *_m.IPAddress = value.String + } case usagelog.FieldImageCount: if value, ok := values[i].(*sql.NullInt64); !ok { return fmt.Errorf("unexpected type %T for field image_count", values[i]) @@ -512,6 +521,11 @@ func (_m *UsageLog) String() string { builder.WriteString(*v) } builder.WriteString(", ") + if v := _m.IPAddress; v != nil { + builder.WriteString("ip_address=") + builder.WriteString(*v) + } + builder.WriteString(", ") builder.WriteString("image_count=") builder.WriteString(fmt.Sprintf("%v", _m.ImageCount)) builder.WriteString(", ") diff --git a/backend/ent/usagelog/usagelog.go b/backend/ent/usagelog/usagelog.go index d3edfb4d..c06925c4 100644 --- a/backend/ent/usagelog/usagelog.go +++ b/backend/ent/usagelog/usagelog.go @@ -64,6 +64,8 @@ const ( FieldFirstTokenMs = "first_token_ms" // FieldUserAgent holds the string denoting the user_agent field in the database. FieldUserAgent = "user_agent" + // FieldIPAddress holds the string denoting the ip_address field in the database. + FieldIPAddress = "ip_address" // FieldImageCount holds the string denoting the image_count field in the database. FieldImageCount = "image_count" // FieldImageSize holds the string denoting the image_size field in the database. @@ -147,6 +149,7 @@ var Columns = []string{ FieldDurationMs, FieldFirstTokenMs, FieldUserAgent, + FieldIPAddress, FieldImageCount, FieldImageSize, FieldCreatedAt, @@ -199,6 +202,8 @@ var ( DefaultStream bool // UserAgentValidator is a validator for the "user_agent" field. It is called by the builders before save. UserAgentValidator func(string) error + // IPAddressValidator is a validator for the "ip_address" field. It is called by the builders before save. + IPAddressValidator func(string) error // DefaultImageCount holds the default value on creation for the "image_count" field. DefaultImageCount int // ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save. @@ -340,6 +345,11 @@ func ByUserAgent(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldUserAgent, opts...).ToFunc() } +// ByIPAddress orders the results by the ip_address field. +func ByIPAddress(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldIPAddress, opts...).ToFunc() +} + // ByImageCount orders the results by the image_count field. func ByImageCount(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldImageCount, opts...).ToFunc() diff --git a/backend/ent/usagelog/where.go b/backend/ent/usagelog/where.go index c7acd59d..96b7a19c 100644 --- a/backend/ent/usagelog/where.go +++ b/backend/ent/usagelog/where.go @@ -180,6 +180,11 @@ func UserAgent(v string) predicate.UsageLog { return predicate.UsageLog(sql.FieldEQ(FieldUserAgent, v)) } +// IPAddress applies equality check predicate on the "ip_address" field. It's identical to IPAddressEQ. +func IPAddress(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldIPAddress, v)) +} + // ImageCount applies equality check predicate on the "image_count" field. It's identical to ImageCountEQ. func ImageCount(v int) predicate.UsageLog { return predicate.UsageLog(sql.FieldEQ(FieldImageCount, v)) @@ -1190,6 +1195,81 @@ func UserAgentContainsFold(v string) predicate.UsageLog { return predicate.UsageLog(sql.FieldContainsFold(FieldUserAgent, v)) } +// IPAddressEQ applies the EQ predicate on the "ip_address" field. +func IPAddressEQ(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEQ(FieldIPAddress, v)) +} + +// IPAddressNEQ applies the NEQ predicate on the "ip_address" field. +func IPAddressNEQ(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNEQ(FieldIPAddress, v)) +} + +// IPAddressIn applies the In predicate on the "ip_address" field. +func IPAddressIn(vs ...string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldIn(FieldIPAddress, vs...)) +} + +// IPAddressNotIn applies the NotIn predicate on the "ip_address" field. +func IPAddressNotIn(vs ...string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotIn(FieldIPAddress, vs...)) +} + +// IPAddressGT applies the GT predicate on the "ip_address" field. +func IPAddressGT(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGT(FieldIPAddress, v)) +} + +// IPAddressGTE applies the GTE predicate on the "ip_address" field. +func IPAddressGTE(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldGTE(FieldIPAddress, v)) +} + +// IPAddressLT applies the LT predicate on the "ip_address" field. +func IPAddressLT(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLT(FieldIPAddress, v)) +} + +// IPAddressLTE applies the LTE predicate on the "ip_address" field. +func IPAddressLTE(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldLTE(FieldIPAddress, v)) +} + +// IPAddressContains applies the Contains predicate on the "ip_address" field. +func IPAddressContains(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldContains(FieldIPAddress, v)) +} + +// IPAddressHasPrefix applies the HasPrefix predicate on the "ip_address" field. +func IPAddressHasPrefix(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldHasPrefix(FieldIPAddress, v)) +} + +// IPAddressHasSuffix applies the HasSuffix predicate on the "ip_address" field. +func IPAddressHasSuffix(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldHasSuffix(FieldIPAddress, v)) +} + +// IPAddressIsNil applies the IsNil predicate on the "ip_address" field. +func IPAddressIsNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldIsNull(FieldIPAddress)) +} + +// IPAddressNotNil applies the NotNil predicate on the "ip_address" field. +func IPAddressNotNil() predicate.UsageLog { + return predicate.UsageLog(sql.FieldNotNull(FieldIPAddress)) +} + +// IPAddressEqualFold applies the EqualFold predicate on the "ip_address" field. +func IPAddressEqualFold(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldEqualFold(FieldIPAddress, v)) +} + +// IPAddressContainsFold applies the ContainsFold predicate on the "ip_address" field. +func IPAddressContainsFold(v string) predicate.UsageLog { + return predicate.UsageLog(sql.FieldContainsFold(FieldIPAddress, v)) +} + // ImageCountEQ applies the EQ predicate on the "image_count" field. func ImageCountEQ(v int) predicate.UsageLog { return predicate.UsageLog(sql.FieldEQ(FieldImageCount, v)) diff --git a/backend/ent/usagelog_create.go b/backend/ent/usagelog_create.go index f77650ab..e63fab05 100644 --- a/backend/ent/usagelog_create.go +++ b/backend/ent/usagelog_create.go @@ -337,6 +337,20 @@ func (_c *UsageLogCreate) SetNillableUserAgent(v *string) *UsageLogCreate { return _c } +// SetIPAddress sets the "ip_address" field. +func (_c *UsageLogCreate) SetIPAddress(v string) *UsageLogCreate { + _c.mutation.SetIPAddress(v) + return _c +} + +// SetNillableIPAddress sets the "ip_address" field if the given value is not nil. +func (_c *UsageLogCreate) SetNillableIPAddress(v *string) *UsageLogCreate { + if v != nil { + _c.SetIPAddress(*v) + } + return _c +} + // SetImageCount sets the "image_count" field. func (_c *UsageLogCreate) SetImageCount(v int) *UsageLogCreate { _c.mutation.SetImageCount(v) @@ -586,6 +600,11 @@ func (_c *UsageLogCreate) check() error { return &ValidationError{Name: "user_agent", err: fmt.Errorf(`ent: validator failed for field "UsageLog.user_agent": %w`, err)} } } + if v, ok := _c.mutation.IPAddress(); ok { + if err := usagelog.IPAddressValidator(v); err != nil { + return &ValidationError{Name: "ip_address", err: fmt.Errorf(`ent: validator failed for field "UsageLog.ip_address": %w`, err)} + } + } if _, ok := _c.mutation.ImageCount(); !ok { return &ValidationError{Name: "image_count", err: errors.New(`ent: missing required field "UsageLog.image_count"`)} } @@ -713,6 +732,10 @@ func (_c *UsageLogCreate) createSpec() (*UsageLog, *sqlgraph.CreateSpec) { _spec.SetField(usagelog.FieldUserAgent, field.TypeString, value) _node.UserAgent = &value } + if value, ok := _c.mutation.IPAddress(); ok { + _spec.SetField(usagelog.FieldIPAddress, field.TypeString, value) + _node.IPAddress = &value + } if value, ok := _c.mutation.ImageCount(); ok { _spec.SetField(usagelog.FieldImageCount, field.TypeInt, value) _node.ImageCount = value @@ -1288,6 +1311,24 @@ func (u *UsageLogUpsert) ClearUserAgent() *UsageLogUpsert { return u } +// SetIPAddress sets the "ip_address" field. +func (u *UsageLogUpsert) SetIPAddress(v string) *UsageLogUpsert { + u.Set(usagelog.FieldIPAddress, v) + return u +} + +// UpdateIPAddress sets the "ip_address" field to the value that was provided on create. +func (u *UsageLogUpsert) UpdateIPAddress() *UsageLogUpsert { + u.SetExcluded(usagelog.FieldIPAddress) + return u +} + +// ClearIPAddress clears the value of the "ip_address" field. +func (u *UsageLogUpsert) ClearIPAddress() *UsageLogUpsert { + u.SetNull(usagelog.FieldIPAddress) + return u +} + // SetImageCount sets the "image_count" field. func (u *UsageLogUpsert) SetImageCount(v int) *UsageLogUpsert { u.Set(usagelog.FieldImageCount, v) @@ -1866,6 +1907,27 @@ func (u *UsageLogUpsertOne) ClearUserAgent() *UsageLogUpsertOne { }) } +// SetIPAddress sets the "ip_address" field. +func (u *UsageLogUpsertOne) SetIPAddress(v string) *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.SetIPAddress(v) + }) +} + +// UpdateIPAddress sets the "ip_address" field to the value that was provided on create. +func (u *UsageLogUpsertOne) UpdateIPAddress() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateIPAddress() + }) +} + +// ClearIPAddress clears the value of the "ip_address" field. +func (u *UsageLogUpsertOne) ClearIPAddress() *UsageLogUpsertOne { + return u.Update(func(s *UsageLogUpsert) { + s.ClearIPAddress() + }) +} + // SetImageCount sets the "image_count" field. func (u *UsageLogUpsertOne) SetImageCount(v int) *UsageLogUpsertOne { return u.Update(func(s *UsageLogUpsert) { @@ -2616,6 +2678,27 @@ func (u *UsageLogUpsertBulk) ClearUserAgent() *UsageLogUpsertBulk { }) } +// SetIPAddress sets the "ip_address" field. +func (u *UsageLogUpsertBulk) SetIPAddress(v string) *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.SetIPAddress(v) + }) +} + +// UpdateIPAddress sets the "ip_address" field to the value that was provided on create. +func (u *UsageLogUpsertBulk) UpdateIPAddress() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.UpdateIPAddress() + }) +} + +// ClearIPAddress clears the value of the "ip_address" field. +func (u *UsageLogUpsertBulk) ClearIPAddress() *UsageLogUpsertBulk { + return u.Update(func(s *UsageLogUpsert) { + s.ClearIPAddress() + }) +} + // SetImageCount sets the "image_count" field. func (u *UsageLogUpsertBulk) SetImageCount(v int) *UsageLogUpsertBulk { return u.Update(func(s *UsageLogUpsert) { diff --git a/backend/ent/usagelog_query.go b/backend/ent/usagelog_query.go index de64171a..c709bde0 100644 --- a/backend/ent/usagelog_query.go +++ b/backend/ent/usagelog_query.go @@ -8,6 +8,7 @@ import ( "math" "entgo.io/ent" + "entgo.io/ent/dialect" "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" @@ -32,6 +33,7 @@ type UsageLogQuery struct { withAccount *AccountQuery withGroup *GroupQuery withSubscription *UserSubscriptionQuery + modifiers []func(*sql.Selector) // intermediate query (i.e. traversal path). sql *sql.Selector path func(context.Context) (*sql.Selector, error) @@ -531,6 +533,9 @@ func (_q *UsageLogQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Usa 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) } @@ -727,6 +732,9 @@ func (_q *UsageLogQuery) loadSubscription(ctx context.Context, query *UserSubscr func (_q *UsageLogQuery) 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 @@ -804,6 +812,9 @@ func (_q *UsageLogQuery) sqlQuery(ctx context.Context) *sql.Selector { if _q.ctx.Unique != nil && *_q.ctx.Unique { selector.Distinct() } + for _, m := range _q.modifiers { + m(selector) + } for _, p := range _q.predicates { p(selector) } @@ -821,6 +832,32 @@ func (_q *UsageLogQuery) sqlQuery(ctx context.Context) *sql.Selector { 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 *UsageLogQuery) ForUpdate(opts ...sql.LockOption) *UsageLogQuery { + 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 *UsageLogQuery) ForShare(opts ...sql.LockOption) *UsageLogQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForShare(opts...) + }) + return _q +} + // UsageLogGroupBy is the group-by builder for UsageLog entities. type UsageLogGroupBy struct { selector diff --git a/backend/ent/usagelog_update.go b/backend/ent/usagelog_update.go index 2e77eef7..ec2acbbb 100644 --- a/backend/ent/usagelog_update.go +++ b/backend/ent/usagelog_update.go @@ -524,6 +524,26 @@ func (_u *UsageLogUpdate) ClearUserAgent() *UsageLogUpdate { return _u } +// SetIPAddress sets the "ip_address" field. +func (_u *UsageLogUpdate) SetIPAddress(v string) *UsageLogUpdate { + _u.mutation.SetIPAddress(v) + return _u +} + +// SetNillableIPAddress sets the "ip_address" field if the given value is not nil. +func (_u *UsageLogUpdate) SetNillableIPAddress(v *string) *UsageLogUpdate { + if v != nil { + _u.SetIPAddress(*v) + } + return _u +} + +// ClearIPAddress clears the value of the "ip_address" field. +func (_u *UsageLogUpdate) ClearIPAddress() *UsageLogUpdate { + _u.mutation.ClearIPAddress() + return _u +} + // SetImageCount sets the "image_count" field. func (_u *UsageLogUpdate) SetImageCount(v int) *UsageLogUpdate { _u.mutation.ResetImageCount() @@ -669,6 +689,11 @@ func (_u *UsageLogUpdate) check() error { return &ValidationError{Name: "user_agent", err: fmt.Errorf(`ent: validator failed for field "UsageLog.user_agent": %w`, err)} } } + if v, ok := _u.mutation.IPAddress(); ok { + if err := usagelog.IPAddressValidator(v); err != nil { + return &ValidationError{Name: "ip_address", err: fmt.Errorf(`ent: validator failed for field "UsageLog.ip_address": %w`, err)} + } + } if v, ok := _u.mutation.ImageSize(); ok { if err := usagelog.ImageSizeValidator(v); err != nil { return &ValidationError{Name: "image_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size": %w`, err)} @@ -815,6 +840,12 @@ func (_u *UsageLogUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.UserAgentCleared() { _spec.ClearField(usagelog.FieldUserAgent, field.TypeString) } + if value, ok := _u.mutation.IPAddress(); ok { + _spec.SetField(usagelog.FieldIPAddress, field.TypeString, value) + } + if _u.mutation.IPAddressCleared() { + _spec.ClearField(usagelog.FieldIPAddress, field.TypeString) + } if value, ok := _u.mutation.ImageCount(); ok { _spec.SetField(usagelog.FieldImageCount, field.TypeInt, value) } @@ -1484,6 +1515,26 @@ func (_u *UsageLogUpdateOne) ClearUserAgent() *UsageLogUpdateOne { return _u } +// SetIPAddress sets the "ip_address" field. +func (_u *UsageLogUpdateOne) SetIPAddress(v string) *UsageLogUpdateOne { + _u.mutation.SetIPAddress(v) + return _u +} + +// SetNillableIPAddress sets the "ip_address" field if the given value is not nil. +func (_u *UsageLogUpdateOne) SetNillableIPAddress(v *string) *UsageLogUpdateOne { + if v != nil { + _u.SetIPAddress(*v) + } + return _u +} + +// ClearIPAddress clears the value of the "ip_address" field. +func (_u *UsageLogUpdateOne) ClearIPAddress() *UsageLogUpdateOne { + _u.mutation.ClearIPAddress() + return _u +} + // SetImageCount sets the "image_count" field. func (_u *UsageLogUpdateOne) SetImageCount(v int) *UsageLogUpdateOne { _u.mutation.ResetImageCount() @@ -1642,6 +1693,11 @@ func (_u *UsageLogUpdateOne) check() error { return &ValidationError{Name: "user_agent", err: fmt.Errorf(`ent: validator failed for field "UsageLog.user_agent": %w`, err)} } } + if v, ok := _u.mutation.IPAddress(); ok { + if err := usagelog.IPAddressValidator(v); err != nil { + return &ValidationError{Name: "ip_address", err: fmt.Errorf(`ent: validator failed for field "UsageLog.ip_address": %w`, err)} + } + } if v, ok := _u.mutation.ImageSize(); ok { if err := usagelog.ImageSizeValidator(v); err != nil { return &ValidationError{Name: "image_size", err: fmt.Errorf(`ent: validator failed for field "UsageLog.image_size": %w`, err)} @@ -1805,6 +1861,12 @@ func (_u *UsageLogUpdateOne) sqlSave(ctx context.Context) (_node *UsageLog, err if _u.mutation.UserAgentCleared() { _spec.ClearField(usagelog.FieldUserAgent, field.TypeString) } + if value, ok := _u.mutation.IPAddress(); ok { + _spec.SetField(usagelog.FieldIPAddress, field.TypeString, value) + } + if _u.mutation.IPAddressCleared() { + _spec.ClearField(usagelog.FieldIPAddress, field.TypeString) + } if value, ok := _u.mutation.ImageCount(); ok { _spec.SetField(usagelog.FieldImageCount, field.TypeInt, value) } diff --git a/backend/ent/user.go b/backend/ent/user.go index 20036475..0b9a48cc 100644 --- a/backend/ent/user.go +++ b/backend/ent/user.go @@ -61,11 +61,13 @@ type UserEdges struct { UsageLogs []*UsageLog `json:"usage_logs,omitempty"` // AttributeValues holds the value of the attribute_values edge. AttributeValues []*UserAttributeValue `json:"attribute_values,omitempty"` + // PromoCodeUsages holds the value of the promo_code_usages edge. + PromoCodeUsages []*PromoCodeUsage `json:"promo_code_usages,omitempty"` // UserAllowedGroups holds the value of the user_allowed_groups edge. 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 [8]bool + loadedTypes [9]bool } // APIKeysOrErr returns the APIKeys value or an error if the edge @@ -131,10 +133,19 @@ func (e UserEdges) AttributeValuesOrErr() ([]*UserAttributeValue, error) { return nil, &NotLoadedError{edge: "attribute_values"} } +// 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] { + return e.PromoCodeUsages, nil + } + return nil, &NotLoadedError{edge: "promo_code_usages"} +} + // 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[7] { + if e.loadedTypes[8] { return e.UserAllowedGroups, nil } return nil, &NotLoadedError{edge: "user_allowed_groups"} @@ -289,6 +300,11 @@ func (_m *User) QueryAttributeValues() *UserAttributeValueQuery { return NewUserClient(_m.config).QueryAttributeValues(_m) } +// QueryPromoCodeUsages queries the "promo_code_usages" edge of the User entity. +func (_m *User) QueryPromoCodeUsages() *PromoCodeUsageQuery { + return NewUserClient(_m.config).QueryPromoCodeUsages(_m) +} + // QueryUserAllowedGroups queries the "user_allowed_groups" edge of the User entity. func (_m *User) QueryUserAllowedGroups() *UserAllowedGroupQuery { return NewUserClient(_m.config).QueryUserAllowedGroups(_m) diff --git a/backend/ent/user/user.go b/backend/ent/user/user.go index a6871c5d..1be1d871 100644 --- a/backend/ent/user/user.go +++ b/backend/ent/user/user.go @@ -51,6 +51,8 @@ const ( EdgeUsageLogs = "usage_logs" // EdgeAttributeValues holds the string denoting the attribute_values edge name in mutations. EdgeAttributeValues = "attribute_values" + // EdgePromoCodeUsages holds the string denoting the promo_code_usages edge name in mutations. + EdgePromoCodeUsages = "promo_code_usages" // EdgeUserAllowedGroups holds the string denoting the user_allowed_groups edge name in mutations. EdgeUserAllowedGroups = "user_allowed_groups" // Table holds the table name of the user in the database. @@ -102,6 +104,13 @@ const ( AttributeValuesInverseTable = "user_attribute_values" // AttributeValuesColumn is the table column denoting the attribute_values relation/edge. AttributeValuesColumn = "user_id" + // PromoCodeUsagesTable is the table that holds the promo_code_usages relation/edge. + PromoCodeUsagesTable = "promo_code_usages" + // PromoCodeUsagesInverseTable is the table name for the PromoCodeUsage entity. + // It exists in this package in order to avoid circular dependency with the "promocodeusage" package. + PromoCodeUsagesInverseTable = "promo_code_usages" + // PromoCodeUsagesColumn is the table column denoting the promo_code_usages relation/edge. + PromoCodeUsagesColumn = "user_id" // UserAllowedGroupsTable is the table that holds the user_allowed_groups relation/edge. UserAllowedGroupsTable = "user_allowed_groups" // UserAllowedGroupsInverseTable is the table name for the UserAllowedGroup entity. @@ -342,6 +351,20 @@ func ByAttributeValues(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption { } } +// ByPromoCodeUsagesCount orders the results by promo_code_usages count. +func ByPromoCodeUsagesCount(opts ...sql.OrderTermOption) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborsCount(s, newPromoCodeUsagesStep(), opts...) + } +} + +// ByPromoCodeUsages orders the results by promo_code_usages terms. +func ByPromoCodeUsages(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborTerms(s, newPromoCodeUsagesStep(), append([]sql.OrderTerm{term}, terms...)...) + } +} + // ByUserAllowedGroupsCount orders the results by user_allowed_groups count. func ByUserAllowedGroupsCount(opts ...sql.OrderTermOption) OrderOption { return func(s *sql.Selector) { @@ -404,6 +427,13 @@ func newAttributeValuesStep() *sqlgraph.Step { sqlgraph.Edge(sqlgraph.O2M, false, AttributeValuesTable, AttributeValuesColumn), ) } +func newPromoCodeUsagesStep() *sqlgraph.Step { + return sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.To(PromoCodeUsagesInverseTable, FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, PromoCodeUsagesTable, PromoCodeUsagesColumn), + ) +} func newUserAllowedGroupsStep() *sqlgraph.Step { return sqlgraph.NewStep( sqlgraph.From(Table, FieldID), diff --git a/backend/ent/user/where.go b/backend/ent/user/where.go index 38812770..6a460f10 100644 --- a/backend/ent/user/where.go +++ b/backend/ent/user/where.go @@ -871,6 +871,29 @@ func HasAttributeValuesWith(preds ...predicate.UserAttributeValue) predicate.Use }) } +// HasPromoCodeUsages applies the HasEdge predicate on the "promo_code_usages" edge. +func HasPromoCodeUsages() predicate.User { + return predicate.User(func(s *sql.Selector) { + step := sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, PromoCodeUsagesTable, PromoCodeUsagesColumn), + ) + sqlgraph.HasNeighbors(s, step) + }) +} + +// HasPromoCodeUsagesWith applies the HasEdge predicate on the "promo_code_usages" edge with a given conditions (other predicates). +func HasPromoCodeUsagesWith(preds ...predicate.PromoCodeUsage) predicate.User { + return predicate.User(func(s *sql.Selector) { + step := newPromoCodeUsagesStep() + sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { + for _, p := range preds { + p(s) + } + }) + }) +} + // HasUserAllowedGroups applies the HasEdge predicate on the "user_allowed_groups" edge. func HasUserAllowedGroups() predicate.User { return predicate.User(func(s *sql.Selector) { diff --git a/backend/ent/user_create.go b/backend/ent/user_create.go index 4ce48d4b..e12e476c 100644 --- a/backend/ent/user_create.go +++ b/backend/ent/user_create.go @@ -13,6 +13,7 @@ import ( "entgo.io/ent/schema/field" "github.com/Wei-Shaw/sub2api/ent/apikey" "github.com/Wei-Shaw/sub2api/ent/group" + "github.com/Wei-Shaw/sub2api/ent/promocodeusage" "github.com/Wei-Shaw/sub2api/ent/redeemcode" "github.com/Wei-Shaw/sub2api/ent/usagelog" "github.com/Wei-Shaw/sub2api/ent/user" @@ -271,6 +272,21 @@ func (_c *UserCreate) AddAttributeValues(v ...*UserAttributeValue) *UserCreate { return _c.AddAttributeValueIDs(ids...) } +// AddPromoCodeUsageIDs adds the "promo_code_usages" edge to the PromoCodeUsage entity by IDs. +func (_c *UserCreate) AddPromoCodeUsageIDs(ids ...int64) *UserCreate { + _c.mutation.AddPromoCodeUsageIDs(ids...) + return _c +} + +// AddPromoCodeUsages adds the "promo_code_usages" edges to the PromoCodeUsage entity. +func (_c *UserCreate) AddPromoCodeUsages(v ...*PromoCodeUsage) *UserCreate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _c.AddPromoCodeUsageIDs(ids...) +} + // Mutation returns the UserMutation object of the builder. func (_c *UserCreate) Mutation() *UserMutation { return _c.mutation @@ -593,6 +609,22 @@ func (_c *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) { } _spec.Edges = append(_spec.Edges, edge) } + if nodes := _c.mutation.PromoCodeUsagesIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.PromoCodeUsagesTable, + Columns: []string{user.PromoCodeUsagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(promocodeusage.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges = append(_spec.Edges, edge) + } return _node, _spec } diff --git a/backend/ent/user_query.go b/backend/ent/user_query.go index 0d65a2dd..e66e2dc8 100644 --- a/backend/ent/user_query.go +++ b/backend/ent/user_query.go @@ -9,12 +9,14 @@ import ( "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/apikey" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/predicate" + "github.com/Wei-Shaw/sub2api/ent/promocodeusage" "github.com/Wei-Shaw/sub2api/ent/redeemcode" "github.com/Wei-Shaw/sub2api/ent/usagelog" "github.com/Wei-Shaw/sub2api/ent/user" @@ -37,7 +39,9 @@ type UserQuery struct { withAllowedGroups *GroupQuery withUsageLogs *UsageLogQuery withAttributeValues *UserAttributeValueQuery + withPromoCodeUsages *PromoCodeUsageQuery withUserAllowedGroups *UserAllowedGroupQuery + modifiers []func(*sql.Selector) // intermediate query (i.e. traversal path). sql *sql.Selector path func(context.Context) (*sql.Selector, error) @@ -228,6 +232,28 @@ func (_q *UserQuery) QueryAttributeValues() *UserAttributeValueQuery { return query } +// QueryPromoCodeUsages chains the current query on the "promo_code_usages" edge. +func (_q *UserQuery) QueryPromoCodeUsages() *PromoCodeUsageQuery { + query := (&PromoCodeUsageClient{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(promocodeusage.Table, promocodeusage.FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, user.PromoCodeUsagesTable, user.PromoCodeUsagesColumn), + ) + fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step) + return fromU, nil + } + return query +} + // QueryUserAllowedGroups chains the current query on the "user_allowed_groups" edge. func (_q *UserQuery) QueryUserAllowedGroups() *UserAllowedGroupQuery { query := (&UserAllowedGroupClient{config: _q.config}).Query() @@ -449,6 +475,7 @@ func (_q *UserQuery) Clone() *UserQuery { withAllowedGroups: _q.withAllowedGroups.Clone(), withUsageLogs: _q.withUsageLogs.Clone(), withAttributeValues: _q.withAttributeValues.Clone(), + withPromoCodeUsages: _q.withPromoCodeUsages.Clone(), withUserAllowedGroups: _q.withUserAllowedGroups.Clone(), // clone intermediate query. sql: _q.sql.Clone(), @@ -533,6 +560,17 @@ func (_q *UserQuery) WithAttributeValues(opts ...func(*UserAttributeValueQuery)) return _q } +// WithPromoCodeUsages tells the query-builder to eager-load the nodes that are connected to +// the "promo_code_usages" edge. The optional arguments are used to configure the query builder of the edge. +func (_q *UserQuery) WithPromoCodeUsages(opts ...func(*PromoCodeUsageQuery)) *UserQuery { + query := (&PromoCodeUsageClient{config: _q.config}).Query() + for _, opt := range opts { + opt(query) + } + _q.withPromoCodeUsages = query + return _q +} + // WithUserAllowedGroups tells the query-builder to eager-load the nodes that are connected to // the "user_allowed_groups" edge. The optional arguments are used to configure the query builder of the edge. func (_q *UserQuery) WithUserAllowedGroups(opts ...func(*UserAllowedGroupQuery)) *UserQuery { @@ -622,7 +660,7 @@ func (_q *UserQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*User, e var ( nodes = []*User{} _spec = _q.querySpec() - loadedTypes = [8]bool{ + loadedTypes = [9]bool{ _q.withAPIKeys != nil, _q.withRedeemCodes != nil, _q.withSubscriptions != nil, @@ -630,6 +668,7 @@ func (_q *UserQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*User, e _q.withAllowedGroups != nil, _q.withUsageLogs != nil, _q.withAttributeValues != nil, + _q.withPromoCodeUsages != nil, _q.withUserAllowedGroups != nil, } ) @@ -642,6 +681,9 @@ func (_q *UserQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*User, e 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) } @@ -702,6 +744,13 @@ func (_q *UserQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*User, e return nil, err } } + if query := _q.withPromoCodeUsages; query != nil { + if err := _q.loadPromoCodeUsages(ctx, query, nodes, + func(n *User) { n.Edges.PromoCodeUsages = []*PromoCodeUsage{} }, + func(n *User, e *PromoCodeUsage) { n.Edges.PromoCodeUsages = append(n.Edges.PromoCodeUsages, e) }); err != nil { + return nil, err + } + } if query := _q.withUserAllowedGroups; query != nil { if err := _q.loadUserAllowedGroups(ctx, query, nodes, func(n *User) { n.Edges.UserAllowedGroups = []*UserAllowedGroup{} }, @@ -959,6 +1008,36 @@ func (_q *UserQuery) loadAttributeValues(ctx context.Context, query *UserAttribu } return nil } +func (_q *UserQuery) loadPromoCodeUsages(ctx context.Context, query *PromoCodeUsageQuery, nodes []*User, init func(*User), assign func(*User, *PromoCodeUsage)) 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(promocodeusage.FieldUserID) + } + query.Where(predicate.PromoCodeUsage(func(s *sql.Selector) { + s.Where(sql.InValues(s.C(user.PromoCodeUsagesColumn), 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) loadUserAllowedGroups(ctx context.Context, query *UserAllowedGroupQuery, nodes []*User, init func(*User), assign func(*User, *UserAllowedGroup)) error { fks := make([]driver.Value, 0, len(nodes)) nodeids := make(map[int64]*User) @@ -992,6 +1071,9 @@ func (_q *UserQuery) loadUserAllowedGroups(ctx context.Context, query *UserAllow func (_q *UserQuery) 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 @@ -1054,6 +1136,9 @@ func (_q *UserQuery) sqlQuery(ctx context.Context) *sql.Selector { if _q.ctx.Unique != nil && *_q.ctx.Unique { selector.Distinct() } + for _, m := range _q.modifiers { + m(selector) + } for _, p := range _q.predicates { p(selector) } @@ -1071,6 +1156,32 @@ func (_q *UserQuery) sqlQuery(ctx context.Context) *sql.Selector { 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 *UserQuery) ForUpdate(opts ...sql.LockOption) *UserQuery { + 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 *UserQuery) ForShare(opts ...sql.LockOption) *UserQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForShare(opts...) + }) + return _q +} + // UserGroupBy is the group-by builder for User entities. type UserGroupBy struct { selector diff --git a/backend/ent/user_update.go b/backend/ent/user_update.go index 49ddf493..cf189fea 100644 --- a/backend/ent/user_update.go +++ b/backend/ent/user_update.go @@ -14,6 +14,7 @@ import ( "github.com/Wei-Shaw/sub2api/ent/apikey" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/predicate" + "github.com/Wei-Shaw/sub2api/ent/promocodeusage" "github.com/Wei-Shaw/sub2api/ent/redeemcode" "github.com/Wei-Shaw/sub2api/ent/usagelog" "github.com/Wei-Shaw/sub2api/ent/user" @@ -291,6 +292,21 @@ func (_u *UserUpdate) AddAttributeValues(v ...*UserAttributeValue) *UserUpdate { return _u.AddAttributeValueIDs(ids...) } +// AddPromoCodeUsageIDs adds the "promo_code_usages" edge to the PromoCodeUsage entity by IDs. +func (_u *UserUpdate) AddPromoCodeUsageIDs(ids ...int64) *UserUpdate { + _u.mutation.AddPromoCodeUsageIDs(ids...) + return _u +} + +// AddPromoCodeUsages adds the "promo_code_usages" edges to the PromoCodeUsage entity. +func (_u *UserUpdate) AddPromoCodeUsages(v ...*PromoCodeUsage) *UserUpdate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.AddPromoCodeUsageIDs(ids...) +} + // Mutation returns the UserMutation object of the builder. func (_u *UserUpdate) Mutation() *UserMutation { return _u.mutation @@ -443,6 +459,27 @@ func (_u *UserUpdate) RemoveAttributeValues(v ...*UserAttributeValue) *UserUpdat return _u.RemoveAttributeValueIDs(ids...) } +// ClearPromoCodeUsages clears all "promo_code_usages" edges to the PromoCodeUsage entity. +func (_u *UserUpdate) ClearPromoCodeUsages() *UserUpdate { + _u.mutation.ClearPromoCodeUsages() + return _u +} + +// RemovePromoCodeUsageIDs removes the "promo_code_usages" edge to PromoCodeUsage entities by IDs. +func (_u *UserUpdate) RemovePromoCodeUsageIDs(ids ...int64) *UserUpdate { + _u.mutation.RemovePromoCodeUsageIDs(ids...) + return _u +} + +// RemovePromoCodeUsages removes "promo_code_usages" edges to PromoCodeUsage entities. +func (_u *UserUpdate) RemovePromoCodeUsages(v ...*PromoCodeUsage) *UserUpdate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.RemovePromoCodeUsageIDs(ids...) +} + // Save executes the query and returns the number of nodes affected by the update operation. func (_u *UserUpdate) Save(ctx context.Context) (int, error) { if err := _u.defaults(); err != nil { @@ -893,6 +930,51 @@ func (_u *UserUpdate) sqlSave(ctx context.Context) (_node int, err error) { } _spec.Edges.Add = append(_spec.Edges.Add, edge) } + if _u.mutation.PromoCodeUsagesCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.PromoCodeUsagesTable, + Columns: []string{user.PromoCodeUsagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(promocodeusage.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.RemovedPromoCodeUsagesIDs(); len(nodes) > 0 && !_u.mutation.PromoCodeUsagesCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.PromoCodeUsagesTable, + Columns: []string{user.PromoCodeUsagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(promocodeusage.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.PromoCodeUsagesIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.PromoCodeUsagesTable, + Columns: []string{user.PromoCodeUsagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(promocodeusage.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{user.Label} @@ -1170,6 +1252,21 @@ func (_u *UserUpdateOne) AddAttributeValues(v ...*UserAttributeValue) *UserUpdat return _u.AddAttributeValueIDs(ids...) } +// AddPromoCodeUsageIDs adds the "promo_code_usages" edge to the PromoCodeUsage entity by IDs. +func (_u *UserUpdateOne) AddPromoCodeUsageIDs(ids ...int64) *UserUpdateOne { + _u.mutation.AddPromoCodeUsageIDs(ids...) + return _u +} + +// AddPromoCodeUsages adds the "promo_code_usages" edges to the PromoCodeUsage entity. +func (_u *UserUpdateOne) AddPromoCodeUsages(v ...*PromoCodeUsage) *UserUpdateOne { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.AddPromoCodeUsageIDs(ids...) +} + // Mutation returns the UserMutation object of the builder. func (_u *UserUpdateOne) Mutation() *UserMutation { return _u.mutation @@ -1322,6 +1419,27 @@ func (_u *UserUpdateOne) RemoveAttributeValues(v ...*UserAttributeValue) *UserUp return _u.RemoveAttributeValueIDs(ids...) } +// ClearPromoCodeUsages clears all "promo_code_usages" edges to the PromoCodeUsage entity. +func (_u *UserUpdateOne) ClearPromoCodeUsages() *UserUpdateOne { + _u.mutation.ClearPromoCodeUsages() + return _u +} + +// RemovePromoCodeUsageIDs removes the "promo_code_usages" edge to PromoCodeUsage entities by IDs. +func (_u *UserUpdateOne) RemovePromoCodeUsageIDs(ids ...int64) *UserUpdateOne { + _u.mutation.RemovePromoCodeUsageIDs(ids...) + return _u +} + +// RemovePromoCodeUsages removes "promo_code_usages" edges to PromoCodeUsage entities. +func (_u *UserUpdateOne) RemovePromoCodeUsages(v ...*PromoCodeUsage) *UserUpdateOne { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.RemovePromoCodeUsageIDs(ids...) +} + // Where appends a list predicates to the UserUpdate builder. func (_u *UserUpdateOne) Where(ps ...predicate.User) *UserUpdateOne { _u.mutation.Where(ps...) @@ -1802,6 +1920,51 @@ func (_u *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) { } _spec.Edges.Add = append(_spec.Edges.Add, edge) } + if _u.mutation.PromoCodeUsagesCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.PromoCodeUsagesTable, + Columns: []string{user.PromoCodeUsagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(promocodeusage.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.RemovedPromoCodeUsagesIDs(); len(nodes) > 0 && !_u.mutation.PromoCodeUsagesCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.PromoCodeUsagesTable, + Columns: []string{user.PromoCodeUsagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(promocodeusage.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.PromoCodeUsagesIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.PromoCodeUsagesTable, + Columns: []string{user.PromoCodeUsagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(promocodeusage.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } _node = &User{config: _u.config} _spec.Assign = _node.assignValues _spec.ScanValues = _node.scanValues diff --git a/backend/ent/userallowedgroup_query.go b/backend/ent/userallowedgroup_query.go index da2c19a7..527ddc77 100644 --- a/backend/ent/userallowedgroup_query.go +++ b/backend/ent/userallowedgroup_query.go @@ -8,6 +8,7 @@ import ( "math" "entgo.io/ent" + "entgo.io/ent/dialect" "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" "github.com/Wei-Shaw/sub2api/ent/group" @@ -25,6 +26,7 @@ type UserAllowedGroupQuery struct { predicates []predicate.UserAllowedGroup withUser *UserQuery withGroup *GroupQuery + modifiers []func(*sql.Selector) // intermediate query (i.e. traversal path). sql *sql.Selector path func(context.Context) (*sql.Selector, error) @@ -347,6 +349,9 @@ func (_q *UserAllowedGroupQuery) sqlAll(ctx context.Context, hooks ...queryHook) 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) } @@ -432,6 +437,9 @@ func (_q *UserAllowedGroupQuery) loadGroup(ctx context.Context, query *GroupQuer func (_q *UserAllowedGroupQuery) sqlCount(ctx context.Context) (int, error) { _spec := _q.querySpec() + if len(_q.modifiers) > 0 { + _spec.Modifiers = _q.modifiers + } _spec.Unique = false _spec.Node.Columns = nil return sqlgraph.CountNodes(ctx, _q.driver, _spec) @@ -495,6 +503,9 @@ func (_q *UserAllowedGroupQuery) sqlQuery(ctx context.Context) *sql.Selector { if _q.ctx.Unique != nil && *_q.ctx.Unique { selector.Distinct() } + for _, m := range _q.modifiers { + m(selector) + } for _, p := range _q.predicates { p(selector) } @@ -512,6 +523,32 @@ func (_q *UserAllowedGroupQuery) sqlQuery(ctx context.Context) *sql.Selector { 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 *UserAllowedGroupQuery) ForUpdate(opts ...sql.LockOption) *UserAllowedGroupQuery { + 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 *UserAllowedGroupQuery) ForShare(opts ...sql.LockOption) *UserAllowedGroupQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForShare(opts...) + }) + return _q +} + // UserAllowedGroupGroupBy is the group-by builder for UserAllowedGroup entities. type UserAllowedGroupGroupBy struct { selector diff --git a/backend/ent/userattributedefinition_query.go b/backend/ent/userattributedefinition_query.go index 9022d306..0727b47c 100644 --- a/backend/ent/userattributedefinition_query.go +++ b/backend/ent/userattributedefinition_query.go @@ -9,6 +9,7 @@ import ( "math" "entgo.io/ent" + "entgo.io/ent/dialect" "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" @@ -25,6 +26,7 @@ type UserAttributeDefinitionQuery struct { inters []Interceptor predicates []predicate.UserAttributeDefinition withValues *UserAttributeValueQuery + modifiers []func(*sql.Selector) // intermediate query (i.e. traversal path). sql *sql.Selector path func(context.Context) (*sql.Selector, error) @@ -384,6 +386,9 @@ func (_q *UserAttributeDefinitionQuery) sqlAll(ctx context.Context, hooks ...que 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) } @@ -436,6 +441,9 @@ func (_q *UserAttributeDefinitionQuery) loadValues(ctx context.Context, query *U func (_q *UserAttributeDefinitionQuery) 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 @@ -498,6 +506,9 @@ func (_q *UserAttributeDefinitionQuery) sqlQuery(ctx context.Context) *sql.Selec if _q.ctx.Unique != nil && *_q.ctx.Unique { selector.Distinct() } + for _, m := range _q.modifiers { + m(selector) + } for _, p := range _q.predicates { p(selector) } @@ -515,6 +526,32 @@ func (_q *UserAttributeDefinitionQuery) sqlQuery(ctx context.Context) *sql.Selec 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 *UserAttributeDefinitionQuery) ForUpdate(opts ...sql.LockOption) *UserAttributeDefinitionQuery { + 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 *UserAttributeDefinitionQuery) ForShare(opts ...sql.LockOption) *UserAttributeDefinitionQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForShare(opts...) + }) + return _q +} + // UserAttributeDefinitionGroupBy is the group-by builder for UserAttributeDefinition entities. type UserAttributeDefinitionGroupBy struct { selector diff --git a/backend/ent/userattributevalue_query.go b/backend/ent/userattributevalue_query.go index babfc9a9..a7c6b74a 100644 --- a/backend/ent/userattributevalue_query.go +++ b/backend/ent/userattributevalue_query.go @@ -8,6 +8,7 @@ import ( "math" "entgo.io/ent" + "entgo.io/ent/dialect" "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" @@ -26,6 +27,7 @@ type UserAttributeValueQuery struct { predicates []predicate.UserAttributeValue withUser *UserQuery withDefinition *UserAttributeDefinitionQuery + modifiers []func(*sql.Selector) // intermediate query (i.e. traversal path). sql *sql.Selector path func(context.Context) (*sql.Selector, error) @@ -420,6 +422,9 @@ func (_q *UserAttributeValueQuery) sqlAll(ctx context.Context, hooks ...queryHoo 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) } @@ -505,6 +510,9 @@ func (_q *UserAttributeValueQuery) loadDefinition(ctx context.Context, query *Us func (_q *UserAttributeValueQuery) 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 @@ -573,6 +581,9 @@ func (_q *UserAttributeValueQuery) sqlQuery(ctx context.Context) *sql.Selector { if _q.ctx.Unique != nil && *_q.ctx.Unique { selector.Distinct() } + for _, m := range _q.modifiers { + m(selector) + } for _, p := range _q.predicates { p(selector) } @@ -590,6 +601,32 @@ func (_q *UserAttributeValueQuery) sqlQuery(ctx context.Context) *sql.Selector { 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 *UserAttributeValueQuery) ForUpdate(opts ...sql.LockOption) *UserAttributeValueQuery { + 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 *UserAttributeValueQuery) ForShare(opts ...sql.LockOption) *UserAttributeValueQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForShare(opts...) + }) + return _q +} + // UserAttributeValueGroupBy is the group-by builder for UserAttributeValue entities. type UserAttributeValueGroupBy struct { selector diff --git a/backend/ent/usersubscription_query.go b/backend/ent/usersubscription_query.go index 967fbddb..288b7b1d 100644 --- a/backend/ent/usersubscription_query.go +++ b/backend/ent/usersubscription_query.go @@ -9,6 +9,7 @@ import ( "math" "entgo.io/ent" + "entgo.io/ent/dialect" "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" @@ -30,6 +31,7 @@ type UserSubscriptionQuery struct { withGroup *GroupQuery withAssignedByUser *UserQuery withUsageLogs *UsageLogQuery + modifiers []func(*sql.Selector) // intermediate query (i.e. traversal path). sql *sql.Selector path func(context.Context) (*sql.Selector, error) @@ -494,6 +496,9 @@ func (_q *UserSubscriptionQuery) sqlAll(ctx context.Context, hooks ...queryHook) 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) } @@ -657,6 +662,9 @@ func (_q *UserSubscriptionQuery) loadUsageLogs(ctx context.Context, query *Usage func (_q *UserSubscriptionQuery) 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 @@ -728,6 +736,9 @@ func (_q *UserSubscriptionQuery) sqlQuery(ctx context.Context) *sql.Selector { if _q.ctx.Unique != nil && *_q.ctx.Unique { selector.Distinct() } + for _, m := range _q.modifiers { + m(selector) + } for _, p := range _q.predicates { p(selector) } @@ -745,6 +756,32 @@ func (_q *UserSubscriptionQuery) sqlQuery(ctx context.Context) *sql.Selector { 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 *UserSubscriptionQuery) ForUpdate(opts ...sql.LockOption) *UserSubscriptionQuery { + 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 *UserSubscriptionQuery) ForShare(opts ...sql.LockOption) *UserSubscriptionQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForShare(opts...) + }) + return _q +} + // UserSubscriptionGroupBy is the group-by builder for UserSubscription entities. type UserSubscriptionGroupBy struct { selector diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index c1e15290..2cc11967 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "fmt" "log" + "net/url" "os" "strings" "time" @@ -35,24 +36,25 @@ const ( ) type Config struct { - Server ServerConfig `mapstructure:"server"` - CORS CORSConfig `mapstructure:"cors"` - Security SecurityConfig `mapstructure:"security"` - Billing BillingConfig `mapstructure:"billing"` - Turnstile TurnstileConfig `mapstructure:"turnstile"` - Database DatabaseConfig `mapstructure:"database"` - Redis RedisConfig `mapstructure:"redis"` - JWT JWTConfig `mapstructure:"jwt"` - Default DefaultConfig `mapstructure:"default"` - RateLimit RateLimitConfig `mapstructure:"rate_limit"` - Pricing PricingConfig `mapstructure:"pricing"` - Gateway GatewayConfig `mapstructure:"gateway"` - Concurrency ConcurrencyConfig `mapstructure:"concurrency"` - TokenRefresh TokenRefreshConfig `mapstructure:"token_refresh"` - RunMode string `mapstructure:"run_mode" yaml:"run_mode"` - Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC" - Gemini GeminiConfig `mapstructure:"gemini"` - Update UpdateConfig `mapstructure:"update"` + Server ServerConfig `mapstructure:"server"` + CORS CORSConfig `mapstructure:"cors"` + Security SecurityConfig `mapstructure:"security"` + Billing BillingConfig `mapstructure:"billing"` + Turnstile TurnstileConfig `mapstructure:"turnstile"` + Database DatabaseConfig `mapstructure:"database"` + Redis RedisConfig `mapstructure:"redis"` + JWT JWTConfig `mapstructure:"jwt"` + LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"` + Default DefaultConfig `mapstructure:"default"` + RateLimit RateLimitConfig `mapstructure:"rate_limit"` + Pricing PricingConfig `mapstructure:"pricing"` + Gateway GatewayConfig `mapstructure:"gateway"` + Concurrency ConcurrencyConfig `mapstructure:"concurrency"` + TokenRefresh TokenRefreshConfig `mapstructure:"token_refresh"` + RunMode string `mapstructure:"run_mode" yaml:"run_mode"` + Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC" + Gemini GeminiConfig `mapstructure:"gemini"` + Update UpdateConfig `mapstructure:"update"` } // UpdateConfig 在线更新相关配置 @@ -322,6 +324,30 @@ type TurnstileConfig struct { Required bool `mapstructure:"required"` } +// LinuxDoConnectConfig 用于 LinuxDo Connect OAuth 登录(终端用户 SSO)。 +// +// 注意:这与上游账号的 OAuth(例如 OpenAI/Gemini 账号接入)不是一回事。 +// 这里是用于登录 Sub2API 本身的用户体系。 +type LinuxDoConnectConfig struct { + Enabled bool `mapstructure:"enabled"` + ClientID string `mapstructure:"client_id"` + ClientSecret string `mapstructure:"client_secret"` + AuthorizeURL string `mapstructure:"authorize_url"` + TokenURL string `mapstructure:"token_url"` + UserInfoURL string `mapstructure:"userinfo_url"` + Scopes string `mapstructure:"scopes"` + RedirectURL string `mapstructure:"redirect_url"` // 后端回调地址(需在提供方后台登记) + FrontendRedirectURL string `mapstructure:"frontend_redirect_url"` // 前端接收 token 的路由(默认:/auth/linuxdo/callback) + TokenAuthMethod string `mapstructure:"token_auth_method"` // client_secret_post / client_secret_basic / none + UsePKCE bool `mapstructure:"use_pkce"` + + // 可选:用于从 userinfo JSON 中提取字段的 gjson 路径。 + // 为空时,服务端会尝试一组常见字段名。 + UserInfoEmailPath string `mapstructure:"userinfo_email_path"` + UserInfoIDPath string `mapstructure:"userinfo_id_path"` + UserInfoUsernamePath string `mapstructure:"userinfo_username_path"` +} + type DefaultConfig struct { AdminEmail string `mapstructure:"admin_email"` AdminPassword string `mapstructure:"admin_password"` @@ -388,6 +414,18 @@ func Load() (*Config, error) { cfg.Server.Mode = "debug" } cfg.JWT.Secret = strings.TrimSpace(cfg.JWT.Secret) + cfg.LinuxDo.ClientID = strings.TrimSpace(cfg.LinuxDo.ClientID) + cfg.LinuxDo.ClientSecret = strings.TrimSpace(cfg.LinuxDo.ClientSecret) + cfg.LinuxDo.AuthorizeURL = strings.TrimSpace(cfg.LinuxDo.AuthorizeURL) + cfg.LinuxDo.TokenURL = strings.TrimSpace(cfg.LinuxDo.TokenURL) + cfg.LinuxDo.UserInfoURL = strings.TrimSpace(cfg.LinuxDo.UserInfoURL) + cfg.LinuxDo.Scopes = strings.TrimSpace(cfg.LinuxDo.Scopes) + cfg.LinuxDo.RedirectURL = strings.TrimSpace(cfg.LinuxDo.RedirectURL) + cfg.LinuxDo.FrontendRedirectURL = strings.TrimSpace(cfg.LinuxDo.FrontendRedirectURL) + cfg.LinuxDo.TokenAuthMethod = strings.ToLower(strings.TrimSpace(cfg.LinuxDo.TokenAuthMethod)) + cfg.LinuxDo.UserInfoEmailPath = strings.TrimSpace(cfg.LinuxDo.UserInfoEmailPath) + cfg.LinuxDo.UserInfoIDPath = strings.TrimSpace(cfg.LinuxDo.UserInfoIDPath) + cfg.LinuxDo.UserInfoUsernamePath = strings.TrimSpace(cfg.LinuxDo.UserInfoUsernamePath) cfg.CORS.AllowedOrigins = normalizeStringSlice(cfg.CORS.AllowedOrigins) cfg.Security.ResponseHeaders.AdditionalAllowed = normalizeStringSlice(cfg.Security.ResponseHeaders.AdditionalAllowed) cfg.Security.ResponseHeaders.ForceRemove = normalizeStringSlice(cfg.Security.ResponseHeaders.ForceRemove) @@ -426,6 +464,81 @@ func Load() (*Config, error) { return &cfg, nil } +// ValidateAbsoluteHTTPURL 校验一个绝对 http(s) URL(禁止 fragment)。 +func ValidateAbsoluteHTTPURL(raw string) error { + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("empty url") + } + u, err := url.Parse(raw) + if err != nil { + return err + } + if !u.IsAbs() { + return fmt.Errorf("must be absolute") + } + if !isHTTPScheme(u.Scheme) { + return fmt.Errorf("unsupported scheme: %s", u.Scheme) + } + if strings.TrimSpace(u.Host) == "" { + return fmt.Errorf("missing host") + } + if u.Fragment != "" { + return fmt.Errorf("must not include fragment") + } + return nil +} + +// ValidateFrontendRedirectURL 校验前端回调地址: +// - 允许同源相对路径(以 / 开头) +// - 或绝对 http(s) URL(禁止 fragment) +func ValidateFrontendRedirectURL(raw string) error { + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("empty url") + } + if strings.ContainsAny(raw, "\r\n") { + return fmt.Errorf("contains invalid characters") + } + if strings.HasPrefix(raw, "/") { + if strings.HasPrefix(raw, "//") { + return fmt.Errorf("must not start with //") + } + return nil + } + u, err := url.Parse(raw) + if err != nil { + return err + } + if !u.IsAbs() { + return fmt.Errorf("must be absolute http(s) url or relative path") + } + if !isHTTPScheme(u.Scheme) { + return fmt.Errorf("unsupported scheme: %s", u.Scheme) + } + if strings.TrimSpace(u.Host) == "" { + return fmt.Errorf("missing host") + } + if u.Fragment != "" { + return fmt.Errorf("must not include fragment") + } + return nil +} + +func isHTTPScheme(scheme string) bool { + return strings.EqualFold(scheme, "http") || strings.EqualFold(scheme, "https") +} + +func warnIfInsecureURL(field, raw string) { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return + } + if strings.EqualFold(u.Scheme, "http") { + log.Printf("Warning: %s uses http scheme; use https in production to avoid token leakage.", field) + } +} + func setDefaults() { viper.SetDefault("run_mode", RunModeStandard) @@ -475,6 +588,22 @@ func setDefaults() { // Turnstile viper.SetDefault("turnstile.required", false) + // LinuxDo Connect OAuth 登录(终端用户 SSO) + viper.SetDefault("linuxdo_connect.enabled", false) + viper.SetDefault("linuxdo_connect.client_id", "") + viper.SetDefault("linuxdo_connect.client_secret", "") + viper.SetDefault("linuxdo_connect.authorize_url", "https://connect.linux.do/oauth2/authorize") + viper.SetDefault("linuxdo_connect.token_url", "https://connect.linux.do/oauth2/token") + viper.SetDefault("linuxdo_connect.userinfo_url", "https://connect.linux.do/api/user") + viper.SetDefault("linuxdo_connect.scopes", "user") + viper.SetDefault("linuxdo_connect.redirect_url", "") + viper.SetDefault("linuxdo_connect.frontend_redirect_url", "/auth/linuxdo/callback") + viper.SetDefault("linuxdo_connect.token_auth_method", "client_secret_post") + viper.SetDefault("linuxdo_connect.use_pkce", false) + viper.SetDefault("linuxdo_connect.userinfo_email_path", "") + viper.SetDefault("linuxdo_connect.userinfo_id_path", "") + viper.SetDefault("linuxdo_connect.userinfo_username_path", "") + // Database viper.SetDefault("database.host", "localhost") viper.SetDefault("database.port", 5432) @@ -544,7 +673,7 @@ func setDefaults() { viper.SetDefault("gateway.concurrency_slot_ttl_minutes", 30) // 并发槽位过期时间(支持超长请求) viper.SetDefault("gateway.stream_data_interval_timeout", 180) viper.SetDefault("gateway.stream_keepalive_interval", 10) - viper.SetDefault("gateway.max_line_size", 10*1024*1024) + viper.SetDefault("gateway.max_line_size", 40*1024*1024) viper.SetDefault("gateway.scheduling.sticky_session_max_waiting", 3) viper.SetDefault("gateway.scheduling.sticky_session_wait_timeout", 45*time.Second) viper.SetDefault("gateway.scheduling.fallback_wait_timeout", 30*time.Second) @@ -586,6 +715,60 @@ func (c *Config) Validate() error { if c.Security.CSP.Enabled && strings.TrimSpace(c.Security.CSP.Policy) == "" { return fmt.Errorf("security.csp.policy is required when CSP is enabled") } + if c.LinuxDo.Enabled { + if strings.TrimSpace(c.LinuxDo.ClientID) == "" { + return fmt.Errorf("linuxdo_connect.client_id is required when linuxdo_connect.enabled=true") + } + if strings.TrimSpace(c.LinuxDo.AuthorizeURL) == "" { + return fmt.Errorf("linuxdo_connect.authorize_url is required when linuxdo_connect.enabled=true") + } + if strings.TrimSpace(c.LinuxDo.TokenURL) == "" { + return fmt.Errorf("linuxdo_connect.token_url is required when linuxdo_connect.enabled=true") + } + if strings.TrimSpace(c.LinuxDo.UserInfoURL) == "" { + return fmt.Errorf("linuxdo_connect.userinfo_url is required when linuxdo_connect.enabled=true") + } + if strings.TrimSpace(c.LinuxDo.RedirectURL) == "" { + return fmt.Errorf("linuxdo_connect.redirect_url is required when linuxdo_connect.enabled=true") + } + method := strings.ToLower(strings.TrimSpace(c.LinuxDo.TokenAuthMethod)) + switch method { + case "", "client_secret_post", "client_secret_basic", "none": + default: + return fmt.Errorf("linuxdo_connect.token_auth_method must be one of: client_secret_post/client_secret_basic/none") + } + if method == "none" && !c.LinuxDo.UsePKCE { + return fmt.Errorf("linuxdo_connect.use_pkce must be true when linuxdo_connect.token_auth_method=none") + } + if (method == "" || method == "client_secret_post" || method == "client_secret_basic") && strings.TrimSpace(c.LinuxDo.ClientSecret) == "" { + return fmt.Errorf("linuxdo_connect.client_secret is required when linuxdo_connect.enabled=true and token_auth_method is client_secret_post/client_secret_basic") + } + if strings.TrimSpace(c.LinuxDo.FrontendRedirectURL) == "" { + return fmt.Errorf("linuxdo_connect.frontend_redirect_url is required when linuxdo_connect.enabled=true") + } + + if err := ValidateAbsoluteHTTPURL(c.LinuxDo.AuthorizeURL); err != nil { + return fmt.Errorf("linuxdo_connect.authorize_url invalid: %w", err) + } + if err := ValidateAbsoluteHTTPURL(c.LinuxDo.TokenURL); err != nil { + return fmt.Errorf("linuxdo_connect.token_url invalid: %w", err) + } + if err := ValidateAbsoluteHTTPURL(c.LinuxDo.UserInfoURL); err != nil { + return fmt.Errorf("linuxdo_connect.userinfo_url invalid: %w", err) + } + if err := ValidateAbsoluteHTTPURL(c.LinuxDo.RedirectURL); err != nil { + return fmt.Errorf("linuxdo_connect.redirect_url invalid: %w", err) + } + if err := ValidateFrontendRedirectURL(c.LinuxDo.FrontendRedirectURL); err != nil { + return fmt.Errorf("linuxdo_connect.frontend_redirect_url invalid: %w", err) + } + + warnIfInsecureURL("linuxdo_connect.authorize_url", c.LinuxDo.AuthorizeURL) + warnIfInsecureURL("linuxdo_connect.token_url", c.LinuxDo.TokenURL) + warnIfInsecureURL("linuxdo_connect.userinfo_url", c.LinuxDo.UserInfoURL) + warnIfInsecureURL("linuxdo_connect.redirect_url", c.LinuxDo.RedirectURL) + warnIfInsecureURL("linuxdo_connect.frontend_redirect_url", c.LinuxDo.FrontendRedirectURL) + } if c.Billing.CircuitBreaker.Enabled { if c.Billing.CircuitBreaker.FailureThreshold <= 0 { return fmt.Errorf("billing.circuit_breaker.failure_threshold must be positive") diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index f28680c6..a39d41f9 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -1,6 +1,7 @@ package config import ( + "strings" "testing" "time" @@ -90,3 +91,53 @@ func TestLoadDefaultSecurityToggles(t *testing.T) { t.Fatalf("ResponseHeaders.Enabled = true, want false") } } + +func TestValidateLinuxDoFrontendRedirectURL(t *testing.T) { + viper.Reset() + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + cfg.LinuxDo.Enabled = true + cfg.LinuxDo.ClientID = "test-client" + cfg.LinuxDo.ClientSecret = "test-secret" + cfg.LinuxDo.RedirectURL = "https://example.com/api/v1/auth/oauth/linuxdo/callback" + cfg.LinuxDo.TokenAuthMethod = "client_secret_post" + cfg.LinuxDo.UsePKCE = false + + cfg.LinuxDo.FrontendRedirectURL = "javascript:alert(1)" + err = cfg.Validate() + if err == nil { + t.Fatalf("Validate() expected error for javascript scheme, got nil") + } + if !strings.Contains(err.Error(), "linuxdo_connect.frontend_redirect_url") { + t.Fatalf("Validate() expected frontend_redirect_url error, got: %v", err) + } +} + +func TestValidateLinuxDoPKCERequiredForPublicClient(t *testing.T) { + viper.Reset() + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + cfg.LinuxDo.Enabled = true + cfg.LinuxDo.ClientID = "test-client" + cfg.LinuxDo.ClientSecret = "" + cfg.LinuxDo.RedirectURL = "https://example.com/api/v1/auth/oauth/linuxdo/callback" + cfg.LinuxDo.FrontendRedirectURL = "/auth/linuxdo/callback" + cfg.LinuxDo.TokenAuthMethod = "none" + cfg.LinuxDo.UsePKCE = false + + err = cfg.Validate() + if err == nil { + t.Fatalf("Validate() expected error when token_auth_method=none and use_pkce=false, got nil") + } + if !strings.Contains(err.Error(), "linuxdo_connect.use_pkce") { + t.Fatalf("Validate() expected use_pkce error, got: %v", err) + } +} diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index da9f6990..8a7270e5 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -116,6 +116,7 @@ type BulkUpdateAccountsRequest struct { Concurrency *int `json:"concurrency"` Priority *int `json:"priority"` Status string `json:"status" binding:"omitempty,oneof=active inactive error"` + Schedulable *bool `json:"schedulable"` GroupIDs *[]int64 `json:"group_ids"` Credentials map[string]any `json:"credentials"` Extra map[string]any `json:"extra"` @@ -136,6 +137,11 @@ func (h *AccountHandler) List(c *gin.Context) { accountType := c.Query("type") status := c.Query("status") search := c.Query("search") + // 标准化和验证 search 参数 + search = strings.TrimSpace(search) + if len(search) > 100 { + search = search[:100] + } accounts, total, err := h.adminService.ListAccounts(c.Request.Context(), page, pageSize, platform, accountType, status, search) if err != nil { @@ -655,6 +661,7 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) { req.Concurrency != nil || req.Priority != nil || req.Status != "" || + req.Schedulable != nil || req.GroupIDs != nil || len(req.Credentials) > 0 || len(req.Extra) > 0 @@ -671,6 +678,7 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) { Concurrency: req.Concurrency, Priority: req.Priority, Status: req.Status, + Schedulable: req.Schedulable, GroupIDs: req.GroupIDs, Credentials: req.Credentials, Extra: req.Extra, diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index acb9462c..a8bae35e 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -2,6 +2,7 @@ package admin import ( "strconv" + "strings" "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/response" @@ -67,6 +68,12 @@ func (h *GroupHandler) List(c *gin.Context) { page, pageSize := response.ParsePagination(c) platform := c.Query("platform") status := c.Query("status") + search := c.Query("search") + // 标准化和验证 search 参数 + search = strings.TrimSpace(search) + if len(search) > 100 { + search = search[:100] + } isExclusiveStr := c.Query("is_exclusive") var isExclusive *bool @@ -75,7 +82,7 @@ func (h *GroupHandler) List(c *gin.Context) { isExclusive = &val } - groups, total, err := h.adminService.ListGroups(c.Request.Context(), page, pageSize, platform, status, isExclusive) + groups, total, err := h.adminService.ListGroups(c.Request.Context(), page, pageSize, platform, status, search, isExclusive) if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/handler/admin/promo_handler.go b/backend/internal/handler/admin/promo_handler.go new file mode 100644 index 00000000..3eafa380 --- /dev/null +++ b/backend/internal/handler/admin/promo_handler.go @@ -0,0 +1,209 @@ +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" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" +) + +// PromoHandler handles admin promo code management +type PromoHandler struct { + promoService *service.PromoService +} + +// NewPromoHandler creates a new admin promo handler +func NewPromoHandler(promoService *service.PromoService) *PromoHandler { + return &PromoHandler{ + promoService: promoService, + } +} + +// CreatePromoCodeRequest represents create promo code request +type CreatePromoCodeRequest struct { + Code string `json:"code"` // 可选,为空则自动生成 + BonusAmount float64 `json:"bonus_amount" binding:"required,min=0"` // 赠送余额 + MaxUses int `json:"max_uses" binding:"min=0"` // 最大使用次数,0=无限 + ExpiresAt *int64 `json:"expires_at"` // 过期时间戳(秒) + Notes string `json:"notes"` // 备注 +} + +// UpdatePromoCodeRequest represents update promo code request +type UpdatePromoCodeRequest struct { + Code *string `json:"code"` + BonusAmount *float64 `json:"bonus_amount" binding:"omitempty,min=0"` + MaxUses *int `json:"max_uses" binding:"omitempty,min=0"` + Status *string `json:"status" binding:"omitempty,oneof=active disabled"` + ExpiresAt *int64 `json:"expires_at"` + Notes *string `json:"notes"` +} + +// List handles listing all promo codes with pagination +// GET /api/v1/admin/promo-codes +func (h *PromoHandler) List(c *gin.Context) { + page, pageSize := response.ParsePagination(c) + status := c.Query("status") + search := strings.TrimSpace(c.Query("search")) + if len(search) > 100 { + search = search[:100] + } + + params := pagination.PaginationParams{ + Page: page, + PageSize: pageSize, + } + + codes, paginationResult, err := h.promoService.List(c.Request.Context(), params, status, search) + if err != nil { + response.ErrorFrom(c, err) + return + } + + out := make([]dto.PromoCode, 0, len(codes)) + for i := range codes { + out = append(out, *dto.PromoCodeFromService(&codes[i])) + } + response.Paginated(c, out, paginationResult.Total, page, pageSize) +} + +// GetByID handles getting a promo code by ID +// GET /api/v1/admin/promo-codes/:id +func (h *PromoHandler) GetByID(c *gin.Context) { + codeID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid promo code ID") + return + } + + code, err := h.promoService.GetByID(c.Request.Context(), codeID) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.PromoCodeFromService(code)) +} + +// Create handles creating a new promo code +// POST /api/v1/admin/promo-codes +func (h *PromoHandler) Create(c *gin.Context) { + var req CreatePromoCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + input := &service.CreatePromoCodeInput{ + Code: req.Code, + BonusAmount: req.BonusAmount, + MaxUses: req.MaxUses, + Notes: req.Notes, + } + + if req.ExpiresAt != nil { + t := time.Unix(*req.ExpiresAt, 0) + input.ExpiresAt = &t + } + + code, err := h.promoService.Create(c.Request.Context(), input) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.PromoCodeFromService(code)) +} + +// Update handles updating a promo code +// PUT /api/v1/admin/promo-codes/:id +func (h *PromoHandler) Update(c *gin.Context) { + codeID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid promo code ID") + return + } + + var req UpdatePromoCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + input := &service.UpdatePromoCodeInput{ + Code: req.Code, + BonusAmount: req.BonusAmount, + MaxUses: req.MaxUses, + Status: req.Status, + Notes: req.Notes, + } + + if req.ExpiresAt != nil { + if *req.ExpiresAt == 0 { + // 0 表示清除过期时间 + input.ExpiresAt = nil + } else { + t := time.Unix(*req.ExpiresAt, 0) + input.ExpiresAt = &t + } + } + + code, err := h.promoService.Update(c.Request.Context(), codeID, input) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.PromoCodeFromService(code)) +} + +// Delete handles deleting a promo code +// DELETE /api/v1/admin/promo-codes/:id +func (h *PromoHandler) Delete(c *gin.Context) { + codeID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid promo code ID") + return + } + + err = h.promoService.Delete(c.Request.Context(), codeID) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{"message": "Promo code deleted successfully"}) +} + +// GetUsages handles getting usage records for a promo code +// GET /api/v1/admin/promo-codes/:id/usages +func (h *PromoHandler) GetUsages(c *gin.Context) { + codeID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid promo code ID") + return + } + + page, pageSize := response.ParsePagination(c) + params := pagination.PaginationParams{ + Page: page, + PageSize: pageSize, + } + + usages, paginationResult, err := h.promoService.ListUsages(c.Request.Context(), codeID, params) + if err != nil { + response.ErrorFrom(c, err) + return + } + + out := make([]dto.PromoCodeUsage, 0, len(usages)) + for i := range usages { + out = append(out, *dto.PromoCodeUsageFromService(&usages[i])) + } + response.Paginated(c, out, paginationResult.Total, page, pageSize) +} diff --git a/backend/internal/handler/admin/proxy_handler.go b/backend/internal/handler/admin/proxy_handler.go index 4fabd8ec..437e9300 100644 --- a/backend/internal/handler/admin/proxy_handler.go +++ b/backend/internal/handler/admin/proxy_handler.go @@ -51,6 +51,11 @@ func (h *ProxyHandler) List(c *gin.Context) { protocol := c.Query("protocol") status := c.Query("status") search := c.Query("search") + // 标准化和验证 search 参数 + search = strings.TrimSpace(search) + if len(search) > 100 { + search = search[:100] + } proxies, total, err := h.adminService.ListProxiesWithAccountCount(c.Request.Context(), page, pageSize, protocol, status, search) if err != nil { diff --git a/backend/internal/handler/admin/redeem_handler.go b/backend/internal/handler/admin/redeem_handler.go index 45fae43a..5b3229b6 100644 --- a/backend/internal/handler/admin/redeem_handler.go +++ b/backend/internal/handler/admin/redeem_handler.go @@ -5,6 +5,7 @@ import ( "encoding/csv" "fmt" "strconv" + "strings" "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/response" @@ -41,6 +42,11 @@ func (h *RedeemHandler) List(c *gin.Context) { codeType := c.Query("type") status := c.Query("status") search := c.Query("search") + // 标准化和验证 search 参数 + search = strings.TrimSpace(search) + if len(search) > 100 { + search = search[:100] + } codes, total, err := h.adminService.ListRedeemCodes(c.Request.Context(), page, pageSize, codeType, status, search) if err != nil { diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 743c4268..d95a8980 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -2,8 +2,10 @@ package admin import ( "log" + "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/server/middleware" @@ -38,33 +40,37 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { } response.Success(c, dto.SystemSettings{ - RegistrationEnabled: settings.RegistrationEnabled, - EmailVerifyEnabled: settings.EmailVerifyEnabled, - SMTPHost: settings.SMTPHost, - SMTPPort: settings.SMTPPort, - SMTPUsername: settings.SMTPUsername, - SMTPPasswordConfigured: settings.SMTPPasswordConfigured, - SMTPFrom: settings.SMTPFrom, - SMTPFromName: settings.SMTPFromName, - SMTPUseTLS: settings.SMTPUseTLS, - TurnstileEnabled: settings.TurnstileEnabled, - TurnstileSiteKey: settings.TurnstileSiteKey, - TurnstileSecretKeyConfigured: settings.TurnstileSecretKeyConfigured, - SiteName: settings.SiteName, - SiteLogo: settings.SiteLogo, - SiteSubtitle: settings.SiteSubtitle, - APIBaseURL: settings.APIBaseURL, - ContactInfo: settings.ContactInfo, - DocURL: settings.DocURL, - DefaultConcurrency: settings.DefaultConcurrency, - DefaultBalance: settings.DefaultBalance, - EnableModelFallback: settings.EnableModelFallback, - FallbackModelAnthropic: settings.FallbackModelAnthropic, - FallbackModelOpenAI: settings.FallbackModelOpenAI, - FallbackModelGemini: settings.FallbackModelGemini, - FallbackModelAntigravity: settings.FallbackModelAntigravity, - EnableIdentityPatch: settings.EnableIdentityPatch, - IdentityPatchPrompt: settings.IdentityPatchPrompt, + RegistrationEnabled: settings.RegistrationEnabled, + EmailVerifyEnabled: settings.EmailVerifyEnabled, + SMTPHost: settings.SMTPHost, + SMTPPort: settings.SMTPPort, + SMTPUsername: settings.SMTPUsername, + SMTPPasswordConfigured: settings.SMTPPasswordConfigured, + SMTPFrom: settings.SMTPFrom, + SMTPFromName: settings.SMTPFromName, + SMTPUseTLS: settings.SMTPUseTLS, + TurnstileEnabled: settings.TurnstileEnabled, + TurnstileSiteKey: settings.TurnstileSiteKey, + TurnstileSecretKeyConfigured: settings.TurnstileSecretKeyConfigured, + LinuxDoConnectEnabled: settings.LinuxDoConnectEnabled, + LinuxDoConnectClientID: settings.LinuxDoConnectClientID, + LinuxDoConnectClientSecretConfigured: settings.LinuxDoConnectClientSecretConfigured, + LinuxDoConnectRedirectURL: settings.LinuxDoConnectRedirectURL, + SiteName: settings.SiteName, + SiteLogo: settings.SiteLogo, + SiteSubtitle: settings.SiteSubtitle, + APIBaseURL: settings.APIBaseURL, + ContactInfo: settings.ContactInfo, + DocURL: settings.DocURL, + DefaultConcurrency: settings.DefaultConcurrency, + DefaultBalance: settings.DefaultBalance, + EnableModelFallback: settings.EnableModelFallback, + FallbackModelAnthropic: settings.FallbackModelAnthropic, + FallbackModelOpenAI: settings.FallbackModelOpenAI, + FallbackModelGemini: settings.FallbackModelGemini, + FallbackModelAntigravity: settings.FallbackModelAntigravity, + EnableIdentityPatch: settings.EnableIdentityPatch, + IdentityPatchPrompt: settings.IdentityPatchPrompt, }) } @@ -88,6 +94,12 @@ type UpdateSettingsRequest struct { TurnstileSiteKey string `json:"turnstile_site_key"` TurnstileSecretKey string `json:"turnstile_secret_key"` + // LinuxDo Connect OAuth 登录(终端用户 SSO) + LinuxDoConnectEnabled bool `json:"linuxdo_connect_enabled"` + LinuxDoConnectClientID string `json:"linuxdo_connect_client_id"` + LinuxDoConnectClientSecret string `json:"linuxdo_connect_client_secret"` + LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"` + // OEM设置 SiteName string `json:"site_name"` SiteLogo string `json:"site_logo"` @@ -165,34 +177,67 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } } + // LinuxDo Connect 参数验证 + if req.LinuxDoConnectEnabled { + req.LinuxDoConnectClientID = strings.TrimSpace(req.LinuxDoConnectClientID) + req.LinuxDoConnectClientSecret = strings.TrimSpace(req.LinuxDoConnectClientSecret) + req.LinuxDoConnectRedirectURL = strings.TrimSpace(req.LinuxDoConnectRedirectURL) + + if req.LinuxDoConnectClientID == "" { + response.BadRequest(c, "LinuxDo Client ID is required when enabled") + return + } + if req.LinuxDoConnectRedirectURL == "" { + response.BadRequest(c, "LinuxDo Redirect URL is required when enabled") + return + } + if err := config.ValidateAbsoluteHTTPURL(req.LinuxDoConnectRedirectURL); err != nil { + response.BadRequest(c, "LinuxDo Redirect URL must be an absolute http(s) URL") + return + } + + // 如果未提供 client_secret,则保留现有值(如有)。 + if req.LinuxDoConnectClientSecret == "" { + if previousSettings.LinuxDoConnectClientSecret == "" { + response.BadRequest(c, "LinuxDo Client Secret is required when enabled") + return + } + req.LinuxDoConnectClientSecret = previousSettings.LinuxDoConnectClientSecret + } + } + settings := &service.SystemSettings{ - RegistrationEnabled: req.RegistrationEnabled, - EmailVerifyEnabled: req.EmailVerifyEnabled, - 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, - SiteName: req.SiteName, - SiteLogo: req.SiteLogo, - SiteSubtitle: req.SiteSubtitle, - APIBaseURL: req.APIBaseURL, - ContactInfo: req.ContactInfo, - DocURL: req.DocURL, - 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, + 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, + 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, } if err := h.settingService.UpdateSettings(c.Request.Context(), settings); err != nil { @@ -210,33 +255,37 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } response.Success(c, dto.SystemSettings{ - RegistrationEnabled: updatedSettings.RegistrationEnabled, - EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled, - SMTPHost: updatedSettings.SMTPHost, - SMTPPort: updatedSettings.SMTPPort, - SMTPUsername: updatedSettings.SMTPUsername, - SMTPPasswordConfigured: updatedSettings.SMTPPasswordConfigured, - SMTPFrom: updatedSettings.SMTPFrom, - SMTPFromName: updatedSettings.SMTPFromName, - SMTPUseTLS: updatedSettings.SMTPUseTLS, - TurnstileEnabled: updatedSettings.TurnstileEnabled, - TurnstileSiteKey: updatedSettings.TurnstileSiteKey, - TurnstileSecretKeyConfigured: updatedSettings.TurnstileSecretKeyConfigured, - SiteName: updatedSettings.SiteName, - SiteLogo: updatedSettings.SiteLogo, - SiteSubtitle: updatedSettings.SiteSubtitle, - APIBaseURL: updatedSettings.APIBaseURL, - ContactInfo: updatedSettings.ContactInfo, - DocURL: updatedSettings.DocURL, - DefaultConcurrency: updatedSettings.DefaultConcurrency, - DefaultBalance: updatedSettings.DefaultBalance, - EnableModelFallback: updatedSettings.EnableModelFallback, - FallbackModelAnthropic: updatedSettings.FallbackModelAnthropic, - FallbackModelOpenAI: updatedSettings.FallbackModelOpenAI, - FallbackModelGemini: updatedSettings.FallbackModelGemini, - FallbackModelAntigravity: updatedSettings.FallbackModelAntigravity, - EnableIdentityPatch: updatedSettings.EnableIdentityPatch, - IdentityPatchPrompt: updatedSettings.IdentityPatchPrompt, + RegistrationEnabled: updatedSettings.RegistrationEnabled, + EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled, + SMTPHost: updatedSettings.SMTPHost, + SMTPPort: updatedSettings.SMTPPort, + SMTPUsername: updatedSettings.SMTPUsername, + SMTPPasswordConfigured: updatedSettings.SMTPPasswordConfigured, + SMTPFrom: updatedSettings.SMTPFrom, + SMTPFromName: updatedSettings.SMTPFromName, + SMTPUseTLS: updatedSettings.SMTPUseTLS, + TurnstileEnabled: updatedSettings.TurnstileEnabled, + TurnstileSiteKey: updatedSettings.TurnstileSiteKey, + TurnstileSecretKeyConfigured: updatedSettings.TurnstileSecretKeyConfigured, + LinuxDoConnectEnabled: updatedSettings.LinuxDoConnectEnabled, + LinuxDoConnectClientID: updatedSettings.LinuxDoConnectClientID, + LinuxDoConnectClientSecretConfigured: updatedSettings.LinuxDoConnectClientSecretConfigured, + LinuxDoConnectRedirectURL: updatedSettings.LinuxDoConnectRedirectURL, + SiteName: updatedSettings.SiteName, + SiteLogo: updatedSettings.SiteLogo, + SiteSubtitle: updatedSettings.SiteSubtitle, + APIBaseURL: updatedSettings.APIBaseURL, + ContactInfo: updatedSettings.ContactInfo, + DocURL: updatedSettings.DocURL, + DefaultConcurrency: updatedSettings.DefaultConcurrency, + DefaultBalance: updatedSettings.DefaultBalance, + EnableModelFallback: updatedSettings.EnableModelFallback, + FallbackModelAnthropic: updatedSettings.FallbackModelAnthropic, + FallbackModelOpenAI: updatedSettings.FallbackModelOpenAI, + FallbackModelGemini: updatedSettings.FallbackModelGemini, + FallbackModelAntigravity: updatedSettings.FallbackModelAntigravity, + EnableIdentityPatch: updatedSettings.EnableIdentityPatch, + IdentityPatchPrompt: updatedSettings.IdentityPatchPrompt, }) } @@ -298,6 +347,18 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if req.TurnstileSecretKey != "" { changed = append(changed, "turnstile_secret_key") } + if before.LinuxDoConnectEnabled != after.LinuxDoConnectEnabled { + changed = append(changed, "linuxdo_connect_enabled") + } + if before.LinuxDoConnectClientID != after.LinuxDoConnectClientID { + changed = append(changed, "linuxdo_connect_client_id") + } + if req.LinuxDoConnectClientSecret != "" { + changed = append(changed, "linuxdo_connect_client_secret") + } + if before.LinuxDoConnectRedirectURL != after.LinuxDoConnectRedirectURL { + changed = append(changed, "linuxdo_connect_redirect_url") + } if before.SiteName != after.SiteName { changed = append(changed, "site_name") } @@ -337,6 +398,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.FallbackModelAntigravity != after.FallbackModelAntigravity { changed = append(changed, "fallback_model_antigravity") } + if before.EnableIdentityPatch != after.EnableIdentityPatch { + changed = append(changed, "enable_identity_patch") + } + if before.IdentityPatchPrompt != after.IdentityPatchPrompt { + changed = append(changed, "identity_patch_prompt") + } return changed } diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index f8cd1d5a..38cc8acd 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -2,6 +2,7 @@ package admin import ( "strconv" + "strings" "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/response" @@ -63,10 +64,17 @@ type UpdateBalanceRequest struct { func (h *UserHandler) List(c *gin.Context) { page, pageSize := response.ParsePagination(c) + search := c.Query("search") + // 标准化和验证 search 参数 + search = strings.TrimSpace(search) + if len(search) > 100 { + search = search[:100] + } + filters := service.UserListFilters{ Status: c.Query("status"), Role: c.Query("role"), - Search: c.Query("search"), + Search: search, Attributes: parseAttributeFilters(c), } diff --git a/backend/internal/handler/api_key_handler.go b/backend/internal/handler/api_key_handler.go index 09772f22..52dc6911 100644 --- a/backend/internal/handler/api_key_handler.go +++ b/backend/internal/handler/api_key_handler.go @@ -27,16 +27,20 @@ func NewAPIKeyHandler(apiKeyService *service.APIKeyService) *APIKeyHandler { // CreateAPIKeyRequest represents the create API key request payload type CreateAPIKeyRequest struct { - Name string `json:"name" binding:"required"` - GroupID *int64 `json:"group_id"` // nullable - CustomKey *string `json:"custom_key"` // 可选的自定义key + Name string `json:"name" binding:"required"` + GroupID *int64 `json:"group_id"` // nullable + CustomKey *string `json:"custom_key"` // 可选的自定义key + IPWhitelist []string `json:"ip_whitelist"` // IP 白名单 + IPBlacklist []string `json:"ip_blacklist"` // IP 黑名单 } // UpdateAPIKeyRequest represents the update API key request payload type UpdateAPIKeyRequest struct { - Name string `json:"name"` - GroupID *int64 `json:"group_id"` - Status string `json:"status" binding:"omitempty,oneof=active inactive"` + Name string `json:"name"` + GroupID *int64 `json:"group_id"` + Status string `json:"status" binding:"omitempty,oneof=active inactive"` + IPWhitelist []string `json:"ip_whitelist"` // IP 白名单 + IPBlacklist []string `json:"ip_blacklist"` // IP 黑名单 } // List handles listing user's API keys with pagination @@ -110,9 +114,11 @@ func (h *APIKeyHandler) Create(c *gin.Context) { } svcReq := service.CreateAPIKeyRequest{ - Name: req.Name, - GroupID: req.GroupID, - CustomKey: req.CustomKey, + Name: req.Name, + GroupID: req.GroupID, + CustomKey: req.CustomKey, + IPWhitelist: req.IPWhitelist, + IPBlacklist: req.IPBlacklist, } key, err := h.apiKeyService.Create(c.Request.Context(), subject.UserID, svcReq) if err != nil { @@ -144,7 +150,10 @@ func (h *APIKeyHandler) Update(c *gin.Context) { return } - svcReq := service.UpdateAPIKeyRequest{} + svcReq := service.UpdateAPIKeyRequest{ + IPWhitelist: req.IPWhitelist, + IPBlacklist: req.IPBlacklist, + } if req.Name != "" { svcReq.Name = &req.Name } diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 8466f131..c5cce937 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -12,17 +12,21 @@ import ( // AuthHandler handles authentication-related requests type AuthHandler struct { - cfg *config.Config - authService *service.AuthService - userService *service.UserService + cfg *config.Config + authService *service.AuthService + userService *service.UserService + settingSvc *service.SettingService + promoService *service.PromoService } // NewAuthHandler creates a new AuthHandler -func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userService *service.UserService) *AuthHandler { +func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userService *service.UserService, settingService *service.SettingService, promoService *service.PromoService) *AuthHandler { return &AuthHandler{ - cfg: cfg, - authService: authService, - userService: userService, + cfg: cfg, + authService: authService, + userService: userService, + settingSvc: settingService, + promoService: promoService, } } @@ -32,6 +36,7 @@ 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"` // 注册优惠码 } // SendVerifyCodeRequest 发送验证码请求 @@ -77,7 +82,7 @@ func (h *AuthHandler) Register(c *gin.Context) { } } - token, user, err := h.authService.RegisterWithVerification(c.Request.Context(), req.Email, req.Password, req.VerifyCode) + token, user, err := h.authService.RegisterWithVerification(c.Request.Context(), req.Email, req.Password, req.VerifyCode, req.PromoCode) if err != nil { response.ErrorFrom(c, err) return @@ -172,3 +177,63 @@ func (h *AuthHandler) GetCurrentUser(c *gin.Context) { response.Success(c, UserResponse{User: dto.UserFromService(user), RunMode: runMode}) } + +// ValidatePromoCodeRequest 验证优惠码请求 +type ValidatePromoCodeRequest struct { + Code string `json:"code" binding:"required"` +} + +// ValidatePromoCodeResponse 验证优惠码响应 +type ValidatePromoCodeResponse struct { + Valid bool `json:"valid"` + BonusAmount float64 `json:"bonus_amount,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + Message string `json:"message,omitempty"` +} + +// ValidatePromoCode 验证优惠码(公开接口,注册前调用) +// POST /api/v1/auth/validate-promo-code +func (h *AuthHandler) ValidatePromoCode(c *gin.Context) { + var req ValidatePromoCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + promoCode, err := h.promoService.ValidatePromoCode(c.Request.Context(), req.Code) + if err != nil { + // 根据错误类型返回对应的错误码 + errorCode := "PROMO_CODE_INVALID" + switch err { + case service.ErrPromoCodeNotFound: + errorCode = "PROMO_CODE_NOT_FOUND" + case service.ErrPromoCodeExpired: + errorCode = "PROMO_CODE_EXPIRED" + case service.ErrPromoCodeDisabled: + errorCode = "PROMO_CODE_DISABLED" + case service.ErrPromoCodeMaxUsed: + errorCode = "PROMO_CODE_MAX_USED" + case service.ErrPromoCodeAlreadyUsed: + errorCode = "PROMO_CODE_ALREADY_USED" + } + + response.Success(c, ValidatePromoCodeResponse{ + Valid: false, + ErrorCode: errorCode, + }) + return + } + + if promoCode == nil { + response.Success(c, ValidatePromoCodeResponse{ + Valid: false, + ErrorCode: "PROMO_CODE_INVALID", + }) + return + } + + response.Success(c, ValidatePromoCodeResponse{ + Valid: true, + BonusAmount: promoCode.BonusAmount, + }) +} diff --git a/backend/internal/handler/auth_linuxdo_oauth.go b/backend/internal/handler/auth_linuxdo_oauth.go new file mode 100644 index 00000000..a16c4cc7 --- /dev/null +++ b/backend/internal/handler/auth_linuxdo_oauth.go @@ -0,0 +1,679 @@ +package handler + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "log" + "net/http" + "net/url" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/Wei-Shaw/sub2api/internal/config" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/oauth" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" + "github.com/imroc/req/v3" + "github.com/tidwall/gjson" +) + +const ( + linuxDoOAuthCookiePath = "/api/v1/auth/oauth/linuxdo" + linuxDoOAuthStateCookieName = "linuxdo_oauth_state" + linuxDoOAuthVerifierCookie = "linuxdo_oauth_verifier" + linuxDoOAuthRedirectCookie = "linuxdo_oauth_redirect" + linuxDoOAuthCookieMaxAgeSec = 10 * 60 // 10 minutes + linuxDoOAuthDefaultRedirectTo = "/dashboard" + linuxDoOAuthDefaultFrontendCB = "/auth/linuxdo/callback" + + linuxDoOAuthMaxRedirectLen = 2048 + linuxDoOAuthMaxFragmentValueLen = 512 + linuxDoOAuthMaxSubjectLen = 64 - len("linuxdo-") +) + +type linuxDoTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token,omitempty"` + Scope string `json:"scope,omitempty"` +} + +type linuxDoTokenExchangeError struct { + StatusCode int + ProviderError string + ProviderDescription string + Body string +} + +func (e *linuxDoTokenExchangeError) Error() string { + if e == nil { + return "" + } + parts := []string{fmt.Sprintf("token exchange status=%d", e.StatusCode)} + if strings.TrimSpace(e.ProviderError) != "" { + parts = append(parts, "error="+strings.TrimSpace(e.ProviderError)) + } + if strings.TrimSpace(e.ProviderDescription) != "" { + parts = append(parts, "error_description="+strings.TrimSpace(e.ProviderDescription)) + } + return strings.Join(parts, " ") +} + +// LinuxDoOAuthStart 启动 LinuxDo Connect OAuth 登录流程。 +// GET /api/v1/auth/oauth/linuxdo/start?redirect=/dashboard +func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) { + cfg, err := h.getLinuxDoOAuthConfig(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + state, err := oauth.GenerateState() + if err != nil { + response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err)) + return + } + + redirectTo := sanitizeFrontendRedirectPath(c.Query("redirect")) + if redirectTo == "" { + redirectTo = linuxDoOAuthDefaultRedirectTo + } + + secureCookie := isRequestHTTPS(c) + setCookie(c, linuxDoOAuthStateCookieName, encodeCookieValue(state), linuxDoOAuthCookieMaxAgeSec, secureCookie) + setCookie(c, linuxDoOAuthRedirectCookie, encodeCookieValue(redirectTo), linuxDoOAuthCookieMaxAgeSec, secureCookie) + + codeChallenge := "" + if cfg.UsePKCE { + verifier, err := oauth.GenerateCodeVerifier() + if err != nil { + response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_PKCE_GEN_FAILED", "failed to generate pkce verifier").WithCause(err)) + return + } + codeChallenge = oauth.GenerateCodeChallenge(verifier) + setCookie(c, linuxDoOAuthVerifierCookie, encodeCookieValue(verifier), linuxDoOAuthCookieMaxAgeSec, secureCookie) + } + + redirectURI := strings.TrimSpace(cfg.RedirectURL) + if redirectURI == "" { + response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured")) + return + } + + authURL, err := buildLinuxDoAuthorizeURL(cfg, state, codeChallenge, redirectURI) + if err != nil { + response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err)) + return + } + + c.Redirect(http.StatusFound, authURL) +} + +// LinuxDoOAuthCallback 处理 OAuth 回调:创建/登录用户,然后重定向到前端。 +// GET /api/v1/auth/oauth/linuxdo/callback?code=...&state=... +func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) { + cfg, cfgErr := h.getLinuxDoOAuthConfig(c.Request.Context()) + if cfgErr != nil { + response.ErrorFrom(c, cfgErr) + return + } + + frontendCallback := strings.TrimSpace(cfg.FrontendRedirectURL) + if frontendCallback == "" { + frontendCallback = linuxDoOAuthDefaultFrontendCB + } + + if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" { + redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description")) + return + } + + code := strings.TrimSpace(c.Query("code")) + state := strings.TrimSpace(c.Query("state")) + if code == "" || state == "" { + redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "") + return + } + + secureCookie := isRequestHTTPS(c) + defer func() { + clearCookie(c, linuxDoOAuthStateCookieName, secureCookie) + clearCookie(c, linuxDoOAuthVerifierCookie, secureCookie) + clearCookie(c, linuxDoOAuthRedirectCookie, secureCookie) + }() + + expectedState, err := readCookieDecoded(c, linuxDoOAuthStateCookieName) + if err != nil || expectedState == "" || state != expectedState { + redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth state", "") + return + } + + redirectTo, _ := readCookieDecoded(c, linuxDoOAuthRedirectCookie) + redirectTo = sanitizeFrontendRedirectPath(redirectTo) + if redirectTo == "" { + redirectTo = linuxDoOAuthDefaultRedirectTo + } + + codeVerifier := "" + if cfg.UsePKCE { + codeVerifier, _ = readCookieDecoded(c, linuxDoOAuthVerifierCookie) + if codeVerifier == "" { + redirectOAuthError(c, frontendCallback, "missing_verifier", "missing pkce verifier", "") + return + } + } + + redirectURI := strings.TrimSpace(cfg.RedirectURL) + if redirectURI == "" { + redirectOAuthError(c, frontendCallback, "config_error", "oauth redirect url not configured", "") + return + } + + tokenResp, err := linuxDoExchangeCode(c.Request.Context(), cfg, code, redirectURI, codeVerifier) + if err != nil { + description := "" + var exchangeErr *linuxDoTokenExchangeError + if errors.As(err, &exchangeErr) && exchangeErr != nil { + log.Printf( + "[LinuxDo OAuth] token exchange failed: status=%d provider_error=%q provider_description=%q body=%s", + exchangeErr.StatusCode, + exchangeErr.ProviderError, + exchangeErr.ProviderDescription, + truncateLogValue(exchangeErr.Body, 2048), + ) + description = exchangeErr.Error() + } else { + log.Printf("[LinuxDo OAuth] token exchange failed: %v", err) + description = err.Error() + } + redirectOAuthError(c, frontendCallback, "token_exchange_failed", "failed to exchange oauth code", singleLine(description)) + return + } + + email, username, subject, err := linuxDoFetchUserInfo(c.Request.Context(), cfg, tokenResp) + if err != nil { + log.Printf("[LinuxDo OAuth] userinfo fetch failed: %v", err) + redirectOAuthError(c, frontendCallback, "userinfo_failed", "failed to fetch user info", "") + return + } + + // 安全考虑:不要把第三方返回的 email 直接映射到本地账号(可能与本地邮箱用户冲突导致账号被接管)。 + // 统一使用基于 subject 的稳定合成邮箱来做账号绑定。 + if subject != "" { + email = linuxDoSyntheticEmail(subject) + } + + jwtToken, _, err := h.authService.LoginOrRegisterOAuth(c.Request.Context(), email, username) + if err != nil { + // 避免把内部细节泄露给客户端;给前端保留结构化原因与提示信息即可。 + redirectOAuthError(c, frontendCallback, "login_failed", infraerrors.Reason(err), infraerrors.Message(err)) + return + } + + fragment := url.Values{} + fragment.Set("access_token", jwtToken) + fragment.Set("token_type", "Bearer") + fragment.Set("redirect", redirectTo) + redirectWithFragment(c, frontendCallback, fragment) +} + +func (h *AuthHandler) getLinuxDoOAuthConfig(ctx context.Context) (config.LinuxDoConnectConfig, error) { + if h != nil && h.settingSvc != nil { + return h.settingSvc.GetLinuxDoConnectOAuthConfig(ctx) + } + if h == nil || h.cfg == nil { + return config.LinuxDoConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") + } + if !h.cfg.LinuxDo.Enabled { + return config.LinuxDoConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled") + } + return h.cfg.LinuxDo, nil +} + +func linuxDoExchangeCode( + ctx context.Context, + cfg config.LinuxDoConnectConfig, + code string, + redirectURI string, + codeVerifier string, +) (*linuxDoTokenResponse, error) { + client := req.C().SetTimeout(30 * time.Second) + + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("client_id", cfg.ClientID) + form.Set("code", code) + form.Set("redirect_uri", redirectURI) + if cfg.UsePKCE { + form.Set("code_verifier", codeVerifier) + } + + r := client.R(). + SetContext(ctx). + SetHeader("Accept", "application/json") + + switch strings.ToLower(strings.TrimSpace(cfg.TokenAuthMethod)) { + case "", "client_secret_post": + form.Set("client_secret", cfg.ClientSecret) + case "client_secret_basic": + r.SetBasicAuth(cfg.ClientID, cfg.ClientSecret) + case "none": + default: + return nil, fmt.Errorf("unsupported token_auth_method: %s", cfg.TokenAuthMethod) + } + + resp, err := r.SetFormDataFromValues(form).Post(cfg.TokenURL) + if err != nil { + return nil, fmt.Errorf("request token: %w", err) + } + body := strings.TrimSpace(resp.String()) + if !resp.IsSuccessState() { + providerErr, providerDesc := parseOAuthProviderError(body) + return nil, &linuxDoTokenExchangeError{ + StatusCode: resp.StatusCode, + ProviderError: providerErr, + ProviderDescription: providerDesc, + Body: body, + } + } + + tokenResp, ok := parseLinuxDoTokenResponse(body) + if !ok || strings.TrimSpace(tokenResp.AccessToken) == "" { + return nil, &linuxDoTokenExchangeError{ + StatusCode: resp.StatusCode, + Body: body, + } + } + if strings.TrimSpace(tokenResp.TokenType) == "" { + tokenResp.TokenType = "Bearer" + } + return tokenResp, nil +} + +func linuxDoFetchUserInfo( + ctx context.Context, + cfg config.LinuxDoConnectConfig, + token *linuxDoTokenResponse, +) (email string, username string, subject string, err error) { + client := req.C().SetTimeout(30 * time.Second) + authorization, err := buildBearerAuthorization(token.TokenType, token.AccessToken) + if err != nil { + return "", "", "", fmt.Errorf("invalid token for userinfo request: %w", err) + } + + resp, err := client.R(). + SetContext(ctx). + SetHeader("Accept", "application/json"). + SetHeader("Authorization", authorization). + Get(cfg.UserInfoURL) + if err != nil { + return "", "", "", fmt.Errorf("request userinfo: %w", err) + } + if !resp.IsSuccessState() { + return "", "", "", fmt.Errorf("userinfo status=%d", resp.StatusCode) + } + + return linuxDoParseUserInfo(resp.String(), cfg) +} + +func linuxDoParseUserInfo(body string, cfg config.LinuxDoConnectConfig) (email string, username string, subject string, err error) { + email = firstNonEmpty( + getGJSON(body, cfg.UserInfoEmailPath), + getGJSON(body, "email"), + getGJSON(body, "user.email"), + getGJSON(body, "data.email"), + getGJSON(body, "attributes.email"), + ) + username = firstNonEmpty( + getGJSON(body, cfg.UserInfoUsernamePath), + getGJSON(body, "username"), + getGJSON(body, "preferred_username"), + getGJSON(body, "name"), + getGJSON(body, "user.username"), + getGJSON(body, "user.name"), + ) + subject = firstNonEmpty( + getGJSON(body, cfg.UserInfoIDPath), + getGJSON(body, "sub"), + getGJSON(body, "id"), + getGJSON(body, "user_id"), + getGJSON(body, "uid"), + getGJSON(body, "user.id"), + ) + + subject = strings.TrimSpace(subject) + if subject == "" { + return "", "", "", errors.New("userinfo missing id field") + } + if !isSafeLinuxDoSubject(subject) { + return "", "", "", errors.New("userinfo returned invalid id field") + } + + email = strings.TrimSpace(email) + if email == "" { + // LinuxDo Connect 的 userinfo 可能不提供 email。为兼容现有用户模型(email 必填且唯一),使用稳定的合成邮箱。 + email = linuxDoSyntheticEmail(subject) + } + + username = strings.TrimSpace(username) + if username == "" { + username = "linuxdo_" + subject + } + + return email, username, subject, nil +} + +func buildLinuxDoAuthorizeURL(cfg config.LinuxDoConnectConfig, state string, codeChallenge string, redirectURI string) (string, error) { + u, err := url.Parse(cfg.AuthorizeURL) + if err != nil { + return "", fmt.Errorf("parse authorize_url: %w", err) + } + + q := u.Query() + q.Set("response_type", "code") + q.Set("client_id", cfg.ClientID) + q.Set("redirect_uri", redirectURI) + if strings.TrimSpace(cfg.Scopes) != "" { + q.Set("scope", cfg.Scopes) + } + q.Set("state", state) + if cfg.UsePKCE { + q.Set("code_challenge", codeChallenge) + q.Set("code_challenge_method", "S256") + } + + u.RawQuery = q.Encode() + return u.String(), nil +} + +func redirectOAuthError(c *gin.Context, frontendCallback string, code string, message string, description string) { + fragment := url.Values{} + fragment.Set("error", truncateFragmentValue(code)) + if strings.TrimSpace(message) != "" { + fragment.Set("error_message", truncateFragmentValue(message)) + } + if strings.TrimSpace(description) != "" { + fragment.Set("error_description", truncateFragmentValue(description)) + } + redirectWithFragment(c, frontendCallback, fragment) +} + +func redirectWithFragment(c *gin.Context, frontendCallback string, fragment url.Values) { + u, err := url.Parse(frontendCallback) + if err != nil { + // 兜底:尽力跳转到默认页面,避免卡死在回调页。 + c.Redirect(http.StatusFound, linuxDoOAuthDefaultRedirectTo) + return + } + if u.Scheme != "" && !strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https") { + c.Redirect(http.StatusFound, linuxDoOAuthDefaultRedirectTo) + return + } + u.Fragment = fragment.Encode() + c.Header("Cache-Control", "no-store") + c.Header("Pragma", "no-cache") + c.Redirect(http.StatusFound, u.String()) +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + v = strings.TrimSpace(v) + if v != "" { + return v + } + } + return "" +} + +func parseOAuthProviderError(body string) (providerErr string, providerDesc string) { + body = strings.TrimSpace(body) + if body == "" { + return "", "" + } + + providerErr = firstNonEmpty( + getGJSON(body, "error"), + getGJSON(body, "code"), + getGJSON(body, "error.code"), + ) + providerDesc = firstNonEmpty( + getGJSON(body, "error_description"), + getGJSON(body, "error.message"), + getGJSON(body, "message"), + getGJSON(body, "detail"), + ) + + if providerErr != "" || providerDesc != "" { + return providerErr, providerDesc + } + + values, err := url.ParseQuery(body) + if err != nil { + return "", "" + } + providerErr = firstNonEmpty(values.Get("error"), values.Get("code")) + providerDesc = firstNonEmpty(values.Get("error_description"), values.Get("error_message"), values.Get("message")) + return providerErr, providerDesc +} + +func parseLinuxDoTokenResponse(body string) (*linuxDoTokenResponse, bool) { + body = strings.TrimSpace(body) + if body == "" { + return nil, false + } + + accessToken := strings.TrimSpace(getGJSON(body, "access_token")) + if accessToken != "" { + tokenType := strings.TrimSpace(getGJSON(body, "token_type")) + refreshToken := strings.TrimSpace(getGJSON(body, "refresh_token")) + scope := strings.TrimSpace(getGJSON(body, "scope")) + expiresIn := gjson.Get(body, "expires_in").Int() + return &linuxDoTokenResponse{ + AccessToken: accessToken, + TokenType: tokenType, + ExpiresIn: expiresIn, + RefreshToken: refreshToken, + Scope: scope, + }, true + } + + values, err := url.ParseQuery(body) + if err != nil { + return nil, false + } + accessToken = strings.TrimSpace(values.Get("access_token")) + if accessToken == "" { + return nil, false + } + expiresIn := int64(0) + if raw := strings.TrimSpace(values.Get("expires_in")); raw != "" { + if v, err := strconv.ParseInt(raw, 10, 64); err == nil { + expiresIn = v + } + } + return &linuxDoTokenResponse{ + AccessToken: accessToken, + TokenType: strings.TrimSpace(values.Get("token_type")), + ExpiresIn: expiresIn, + RefreshToken: strings.TrimSpace(values.Get("refresh_token")), + Scope: strings.TrimSpace(values.Get("scope")), + }, true +} + +func getGJSON(body string, path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + res := gjson.Get(body, path) + if !res.Exists() { + return "" + } + return res.String() +} + +func truncateLogValue(value string, maxLen int) string { + value = strings.TrimSpace(value) + if value == "" || maxLen <= 0 { + return "" + } + if len(value) <= maxLen { + return value + } + value = value[:maxLen] + for !utf8.ValidString(value) { + value = value[:len(value)-1] + } + return value +} + +func singleLine(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + return strings.Join(strings.Fields(value), " ") +} + +func sanitizeFrontendRedirectPath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + if len(path) > linuxDoOAuthMaxRedirectLen { + return "" + } + // 只允许同源相对路径(避免开放重定向)。 + if !strings.HasPrefix(path, "/") { + return "" + } + if strings.HasPrefix(path, "//") { + return "" + } + if strings.Contains(path, "://") { + return "" + } + if strings.ContainsAny(path, "\r\n") { + return "" + } + return path +} + +func isRequestHTTPS(c *gin.Context) bool { + if c.Request.TLS != nil { + return true + } + proto := strings.ToLower(strings.TrimSpace(c.GetHeader("X-Forwarded-Proto"))) + return proto == "https" +} + +func encodeCookieValue(value string) string { + return base64.RawURLEncoding.EncodeToString([]byte(value)) +} + +func decodeCookieValue(value string) (string, error) { + raw, err := base64.RawURLEncoding.DecodeString(value) + if err != nil { + return "", err + } + return string(raw), nil +} + +func readCookieDecoded(c *gin.Context, name string) (string, error) { + ck, err := c.Request.Cookie(name) + if err != nil { + return "", err + } + return decodeCookieValue(ck.Value) +} + +func setCookie(c *gin.Context, name string, value string, maxAgeSec int, secure bool) { + http.SetCookie(c.Writer, &http.Cookie{ + Name: name, + Value: value, + Path: linuxDoOAuthCookiePath, + MaxAge: maxAgeSec, + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + }) +} + +func clearCookie(c *gin.Context, name string, secure bool) { + http.SetCookie(c.Writer, &http.Cookie{ + Name: name, + Value: "", + Path: linuxDoOAuthCookiePath, + MaxAge: -1, + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + }) +} + +func truncateFragmentValue(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + if len(value) > linuxDoOAuthMaxFragmentValueLen { + value = value[:linuxDoOAuthMaxFragmentValueLen] + for !utf8.ValidString(value) { + value = value[:len(value)-1] + } + } + return value +} + +func buildBearerAuthorization(tokenType, accessToken string) (string, error) { + tokenType = strings.TrimSpace(tokenType) + if tokenType == "" { + tokenType = "Bearer" + } + if !strings.EqualFold(tokenType, "Bearer") { + return "", fmt.Errorf("unsupported token_type: %s", tokenType) + } + + accessToken = strings.TrimSpace(accessToken) + if accessToken == "" { + return "", errors.New("missing access_token") + } + if strings.ContainsAny(accessToken, " \t\r\n") { + return "", errors.New("access_token contains whitespace") + } + return "Bearer " + accessToken, nil +} + +func isSafeLinuxDoSubject(subject string) bool { + subject = strings.TrimSpace(subject) + if subject == "" || len(subject) > linuxDoOAuthMaxSubjectLen { + return false + } + for _, r := range subject { + switch { + case r >= '0' && r <= '9': + case r >= 'a' && r <= 'z': + case r >= 'A' && r <= 'Z': + case r == '_' || r == '-': + default: + return false + } + } + return true +} + +func linuxDoSyntheticEmail(subject string) string { + subject = strings.TrimSpace(subject) + if subject == "" { + return "" + } + return "linuxdo-" + subject + service.LinuxDoConnectSyntheticEmailDomain +} diff --git a/backend/internal/handler/auth_linuxdo_oauth_test.go b/backend/internal/handler/auth_linuxdo_oauth_test.go new file mode 100644 index 00000000..ff169c52 --- /dev/null +++ b/backend/internal/handler/auth_linuxdo_oauth_test.go @@ -0,0 +1,108 @@ +package handler + +import ( + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func TestSanitizeFrontendRedirectPath(t *testing.T) { + require.Equal(t, "/dashboard", sanitizeFrontendRedirectPath("/dashboard")) + require.Equal(t, "/dashboard", sanitizeFrontendRedirectPath(" /dashboard ")) + require.Equal(t, "", sanitizeFrontendRedirectPath("dashboard")) + require.Equal(t, "", sanitizeFrontendRedirectPath("//evil.com")) + require.Equal(t, "", sanitizeFrontendRedirectPath("https://evil.com")) + require.Equal(t, "", sanitizeFrontendRedirectPath("/\nfoo")) + + long := "/" + strings.Repeat("a", linuxDoOAuthMaxRedirectLen) + require.Equal(t, "", sanitizeFrontendRedirectPath(long)) +} + +func TestBuildBearerAuthorization(t *testing.T) { + auth, err := buildBearerAuthorization("", "token123") + require.NoError(t, err) + require.Equal(t, "Bearer token123", auth) + + auth, err = buildBearerAuthorization("bearer", "token123") + require.NoError(t, err) + require.Equal(t, "Bearer token123", auth) + + _, err = buildBearerAuthorization("MAC", "token123") + require.Error(t, err) + + _, err = buildBearerAuthorization("Bearer", "token 123") + require.Error(t, err) +} + +func TestLinuxDoParseUserInfoParsesIDAndUsername(t *testing.T) { + cfg := config.LinuxDoConnectConfig{ + UserInfoURL: "https://connect.linux.do/api/user", + } + + email, username, subject, err := linuxDoParseUserInfo(`{"id":123,"username":"alice"}`, cfg) + require.NoError(t, err) + require.Equal(t, "123", subject) + require.Equal(t, "alice", username) + require.Equal(t, "linuxdo-123@linuxdo-connect.invalid", email) +} + +func TestLinuxDoParseUserInfoDefaultsUsername(t *testing.T) { + cfg := config.LinuxDoConnectConfig{ + UserInfoURL: "https://connect.linux.do/api/user", + } + + email, username, subject, err := linuxDoParseUserInfo(`{"id":"123"}`, cfg) + require.NoError(t, err) + require.Equal(t, "123", subject) + require.Equal(t, "linuxdo_123", username) + require.Equal(t, "linuxdo-123@linuxdo-connect.invalid", email) +} + +func TestLinuxDoParseUserInfoRejectsUnsafeSubject(t *testing.T) { + cfg := config.LinuxDoConnectConfig{ + UserInfoURL: "https://connect.linux.do/api/user", + } + + _, _, _, err := linuxDoParseUserInfo(`{"id":"123@456"}`, cfg) + require.Error(t, err) + + tooLong := strings.Repeat("a", linuxDoOAuthMaxSubjectLen+1) + _, _, _, err = linuxDoParseUserInfo(`{"id":"`+tooLong+`"}`, cfg) + require.Error(t, err) +} + +func TestParseOAuthProviderErrorJSON(t *testing.T) { + code, desc := parseOAuthProviderError(`{"error":"invalid_client","error_description":"bad secret"}`) + require.Equal(t, "invalid_client", code) + require.Equal(t, "bad secret", desc) +} + +func TestParseOAuthProviderErrorForm(t *testing.T) { + code, desc := parseOAuthProviderError("error=invalid_request&error_description=Missing+code_verifier") + require.Equal(t, "invalid_request", code) + require.Equal(t, "Missing code_verifier", desc) +} + +func TestParseLinuxDoTokenResponseJSON(t *testing.T) { + token, ok := parseLinuxDoTokenResponse(`{"access_token":"t1","token_type":"Bearer","expires_in":3600,"scope":"user"}`) + require.True(t, ok) + require.Equal(t, "t1", token.AccessToken) + require.Equal(t, "Bearer", token.TokenType) + require.Equal(t, int64(3600), token.ExpiresIn) + require.Equal(t, "user", token.Scope) +} + +func TestParseLinuxDoTokenResponseForm(t *testing.T) { + token, ok := parseLinuxDoTokenResponse("access_token=t2&token_type=bearer&expires_in=60") + require.True(t, ok) + require.Equal(t, "t2", token.AccessToken) + require.Equal(t, "bearer", token.TokenType) + require.Equal(t, int64(60), token.ExpiresIn) +} + +func TestSingleLineStripsWhitespace(t *testing.T) { + require.Equal(t, "hello world", singleLine("hello\r\nworld")) + require.Equal(t, "", singleLine("\n\t\r")) +} diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 9a672064..6ffaedea 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -53,16 +53,18 @@ func APIKeyFromService(k *service.APIKey) *APIKey { return nil } return &APIKey{ - ID: k.ID, - UserID: k.UserID, - Key: k.Key, - Name: k.Name, - GroupID: k.GroupID, - Status: k.Status, - CreatedAt: k.CreatedAt, - UpdatedAt: k.UpdatedAt, - User: UserFromServiceShallow(k.User), - Group: GroupFromServiceShallow(k.Group), + ID: k.ID, + UserID: k.UserID, + Key: k.Key, + Name: k.Name, + GroupID: k.GroupID, + Status: k.Status, + IPWhitelist: k.IPWhitelist, + IPBlacklist: k.IPBlacklist, + CreatedAt: k.CreatedAt, + UpdatedAt: k.UpdatedAt, + User: UserFromServiceShallow(k.User), + Group: GroupFromServiceShallow(k.Group), } } @@ -250,11 +252,12 @@ func AccountSummaryFromService(a *service.Account) *AccountSummary { // usageLogFromServiceBase is a helper that converts service UsageLog to DTO. // The account parameter allows caller to control what Account info is included. -func usageLogFromServiceBase(l *service.UsageLog, account *AccountSummary) *UsageLog { +// The includeIPAddress parameter controls whether to include the IP address (admin-only). +func usageLogFromServiceBase(l *service.UsageLog, account *AccountSummary, includeIPAddress bool) *UsageLog { if l == nil { return nil } - return &UsageLog{ + result := &UsageLog{ ID: l.ID, UserID: l.UserID, APIKeyID: l.APIKeyID, @@ -290,21 +293,26 @@ func usageLogFromServiceBase(l *service.UsageLog, account *AccountSummary) *Usag Group: GroupFromServiceShallow(l.Group), Subscription: UserSubscriptionFromService(l.Subscription), } + // IP 地址仅对管理员可见 + if includeIPAddress { + result.IPAddress = l.IPAddress + } + return result } // UsageLogFromService converts a service UsageLog to DTO for regular users. -// It excludes Account details - users should not see account information. +// It excludes Account details and IP address - users should not see these. func UsageLogFromService(l *service.UsageLog) *UsageLog { - return usageLogFromServiceBase(l, nil) + return usageLogFromServiceBase(l, nil, false) } // UsageLogFromServiceAdmin converts a service UsageLog to DTO for admin users. -// It includes minimal Account info (ID, Name only). +// It includes minimal Account info (ID, Name only) and IP address. func UsageLogFromServiceAdmin(l *service.UsageLog) *UsageLog { if l == nil { return nil } - return usageLogFromServiceBase(l, AccountSummaryFromService(l.Account)) + return usageLogFromServiceBase(l, AccountSummaryFromService(l.Account), true) } func SettingFromService(s *service.Setting) *Setting { @@ -362,3 +370,35 @@ func BulkAssignResultFromService(r *service.BulkAssignResult) *BulkAssignResult Errors: r.Errors, } } + +func PromoCodeFromService(pc *service.PromoCode) *PromoCode { + if pc == nil { + return nil + } + return &PromoCode{ + ID: pc.ID, + Code: pc.Code, + BonusAmount: pc.BonusAmount, + MaxUses: pc.MaxUses, + UsedCount: pc.UsedCount, + Status: pc.Status, + ExpiresAt: pc.ExpiresAt, + Notes: pc.Notes, + CreatedAt: pc.CreatedAt, + UpdatedAt: pc.UpdatedAt, + } +} + +func PromoCodeUsageFromService(u *service.PromoCodeUsage) *PromoCodeUsage { + if u == nil { + return nil + } + return &PromoCodeUsage{ + ID: u.ID, + PromoCodeID: u.PromoCodeID, + UserID: u.UserID, + BonusAmount: u.BonusAmount, + UsedAt: u.UsedAt, + User: UserFromServiceShallow(u.User), + } +} diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 4c50cedf..dab5eb75 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -17,6 +17,11 @@ type SystemSettings struct { TurnstileSiteKey string `json:"turnstile_site_key"` TurnstileSecretKeyConfigured bool `json:"turnstile_secret_key_configured"` + LinuxDoConnectEnabled bool `json:"linuxdo_connect_enabled"` + LinuxDoConnectClientID string `json:"linuxdo_connect_client_id"` + 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"` @@ -50,5 +55,6 @@ type PublicSettings struct { APIBaseURL string `json:"api_base_url"` ContactInfo string `json:"contact_info"` DocURL string `json:"doc_url"` + LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` Version string `json:"version"` } diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 03f7080b..a9b010b9 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -20,14 +20,16 @@ type User struct { } type APIKey struct { - ID int64 `json:"id"` - UserID int64 `json:"user_id"` - Key string `json:"key"` - Name string `json:"name"` - GroupID *int64 `json:"group_id"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Key string `json:"key"` + Name string `json:"name"` + GroupID *int64 `json:"group_id"` + Status string `json:"status"` + IPWhitelist []string `json:"ip_whitelist"` + IPBlacklist []string `json:"ip_blacklist"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` User *User `json:"user,omitempty"` Group *Group `json:"group,omitempty"` @@ -187,6 +189,9 @@ type UsageLog struct { // User-Agent UserAgent *string `json:"user_agent"` + // IP 地址(仅管理员可见) + IPAddress *string `json:"ip_address,omitempty"` + CreatedAt time.Time `json:"created_at"` User *User `json:"user,omitempty"` @@ -245,3 +250,28 @@ type BulkAssignResult struct { Subscriptions []UserSubscription `json:"subscriptions"` Errors []string `json:"errors"` } + +// PromoCode 注册优惠码 +type PromoCode struct { + ID int64 `json:"id"` + Code string `json:"code"` + BonusAmount float64 `json:"bonus_amount"` + MaxUses int `json:"max_uses"` + UsedCount int `json:"used_count"` + Status string `json:"status"` + ExpiresAt *time.Time `json:"expires_at"` + Notes string `json:"notes"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// PromoCodeUsage 优惠码使用记录 +type PromoCodeUsage struct { + ID int64 `json:"id"` + PromoCodeID int64 `json:"promo_code_id"` + UserID int64 `json:"user_id"` + BonusAmount float64 `json:"bonus_amount"` + UsedAt time.Time `json:"used_at"` + + User *User `json:"user,omitempty"` +} diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 48a827f3..0d38db17 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -15,6 +15,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" pkgerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -114,6 +115,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) { // 获取 User-Agent userAgent := c.Request.UserAgent() + // 获取客户端 IP + clientIP := ip.GetClientIP(c) + // 0. 检查wait队列是否已满 maxWait := service.CalculateMaxWait(subject.Concurrency) canWait, err := h.concurrencyHelper.IncrementWaitCount(c.Request.Context(), subject.UserID, maxWait) @@ -273,7 +277,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { } // 异步记录使用量(subscription已在函数开头获取) - go func(result *service.ForwardResult, usedAccount *service.Account, ua string) { + go func(result *service.ForwardResult, usedAccount *service.Account, ua string, cip string) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ @@ -283,10 +287,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) { Account: usedAccount, Subscription: subscription, UserAgent: ua, + IPAddress: cip, }); err != nil { log.Printf("Record usage failed: %v", err) } - }(result, account, userAgent) + }(result, account, userAgent, clientIP) return } } @@ -401,7 +406,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { } // 异步记录使用量(subscription已在函数开头获取) - go func(result *service.ForwardResult, usedAccount *service.Account, ua string) { + go func(result *service.ForwardResult, usedAccount *service.Account, ua string, cip string) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ @@ -411,10 +416,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) { Account: usedAccount, Subscription: subscription, UserAgent: ua, + IPAddress: cip, }); err != nil { log.Printf("Record usage failed: %v", err) } - }(result, account, userAgent) + }(result, account, userAgent, clientIP) return } } diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index 0cbe44f2..986b174b 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -12,6 +12,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "github.com/Wei-Shaw/sub2api/internal/pkg/gemini" "github.com/Wei-Shaw/sub2api/internal/pkg/googleapi" + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -167,6 +168,9 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { // 获取 User-Agent userAgent := c.Request.UserAgent() + // 获取客户端 IP + clientIP := ip.GetClientIP(c) + // For Gemini native API, do not send Claude-style ping frames. geminiConcurrency := NewConcurrencyHelper(h.concurrencyHelper.concurrencyService, SSEPingFormatNone, 0) @@ -307,7 +311,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { } // 6) record usage async - go func(result *service.ForwardResult, usedAccount *service.Account, ua string) { + go func(result *service.ForwardResult, usedAccount *service.Account, ua string, cip string) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ @@ -317,10 +321,11 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { Account: usedAccount, Subscription: subscription, UserAgent: ua, + IPAddress: cip, }); err != nil { log.Printf("Record usage failed: %v", err) } - }(result, account, userAgent) + }(result, account, userAgent, clientIP) return } } diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 817b71d3..047703f3 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -16,6 +16,7 @@ type AdminHandlers struct { AntigravityOAuth *admin.AntigravityOAuthHandler Proxy *admin.ProxyHandler Redeem *admin.RedeemHandler + Promo *admin.PromoHandler Setting *admin.SettingHandler System *admin.SystemHandler Subscription *admin.SubscriptionHandler diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 5400da3f..8c7d7d52 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -8,9 +8,12 @@ import ( "io" "log" "net/http" + "strings" "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -93,6 +96,24 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { userAgent := c.GetHeader("User-Agent") + // 获取客户端 IP + clientIP := ip.GetClientIP(c) + + if !openai.IsCodexCLIRequest(userAgent) { + existingInstructions, _ := reqBody["instructions"].(string) + if strings.TrimSpace(existingInstructions) == "" { + if instructions := strings.TrimSpace(service.GetOpenCodeInstructions()); instructions != "" { + reqBody["instructions"] = instructions + // Re-serialize body + body, err = json.Marshal(reqBody) + if err != nil { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to process request") + return + } + } + } + } + // Track if we've started streaming (for error handling) streamStarted := false @@ -231,7 +252,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { } // Async record usage - go func(result *service.OpenAIForwardResult, usedAccount *service.Account, ua string) { + go func(result *service.OpenAIForwardResult, usedAccount *service.Account, ua string, cip string) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{ @@ -241,10 +262,11 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { Account: usedAccount, Subscription: subscription, UserAgent: ua, + IPAddress: cip, }); err != nil { log.Printf("Record usage failed: %v", err) } - }(result, account, userAgent) + }(result, account, userAgent, clientIP) return } } diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 3cae7a7f..e1b20c8c 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -42,6 +42,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { APIBaseURL: settings.APIBaseURL, ContactInfo: settings.ContactInfo, DocURL: settings.DocURL, + LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, Version: h.version, }) } diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 1695f8a9..a5e62d0a 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -19,6 +19,7 @@ func ProvideAdminHandlers( antigravityOAuthHandler *admin.AntigravityOAuthHandler, proxyHandler *admin.ProxyHandler, redeemHandler *admin.RedeemHandler, + promoHandler *admin.PromoHandler, settingHandler *admin.SettingHandler, systemHandler *admin.SystemHandler, subscriptionHandler *admin.SubscriptionHandler, @@ -36,6 +37,7 @@ func ProvideAdminHandlers( AntigravityOAuth: antigravityOAuthHandler, Proxy: proxyHandler, Redeem: redeemHandler, + Promo: promoHandler, Setting: settingHandler, System: systemHandler, Subscription: subscriptionHandler, @@ -105,6 +107,7 @@ var ProviderSet = wire.NewSet( admin.NewAntigravityOAuthHandler, admin.NewProxyHandler, admin.NewRedeemHandler, + admin.NewPromoHandler, admin.NewSettingHandler, ProvideSystemHandler, admin.NewSubscriptionHandler, diff --git a/backend/internal/middleware/rate_limiter.go b/backend/internal/middleware/rate_limiter.go new file mode 100644 index 00000000..9526f071 --- /dev/null +++ b/backend/internal/middleware/rate_limiter.go @@ -0,0 +1,60 @@ +package middleware + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" +) + +// RateLimiter Redis 速率限制器 +type RateLimiter struct { + redis *redis.Client + prefix string +} + +// NewRateLimiter 创建速率限制器实例 +func NewRateLimiter(redisClient *redis.Client) *RateLimiter { + return &RateLimiter{ + redis: redisClient, + prefix: "rate_limit:", + } +} + +// Limit 返回速率限制中间件 +// key: 限制类型标识 +// limit: 时间窗口内最大请求数 +// window: 时间窗口 +func (r *RateLimiter) Limit(key string, limit int, window time.Duration) gin.HandlerFunc { + return func(c *gin.Context) { + ip := c.ClientIP() + redisKey := r.prefix + key + ":" + ip + + ctx := c.Request.Context() + + // 使用 INCR 原子操作增加计数 + count, err := r.redis.Incr(ctx, redisKey).Result() + if err != nil { + // Redis 错误时放行,避免影响正常服务 + c.Next() + return + } + + // 首次访问时设置过期时间 + if count == 1 { + r.redis.Expire(ctx, redisKey, window) + } + + // 超过限制 + if count > int64(limit) { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "error": "rate limit exceeded", + "message": "Too many requests, please try again later", + }) + return + } + + c.Next() + } +} diff --git a/backend/internal/pkg/ctxkey/ctxkey.go b/backend/internal/pkg/ctxkey/ctxkey.go index 3add78de..bd10eae0 100644 --- a/backend/internal/pkg/ctxkey/ctxkey.go +++ b/backend/internal/pkg/ctxkey/ctxkey.go @@ -9,4 +9,6 @@ const ( ForcePlatform Key = "ctx_force_platform" // IsClaudeCodeClient 是否为 Claude Code 客户端,由中间件设置 IsClaudeCodeClient Key = "ctx_is_claude_code_client" + // Group 认证后的分组信息,由 API Key 认证中间件设置 + Group Key = "ctx_group" ) diff --git a/backend/internal/pkg/ip/ip.go b/backend/internal/pkg/ip/ip.go new file mode 100644 index 00000000..97109c0c --- /dev/null +++ b/backend/internal/pkg/ip/ip.go @@ -0,0 +1,168 @@ +// Package ip 提供客户端 IP 地址提取工具。 +package ip + +import ( + "net" + "strings" + + "github.com/gin-gonic/gin" +) + +// GetClientIP 从 Gin Context 中提取客户端真实 IP 地址。 +// 按以下优先级检查 Header: +// 1. CF-Connecting-IP (Cloudflare) +// 2. X-Real-IP (Nginx) +// 3. X-Forwarded-For (取第一个非私有 IP) +// 4. c.ClientIP() (Gin 内置方法) +func GetClientIP(c *gin.Context) string { + // 1. Cloudflare + if ip := c.GetHeader("CF-Connecting-IP"); ip != "" { + return normalizeIP(ip) + } + + // 2. Nginx X-Real-IP + if ip := c.GetHeader("X-Real-IP"); ip != "" { + return normalizeIP(ip) + } + + // 3. X-Forwarded-For (多个 IP 时取第一个公网 IP) + if xff := c.GetHeader("X-Forwarded-For"); xff != "" { + ips := strings.Split(xff, ",") + for _, ip := range ips { + ip = strings.TrimSpace(ip) + if ip != "" && !isPrivateIP(ip) { + return normalizeIP(ip) + } + } + // 如果都是私有 IP,返回第一个 + if len(ips) > 0 { + return normalizeIP(strings.TrimSpace(ips[0])) + } + } + + // 4. Gin 内置方法 + return normalizeIP(c.ClientIP()) +} + +// normalizeIP 规范化 IP 地址,去除端口号和空格。 +func normalizeIP(ip string) string { + ip = strings.TrimSpace(ip) + // 移除端口号(如 "192.168.1.1:8080" -> "192.168.1.1") + if host, _, err := net.SplitHostPort(ip); err == nil { + return host + } + return ip +} + +// isPrivateIP 检查 IP 是否为私有地址。 +func isPrivateIP(ipStr string) bool { + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + + // 私有 IP 范围 + privateBlocks := []string{ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "127.0.0.0/8", + "::1/128", + "fc00::/7", + } + + for _, block := range privateBlocks { + _, cidr, err := net.ParseCIDR(block) + if err != nil { + continue + } + if cidr.Contains(ip) { + return true + } + } + return false +} + +// MatchesPattern 检查 IP 是否匹配指定的模式(支持单个 IP 或 CIDR)。 +// pattern 可以是: +// - 单个 IP: "192.168.1.100" +// - CIDR 范围: "192.168.1.0/24" +func MatchesPattern(clientIP, pattern string) bool { + ip := net.ParseIP(clientIP) + if ip == nil { + return false + } + + // 尝试解析为 CIDR + if strings.Contains(pattern, "/") { + _, cidr, err := net.ParseCIDR(pattern) + if err != nil { + return false + } + return cidr.Contains(ip) + } + + // 作为单个 IP 处理 + patternIP := net.ParseIP(pattern) + if patternIP == nil { + return false + } + return ip.Equal(patternIP) +} + +// MatchesAnyPattern 检查 IP 是否匹配任意一个模式。 +func MatchesAnyPattern(clientIP string, patterns []string) bool { + for _, pattern := range patterns { + if MatchesPattern(clientIP, pattern) { + return true + } + } + return false +} + +// CheckIPRestriction 检查 IP 是否被 API Key 的 IP 限制允许。 +// 返回值:(是否允许, 拒绝原因) +// 逻辑: +// 1. 先检查黑名单,如果在黑名单中则直接拒绝 +// 2. 如果白名单不为空,IP 必须在白名单中 +// 3. 如果白名单为空,允许访问(除非被黑名单拒绝) +func CheckIPRestriction(clientIP string, whitelist, blacklist []string) (bool, string) { + // 规范化 IP + clientIP = normalizeIP(clientIP) + if clientIP == "" { + return false, "access denied" + } + + // 1. 检查黑名单 + if len(blacklist) > 0 && MatchesAnyPattern(clientIP, blacklist) { + return false, "access denied" + } + + // 2. 检查白名单(如果设置了白名单,IP 必须在其中) + if len(whitelist) > 0 && !MatchesAnyPattern(clientIP, whitelist) { + return false, "access denied" + } + + return true, "" +} + +// ValidateIPPattern 验证 IP 或 CIDR 格式是否有效。 +func ValidateIPPattern(pattern string) bool { + if strings.Contains(pattern, "/") { + _, _, err := net.ParseCIDR(pattern) + return err == nil + } + return net.ParseIP(pattern) != nil +} + +// ValidateIPPatterns 验证多个 IP 或 CIDR 格式。 +// 返回无效的模式列表。 +func ValidateIPPatterns(patterns []string) []string { + var invalid []string + for _, p := range patterns { + if !ValidateIPPattern(p) { + invalid = append(invalid, p) + } + } + return invalid +} diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 83f02608..04ca7052 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -675,6 +675,40 @@ func (r *accountRepository) SetRateLimited(ctx context.Context, id int64, resetA return err } +func (r *accountRepository) SetAntigravityQuotaScopeLimit(ctx context.Context, id int64, scope service.AntigravityQuotaScope, resetAt time.Time) error { + now := time.Now().UTC() + payload := map[string]string{ + "rate_limited_at": now.Format(time.RFC3339), + "rate_limit_reset_at": resetAt.UTC().Format(time.RFC3339), + } + raw, err := json.Marshal(payload) + if err != nil { + return err + } + + path := "{antigravity_quota_scopes," + 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, + raw, + id, + ) + if err != nil { + return err + } + + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return service.ErrAccountNotFound + } + return nil +} + func (r *accountRepository) SetOverloaded(ctx context.Context, id int64, until time.Time) error { _, err := r.client.Account.Update(). Where(dbaccount.IDEQ(id)). @@ -718,6 +752,27 @@ func (r *accountRepository) ClearRateLimit(ctx context.Context, id int64) error return err } +func (r *accountRepository) ClearAntigravityQuotaScopes(ctx context.Context, id int64) error { + client := clientFromContext(ctx, r.client) + result, err := client.ExecContext( + ctx, + "UPDATE accounts SET extra = COALESCE(extra, '{}'::jsonb) - 'antigravity_quota_scopes', updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL", + id, + ) + if err != nil { + return err + } + + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return service.ErrAccountNotFound + } + return nil +} + func (r *accountRepository) UpdateSessionWindow(ctx context.Context, id int64, start, end *time.Time, status string) error { builder := r.client.Account.Update(). Where(dbaccount.IDEQ(id)). @@ -831,6 +886,11 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates args = append(args, *updates.Status) idx++ } + if updates.Schedulable != nil { + setClauses = append(setClauses, "schedulable = $"+itoa(idx)) + args = append(args, *updates.Schedulable) + idx++ + } // JSONB 需要合并而非覆盖,使用 raw SQL 保持旧行为。 if len(updates.Credentials) > 0 { payload, err := json.Marshal(updates.Credentials) diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index f3b07616..6b8cd40d 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -26,13 +26,21 @@ func (r *apiKeyRepository) activeQuery() *dbent.APIKeyQuery { } func (r *apiKeyRepository) Create(ctx context.Context, key *service.APIKey) error { - created, err := r.client.APIKey.Create(). + builder := r.client.APIKey.Create(). SetUserID(key.UserID). SetKey(key.Key). SetName(key.Name). SetStatus(key.Status). - SetNillableGroupID(key.GroupID). - Save(ctx) + SetNillableGroupID(key.GroupID) + + if len(key.IPWhitelist) > 0 { + builder.SetIPWhitelist(key.IPWhitelist) + } + if len(key.IPBlacklist) > 0 { + builder.SetIPBlacklist(key.IPBlacklist) + } + + created, err := builder.Save(ctx) if err == nil { key.ID = created.ID key.CreatedAt = created.CreatedAt @@ -108,6 +116,18 @@ func (r *apiKeyRepository) Update(ctx context.Context, key *service.APIKey) erro builder.ClearGroupID() } + // IP 限制字段 + if len(key.IPWhitelist) > 0 { + builder.SetIPWhitelist(key.IPWhitelist) + } else { + builder.ClearIPWhitelist() + } + if len(key.IPBlacklist) > 0 { + builder.SetIPBlacklist(key.IPBlacklist) + } else { + builder.ClearIPBlacklist() + } + affected, err := builder.Save(ctx) if err != nil { return err @@ -268,14 +288,16 @@ func apiKeyEntityToService(m *dbent.APIKey) *service.APIKey { return nil } out := &service.APIKey{ - ID: m.ID, - UserID: m.UserID, - Key: m.Key, - Name: m.Name, - Status: m.Status, - CreatedAt: m.CreatedAt, - UpdatedAt: m.UpdatedAt, - GroupID: m.GroupID, + ID: m.ID, + UserID: m.UserID, + Key: m.Key, + Name: m.Name, + Status: m.Status, + IPWhitelist: m.IPWhitelist, + IPBlacklist: m.IPBlacklist, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + GroupID: m.GroupID, } if m.Edges.User != nil { out.User = userEntityToService(m.Edges.User) @@ -317,6 +339,7 @@ func groupEntityToService(g *dbent.Group) *service.Group { RateMultiplier: g.RateMultiplier, IsExclusive: g.IsExclusive, Status: g.Status, + Hydrated: true, SubscriptionType: g.SubscriptionType, DailyLimitUSD: g.DailyLimitUsd, WeeklyLimitUSD: g.WeeklyLimitUsd, diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index 1fb4ae90..63651dd7 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -60,6 +60,17 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er } func (r *groupRepository) GetByID(ctx context.Context, id int64) (*service.Group, error) { + out, err := r.GetByIDLite(ctx, id) + if err != nil { + return nil, err + } + count, _ := r.GetAccountCount(ctx, out.ID) + out.AccountCount = count + return out, nil +} + +func (r *groupRepository) GetByIDLite(ctx context.Context, id int64) (*service.Group, error) { + // AccountCount is intentionally not loaded here; use GetByID when needed. m, err := r.client.Group.Query(). Where(group.IDEQ(id)). Only(ctx) @@ -67,10 +78,7 @@ func (r *groupRepository) GetByID(ctx context.Context, id int64) (*service.Group return nil, translatePersistenceError(err, service.ErrGroupNotFound, nil) } - out := groupEntityToService(m) - count, _ := r.GetAccountCount(ctx, out.ID) - out.AccountCount = count - return out, nil + return groupEntityToService(m), nil } func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) error { @@ -112,10 +120,10 @@ func (r *groupRepository) Delete(ctx context.Context, id int64) error { } func (r *groupRepository) List(ctx context.Context, params pagination.PaginationParams) ([]service.Group, *pagination.PaginationResult, error) { - return r.ListWithFilters(ctx, params, "", "", nil) + return r.ListWithFilters(ctx, params, "", "", "", nil) } -func (r *groupRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, status string, isExclusive *bool) ([]service.Group, *pagination.PaginationResult, error) { +func (r *groupRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, status, search string, isExclusive *bool) ([]service.Group, *pagination.PaginationResult, error) { q := r.client.Group.Query() if platform != "" { @@ -124,6 +132,12 @@ func (r *groupRepository) ListWithFilters(ctx context.Context, params pagination if status != "" { q = q.Where(group.StatusEQ(status)) } + if search != "" { + q = q.Where(group.Or( + group.NameContainsFold(search), + group.DescriptionContainsFold(search), + )) + } if isExclusive != nil { q = q.Where(group.IsExclusiveEQ(*isExclusive)) } diff --git a/backend/internal/repository/group_repo_integration_test.go b/backend/internal/repository/group_repo_integration_test.go index b9079d7a..c31a9ec4 100644 --- a/backend/internal/repository/group_repo_integration_test.go +++ b/backend/internal/repository/group_repo_integration_test.go @@ -4,6 +4,8 @@ package repository import ( "context" + "database/sql" + "errors" "testing" dbent "github.com/Wei-Shaw/sub2api/ent" @@ -19,6 +21,20 @@ type GroupRepoSuite struct { repo *groupRepository } +type forbidSQLExecutor struct { + called bool +} + +func (s *forbidSQLExecutor) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { + s.called = true + return nil, errors.New("unexpected sql exec") +} + +func (s *forbidSQLExecutor) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { + s.called = true + return nil, errors.New("unexpected sql query") +} + func (s *GroupRepoSuite) SetupTest() { s.ctx = context.Background() tx := testEntTx(s.T()) @@ -57,6 +73,26 @@ func (s *GroupRepoSuite) TestGetByID_NotFound() { s.Require().ErrorIs(err, service.ErrGroupNotFound) } +func (s *GroupRepoSuite) TestGetByIDLite_DoesNotUseAccountCount() { + group := &service.Group{ + Name: "lite-group", + Platform: service.PlatformAnthropic, + RateMultiplier: 1.0, + IsExclusive: false, + Status: service.StatusActive, + SubscriptionType: service.SubscriptionTypeStandard, + } + s.Require().NoError(s.repo.Create(s.ctx, group)) + + spy := &forbidSQLExecutor{} + repo := newGroupRepositoryWithSQL(s.tx.Client(), spy) + + got, err := repo.GetByIDLite(s.ctx, group.ID) + s.Require().NoError(err) + s.Require().Equal(group.ID, got.ID) + s.Require().False(spy.called, "expected no direct sql executor usage") +} + func (s *GroupRepoSuite) TestUpdate() { group := &service.Group{ Name: "original", @@ -131,6 +167,7 @@ func (s *GroupRepoSuite) TestListWithFilters_Platform() { pagination.PaginationParams{Page: 1, PageSize: 10}, service.PlatformOpenAI, "", + "", nil, ) s.Require().NoError(err, "ListWithFilters base") @@ -152,7 +189,7 @@ func (s *GroupRepoSuite) TestListWithFilters_Platform() { SubscriptionType: service.SubscriptionTypeStandard, })) - groups, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, service.PlatformOpenAI, "", nil) + groups, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, service.PlatformOpenAI, "", "", nil) s.Require().NoError(err) s.Require().Len(groups, len(baseGroups)+1) // Verify all groups are OpenAI platform @@ -179,7 +216,7 @@ func (s *GroupRepoSuite) TestListWithFilters_Status() { SubscriptionType: service.SubscriptionTypeStandard, })) - groups, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, "", service.StatusDisabled, nil) + groups, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, "", service.StatusDisabled, "", nil) s.Require().NoError(err) s.Require().Len(groups, 1) s.Require().Equal(service.StatusDisabled, groups[0].Status) @@ -204,12 +241,117 @@ func (s *GroupRepoSuite) TestListWithFilters_IsExclusive() { })) isExclusive := true - groups, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, "", "", &isExclusive) + groups, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, "", "", "", &isExclusive) s.Require().NoError(err) s.Require().Len(groups, 1) s.Require().True(groups[0].IsExclusive) } +func (s *GroupRepoSuite) TestListWithFilters_Search() { + newRepo := func() (*groupRepository, context.Context) { + tx := testEntTx(s.T()) + return newGroupRepositoryWithSQL(tx.Client(), tx), context.Background() + } + + containsID := func(groups []service.Group, id int64) bool { + for i := range groups { + if groups[i].ID == id { + return true + } + } + return false + } + + mustCreate := func(repo *groupRepository, ctx context.Context, g *service.Group) *service.Group { + s.Require().NoError(repo.Create(ctx, g)) + s.Require().NotZero(g.ID) + return g + } + + newGroup := func(name string) *service.Group { + return &service.Group{ + Name: name, + Platform: service.PlatformAnthropic, + RateMultiplier: 1.0, + IsExclusive: false, + Status: service.StatusActive, + SubscriptionType: service.SubscriptionTypeStandard, + } + } + + s.Run("search_name_should_match", func() { + repo, ctx := newRepo() + + target := mustCreate(repo, ctx, newGroup("it-group-search-name-target")) + other := mustCreate(repo, ctx, newGroup("it-group-search-name-other")) + + groups, _, err := repo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 50}, "", "", "name-target", nil) + s.Require().NoError(err) + s.Require().True(containsID(groups, target.ID), "expected target group to match by name") + s.Require().False(containsID(groups, other.ID), "expected other group to be filtered out") + }) + + s.Run("search_description_should_match", func() { + repo, ctx := newRepo() + + target := newGroup("it-group-search-desc-target") + target.Description = "something about desc-needle in here" + target = mustCreate(repo, ctx, target) + + other := newGroup("it-group-search-desc-other") + other.Description = "nothing to see here" + other = mustCreate(repo, ctx, other) + + groups, _, err := repo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 50}, "", "", "desc-needle", nil) + s.Require().NoError(err) + s.Require().True(containsID(groups, target.ID), "expected target group to match by description") + s.Require().False(containsID(groups, other.ID), "expected other group to be filtered out") + }) + + s.Run("search_nonexistent_should_return_empty", func() { + repo, ctx := newRepo() + + _ = mustCreate(repo, ctx, newGroup("it-group-search-nonexistent-baseline")) + + search := s.T().Name() + "__no_such_group__" + groups, _, err := repo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 50}, "", "", search, nil) + s.Require().NoError(err) + s.Require().Empty(groups) + }) + + s.Run("search_should_be_case_insensitive", func() { + repo, ctx := newRepo() + + target := mustCreate(repo, ctx, newGroup("MiXeDCaSe-Needle")) + other := mustCreate(repo, ctx, newGroup("it-group-search-case-other")) + + groups, _, err := repo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 50}, "", "", "mixedcase-needle", nil) + s.Require().NoError(err) + s.Require().True(containsID(groups, target.ID), "expected case-insensitive match") + s.Require().False(containsID(groups, other.ID), "expected other group to be filtered out") + }) + + s.Run("search_should_escape_like_wildcards", func() { + repo, ctx := newRepo() + + percentTarget := mustCreate(repo, ctx, newGroup("it-group-search-100%-target")) + percentOther := mustCreate(repo, ctx, newGroup("it-group-search-100X-other")) + + groups, _, err := repo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 50}, "", "", "100%", nil) + s.Require().NoError(err) + s.Require().True(containsID(groups, percentTarget.ID), "expected literal %% match") + s.Require().False(containsID(groups, percentOther.ID), "expected %% not to act as wildcard") + + underscoreTarget := mustCreate(repo, ctx, newGroup("it-group-search-ab_cd-target")) + underscoreOther := mustCreate(repo, ctx, newGroup("it-group-search-abXcd-other")) + + groups, _, err = repo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 50}, "", "", "ab_cd", nil) + s.Require().NoError(err) + s.Require().True(containsID(groups, underscoreTarget.ID), "expected literal _ match") + s.Require().False(containsID(groups, underscoreOther.ID), "expected _ not to act as wildcard") + }) +} + func (s *GroupRepoSuite) TestListWithFilters_AccountCount() { g1 := &service.Group{ Name: "g1", @@ -244,7 +386,7 @@ func (s *GroupRepoSuite) TestListWithFilters_AccountCount() { s.Require().NoError(err) isExclusive := true - groups, page, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, service.PlatformAnthropic, service.StatusActive, &isExclusive) + groups, page, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, service.PlatformAnthropic, service.StatusActive, "", &isExclusive) s.Require().NoError(err, "ListWithFilters") s.Require().Equal(int64(1), page.Total) s.Require().Len(groups, 1) diff --git a/backend/internal/repository/promo_code_repo.go b/backend/internal/repository/promo_code_repo.go new file mode 100644 index 00000000..98b422e0 --- /dev/null +++ b/backend/internal/repository/promo_code_repo.go @@ -0,0 +1,273 @@ +package repository + +import ( + "context" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/promocode" + "github.com/Wei-Shaw/sub2api/ent/promocodeusage" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +type promoCodeRepository struct { + client *dbent.Client +} + +func NewPromoCodeRepository(client *dbent.Client) service.PromoCodeRepository { + return &promoCodeRepository{client: client} +} + +func (r *promoCodeRepository) Create(ctx context.Context, code *service.PromoCode) error { + client := clientFromContext(ctx, r.client) + builder := client.PromoCode.Create(). + SetCode(code.Code). + SetBonusAmount(code.BonusAmount). + SetMaxUses(code.MaxUses). + SetUsedCount(code.UsedCount). + SetStatus(code.Status). + SetNotes(code.Notes) + + if code.ExpiresAt != nil { + builder.SetExpiresAt(*code.ExpiresAt) + } + + created, err := builder.Save(ctx) + if err != nil { + return err + } + + code.ID = created.ID + code.CreatedAt = created.CreatedAt + code.UpdatedAt = created.UpdatedAt + return nil +} + +func (r *promoCodeRepository) GetByID(ctx context.Context, id int64) (*service.PromoCode, error) { + m, err := r.client.PromoCode.Query(). + Where(promocode.IDEQ(id)). + Only(ctx) + if err != nil { + if dbent.IsNotFound(err) { + return nil, service.ErrPromoCodeNotFound + } + return nil, err + } + return promoCodeEntityToService(m), nil +} + +func (r *promoCodeRepository) GetByCode(ctx context.Context, code string) (*service.PromoCode, error) { + m, err := r.client.PromoCode.Query(). + Where(promocode.CodeEqualFold(code)). + Only(ctx) + if err != nil { + if dbent.IsNotFound(err) { + return nil, service.ErrPromoCodeNotFound + } + return nil, err + } + return promoCodeEntityToService(m), nil +} + +func (r *promoCodeRepository) GetByCodeForUpdate(ctx context.Context, code string) (*service.PromoCode, error) { + client := clientFromContext(ctx, r.client) + m, err := client.PromoCode.Query(). + Where(promocode.CodeEqualFold(code)). + ForUpdate(). + Only(ctx) + if err != nil { + if dbent.IsNotFound(err) { + return nil, service.ErrPromoCodeNotFound + } + return nil, err + } + return promoCodeEntityToService(m), nil +} + +func (r *promoCodeRepository) Update(ctx context.Context, code *service.PromoCode) error { + client := clientFromContext(ctx, r.client) + builder := client.PromoCode.UpdateOneID(code.ID). + SetCode(code.Code). + SetBonusAmount(code.BonusAmount). + SetMaxUses(code.MaxUses). + SetUsedCount(code.UsedCount). + SetStatus(code.Status). + SetNotes(code.Notes) + + if code.ExpiresAt != nil { + builder.SetExpiresAt(*code.ExpiresAt) + } else { + builder.ClearExpiresAt() + } + + updated, err := builder.Save(ctx) + if err != nil { + if dbent.IsNotFound(err) { + return service.ErrPromoCodeNotFound + } + return err + } + + code.UpdatedAt = updated.UpdatedAt + return nil +} + +func (r *promoCodeRepository) Delete(ctx context.Context, id int64) error { + client := clientFromContext(ctx, r.client) + _, err := client.PromoCode.Delete().Where(promocode.IDEQ(id)).Exec(ctx) + return err +} + +func (r *promoCodeRepository) List(ctx context.Context, params pagination.PaginationParams) ([]service.PromoCode, *pagination.PaginationResult, error) { + return r.ListWithFilters(ctx, params, "", "") +} + +func (r *promoCodeRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, status, search string) ([]service.PromoCode, *pagination.PaginationResult, error) { + q := r.client.PromoCode.Query() + + if status != "" { + q = q.Where(promocode.StatusEQ(status)) + } + if search != "" { + q = q.Where(promocode.CodeContainsFold(search)) + } + + total, err := q.Count(ctx) + if err != nil { + return nil, nil, err + } + + codes, err := q. + Offset(params.Offset()). + Limit(params.Limit()). + Order(dbent.Desc(promocode.FieldID)). + All(ctx) + if err != nil { + return nil, nil, err + } + + outCodes := promoCodeEntitiesToService(codes) + + return outCodes, paginationResultFromTotal(int64(total), params), nil +} + +func (r *promoCodeRepository) CreateUsage(ctx context.Context, usage *service.PromoCodeUsage) error { + client := clientFromContext(ctx, r.client) + created, err := client.PromoCodeUsage.Create(). + SetPromoCodeID(usage.PromoCodeID). + SetUserID(usage.UserID). + SetBonusAmount(usage.BonusAmount). + SetUsedAt(usage.UsedAt). + Save(ctx) + if err != nil { + return err + } + + usage.ID = created.ID + return nil +} + +func (r *promoCodeRepository) GetUsageByPromoCodeAndUser(ctx context.Context, promoCodeID, userID int64) (*service.PromoCodeUsage, error) { + m, err := r.client.PromoCodeUsage.Query(). + Where( + promocodeusage.PromoCodeIDEQ(promoCodeID), + promocodeusage.UserIDEQ(userID), + ). + Only(ctx) + if err != nil { + if dbent.IsNotFound(err) { + return nil, nil + } + return nil, err + } + return promoCodeUsageEntityToService(m), nil +} + +func (r *promoCodeRepository) ListUsagesByPromoCode(ctx context.Context, promoCodeID int64, params pagination.PaginationParams) ([]service.PromoCodeUsage, *pagination.PaginationResult, error) { + q := r.client.PromoCodeUsage.Query(). + Where(promocodeusage.PromoCodeIDEQ(promoCodeID)) + + total, err := q.Count(ctx) + if err != nil { + return nil, nil, err + } + + usages, err := q. + WithUser(). + Offset(params.Offset()). + Limit(params.Limit()). + Order(dbent.Desc(promocodeusage.FieldID)). + All(ctx) + if err != nil { + return nil, nil, err + } + + outUsages := promoCodeUsageEntitiesToService(usages) + + return outUsages, paginationResultFromTotal(int64(total), params), nil +} + +func (r *promoCodeRepository) IncrementUsedCount(ctx context.Context, id int64) error { + client := clientFromContext(ctx, r.client) + _, err := client.PromoCode.UpdateOneID(id). + AddUsedCount(1). + Save(ctx) + return err +} + +// Entity to Service conversions + +func promoCodeEntityToService(m *dbent.PromoCode) *service.PromoCode { + if m == nil { + return nil + } + return &service.PromoCode{ + ID: m.ID, + Code: m.Code, + BonusAmount: m.BonusAmount, + MaxUses: m.MaxUses, + UsedCount: m.UsedCount, + Status: m.Status, + ExpiresAt: m.ExpiresAt, + Notes: derefString(m.Notes), + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + } +} + +func promoCodeEntitiesToService(models []*dbent.PromoCode) []service.PromoCode { + out := make([]service.PromoCode, 0, len(models)) + for i := range models { + if s := promoCodeEntityToService(models[i]); s != nil { + out = append(out, *s) + } + } + return out +} + +func promoCodeUsageEntityToService(m *dbent.PromoCodeUsage) *service.PromoCodeUsage { + if m == nil { + return nil + } + out := &service.PromoCodeUsage{ + ID: m.ID, + PromoCodeID: m.PromoCodeID, + UserID: m.UserID, + BonusAmount: m.BonusAmount, + UsedAt: m.UsedAt, + } + if m.Edges.User != nil { + out.User = userEntityToService(m.Edges.User) + } + return out +} + +func promoCodeUsageEntitiesToService(models []*dbent.PromoCodeUsage) []service.PromoCodeUsage { + out := make([]service.PromoCodeUsage, 0, len(models)) + for i := range models { + if s := promoCodeUsageEntityToService(models[i]); s != nil { + out = append(out, *s) + } + } + return out +} diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index bd5c8b4f..6ed8910e 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, billing_type, stream, duration_ms, first_token_ms, user_agent, image_count, image_size, 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, billing_type, stream, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, created_at" type usageLogRepository struct { client *dbent.Client @@ -110,6 +110,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog) duration_ms, first_token_ms, user_agent, + ip_address, image_count, image_size, created_at @@ -119,7 +120,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 + $20, $21, $22, $23, $24, $25, $26, $27, $28, $29 ) ON CONFLICT (request_id, api_key_id) DO NOTHING RETURNING id, created_at @@ -130,6 +131,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog) duration := nullInt(log.DurationMs) firstToken := nullInt(log.FirstTokenMs) userAgent := nullString(log.UserAgent) + ipAddress := nullString(log.IPAddress) imageSize := nullString(log.ImageSize) var requestIDArg any @@ -163,6 +165,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog) duration, firstToken, userAgent, + ipAddress, log.ImageCount, imageSize, createdAt, @@ -1873,6 +1876,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e durationMs sql.NullInt64 firstTokenMs sql.NullInt64 userAgent sql.NullString + ipAddress sql.NullString imageCount int imageSize sql.NullString createdAt time.Time @@ -1905,6 +1909,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e &durationMs, &firstTokenMs, &userAgent, + &ipAddress, &imageCount, &imageSize, &createdAt, @@ -1959,6 +1964,9 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e if userAgent.Valid { log.UserAgent = &userAgent.String } + if ipAddress.Valid { + log.IPAddress = &ipAddress.String + } if imageSize.Valid { log.ImageSize = &imageSize.String } diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index ba09f85e..0a6118e2 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -45,6 +45,7 @@ var ProviderSet = wire.NewSet( NewAccountRepository, NewProxyRepository, NewRedeemCodeRepository, + NewPromoCodeRepository, NewUsageLogRepository, NewSettingRepository, NewUserSubscriptionRepository, diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 502d74b3..41d8bfdb 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -82,6 +82,8 @@ func TestAPIContracts(t *testing.T) { "name": "Key One", "group_id": null, "status": "active", + "ip_whitelist": null, + "ip_blacklist": null, "created_at": "2025-01-02T03:04:05Z", "updated_at": "2025-01-02T03:04:05Z" } @@ -116,6 +118,8 @@ func TestAPIContracts(t *testing.T) { "name": "Key One", "group_id": null, "status": "active", + "ip_whitelist": null, + "ip_blacklist": null, "created_at": "2025-01-02T03:04:05Z", "updated_at": "2025-01-02T03:04:05Z" } @@ -304,6 +308,10 @@ func TestAPIContracts(t *testing.T) { "turnstile_enabled": true, "turnstile_site_key": "site-key", "turnstile_secret_key_configured": true, + "linuxdo_connect_enabled": false, + "linuxdo_connect_client_id": "", + "linuxdo_connect_client_secret_configured": false, + "linuxdo_connect_redirect_url": "", "site_name": "Sub2API", "site_logo": "", "site_subtitle": "Subtitle", @@ -390,7 +398,7 @@ func newContractDeps(t *testing.T) *contractDeps { settingRepo := newStubSettingRepo() settingService := service.NewSettingService(settingRepo, cfg) - authHandler := handler.NewAuthHandler(cfg, nil, userService) + authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) usageHandler := handler.NewUsageHandler(usageService, apiKeyService) adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil) @@ -567,6 +575,10 @@ func (stubGroupRepo) GetByID(ctx context.Context, id int64) (*service.Group, err return nil, service.ErrGroupNotFound } +func (stubGroupRepo) GetByIDLite(ctx context.Context, id int64) (*service.Group, error) { + return nil, service.ErrGroupNotFound +} + func (stubGroupRepo) Update(ctx context.Context, group *service.Group) error { return errors.New("not implemented") } @@ -583,7 +595,7 @@ func (stubGroupRepo) List(ctx context.Context, params pagination.PaginationParam return nil, nil, errors.New("not implemented") } -func (stubGroupRepo) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, status string, isExclusive *bool) ([]service.Group, *pagination.PaginationResult, error) { +func (stubGroupRepo) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, status, search string, isExclusive *bool) ([]service.Group, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } diff --git a/backend/internal/server/http.go b/backend/internal/server/http.go index a8740ecc..90955867 100644 --- a/backend/internal/server/http.go +++ b/backend/internal/server/http.go @@ -13,6 +13,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/wire" + "github.com/redis/go-redis/v9" ) // ProviderSet 提供服务器层的依赖 @@ -30,6 +31,7 @@ func ProvideRouter( apiKeyAuth middleware2.APIKeyAuthMiddleware, apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, + redisClient *redis.Client, ) *gin.Engine { if cfg.Server.Mode == "release" { gin.SetMode(gin.ReleaseMode) @@ -47,7 +49,7 @@ func ProvideRouter( } } - return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, cfg) + return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, cfg, redisClient) } // ProvideHTTPServer 提供 HTTP 服务器 diff --git a/backend/internal/server/middleware/api_key_auth.go b/backend/internal/server/middleware/api_key_auth.go index 74ff8af3..dff6ba95 100644 --- a/backend/internal/server/middleware/api_key_auth.go +++ b/backend/internal/server/middleware/api_key_auth.go @@ -1,11 +1,14 @@ package middleware import ( + "context" "errors" "log" "strings" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -71,6 +74,17 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti return } + // 检查 IP 限制(白名单/黑名单) + // 注意:错误信息故意模糊,避免暴露具体的 IP 限制机制 + if len(apiKey.IPWhitelist) > 0 || len(apiKey.IPBlacklist) > 0 { + clientIP := ip.GetClientIP(c) + allowed, _ := ip.CheckIPRestriction(clientIP, apiKey.IPWhitelist, apiKey.IPBlacklist) + if !allowed { + AbortWithError(c, 403, "ACCESS_DENIED", "Access denied") + return + } + } + // 检查关联的用户 if apiKey.User == nil { AbortWithError(c, 401, "USER_NOT_FOUND", "User associated with API key not found") @@ -91,6 +105,7 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti Concurrency: apiKey.User.Concurrency, }) c.Set(string(ContextKeyUserRole), apiKey.User.Role) + setGroupContext(c, apiKey.Group) c.Next() return } @@ -149,6 +164,7 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti Concurrency: apiKey.User.Concurrency, }) c.Set(string(ContextKeyUserRole), apiKey.User.Role) + setGroupContext(c, apiKey.Group) c.Next() } @@ -173,3 +189,14 @@ func GetSubscriptionFromContext(c *gin.Context) (*service.UserSubscription, bool subscription, ok := value.(*service.UserSubscription) return subscription, ok } + +func setGroupContext(c *gin.Context, group *service.Group) { + if !service.IsGroupContextValid(group) { + return + } + if existing, ok := c.Request.Context().Value(ctxkey.Group).(*service.Group); ok && existing != nil && existing.ID == group.ID && service.IsGroupContextValid(existing) { + return + } + ctx := context.WithValue(c.Request.Context(), ctxkey.Group, group) + c.Request = c.Request.WithContext(ctx) +} diff --git a/backend/internal/server/middleware/api_key_auth_google.go b/backend/internal/server/middleware/api_key_auth_google.go index c5afd7ef..1a0b0dd5 100644 --- a/backend/internal/server/middleware/api_key_auth_google.go +++ b/backend/internal/server/middleware/api_key_auth_google.go @@ -63,6 +63,7 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs Concurrency: apiKey.User.Concurrency, }) c.Set(string(ContextKeyUserRole), apiKey.User.Role) + setGroupContext(c, apiKey.Group) c.Next() return } @@ -102,6 +103,7 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs Concurrency: apiKey.User.Concurrency, }) c.Set(string(ContextKeyUserRole), apiKey.User.Role) + setGroupContext(c, apiKey.Group) c.Next() } } diff --git a/backend/internal/server/middleware/api_key_auth_google_test.go b/backend/internal/server/middleware/api_key_auth_google_test.go index 0ed5a4a2..07b8e370 100644 --- a/backend/internal/server/middleware/api_key_auth_google_test.go +++ b/backend/internal/server/middleware/api_key_auth_google_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/service" @@ -133,6 +134,70 @@ func TestApiKeyAuthWithSubscriptionGoogle_QueryApiKeyRejected(t *testing.T) { require.Equal(t, "INVALID_ARGUMENT", resp.Error.Status) } +func TestApiKeyAuthWithSubscriptionGoogleSetsGroupContext(t *testing.T) { + gin.SetMode(gin.TestMode) + + group := &service.Group{ + ID: 99, + Name: "g1", + Status: service.StatusActive, + Platform: service.PlatformGemini, + Hydrated: true, + } + user := &service.User{ + ID: 7, + Role: service.RoleUser, + Status: service.StatusActive, + Balance: 10, + Concurrency: 3, + } + apiKey := &service.APIKey{ + ID: 100, + UserID: user.ID, + Key: "test-key", + Status: service.StatusActive, + User: user, + Group: group, + } + apiKey.GroupID = &group.ID + + apiKeyService := service.NewAPIKeyService( + fakeAPIKeyRepo{ + getByKey: func(ctx context.Context, key string) (*service.APIKey, error) { + if key != apiKey.Key { + return nil, service.ErrAPIKeyNotFound + } + clone := *apiKey + return &clone, nil + }, + }, + nil, + nil, + nil, + nil, + &config.Config{RunMode: config.RunModeSimple}, + ) + + cfg := &config.Config{RunMode: config.RunModeSimple} + r := gin.New() + r.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, cfg)) + r.GET("/v1beta/test", func(c *gin.Context) { + groupFromCtx, ok := c.Request.Context().Value(ctxkey.Group).(*service.Group) + if !ok || groupFromCtx == nil || groupFromCtx.ID != group.ID { + c.JSON(http.StatusInternalServerError, gin.H{"ok": false}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil) + req.Header.Set("x-api-key", apiKey.Key) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) +} + func TestApiKeyAuthWithSubscriptionGoogle_QueryKeyAllowedOnV1Beta(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/server/middleware/api_key_auth_test.go b/backend/internal/server/middleware/api_key_auth_test.go index d50fb7b2..182ea5f8 100644 --- a/backend/internal/server/middleware/api_key_auth_test.go +++ b/backend/internal/server/middleware/api_key_auth_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -25,6 +26,7 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) { ID: 42, Name: "sub", Status: service.StatusActive, + Hydrated: true, SubscriptionType: service.SubscriptionTypeSubscription, DailyLimitUSD: &limit, } @@ -110,6 +112,129 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) { }) } +func TestAPIKeyAuthSetsGroupContext(t *testing.T) { + gin.SetMode(gin.TestMode) + + group := &service.Group{ + ID: 101, + Name: "g1", + Status: service.StatusActive, + Platform: service.PlatformAnthropic, + Hydrated: true, + } + user := &service.User{ + ID: 7, + Role: service.RoleUser, + Status: service.StatusActive, + Balance: 10, + Concurrency: 3, + } + apiKey := &service.APIKey{ + ID: 100, + UserID: user.ID, + Key: "test-key", + Status: service.StatusActive, + User: user, + Group: group, + } + apiKey.GroupID = &group.ID + + apiKeyRepo := &stubApiKeyRepo{ + getByKey: func(ctx context.Context, key string) (*service.APIKey, error) { + if key != apiKey.Key { + return nil, service.ErrAPIKeyNotFound + } + clone := *apiKey + return &clone, nil + }, + } + + cfg := &config.Config{RunMode: config.RunModeSimple} + apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, cfg) + router := gin.New() + router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddleware(apiKeyService, nil, cfg))) + router.GET("/t", func(c *gin.Context) { + groupFromCtx, ok := c.Request.Context().Value(ctxkey.Group).(*service.Group) + if !ok || groupFromCtx == nil || groupFromCtx.ID != group.ID { + c.JSON(http.StatusInternalServerError, gin.H{"ok": false}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/t", nil) + req.Header.Set("x-api-key", apiKey.Key) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) +} + +func TestAPIKeyAuthOverwritesInvalidContextGroup(t *testing.T) { + gin.SetMode(gin.TestMode) + + group := &service.Group{ + ID: 101, + Name: "g1", + Status: service.StatusActive, + Platform: service.PlatformAnthropic, + Hydrated: true, + } + user := &service.User{ + ID: 7, + Role: service.RoleUser, + Status: service.StatusActive, + Balance: 10, + Concurrency: 3, + } + apiKey := &service.APIKey{ + ID: 100, + UserID: user.ID, + Key: "test-key", + Status: service.StatusActive, + User: user, + Group: group, + } + apiKey.GroupID = &group.ID + + apiKeyRepo := &stubApiKeyRepo{ + getByKey: func(ctx context.Context, key string) (*service.APIKey, error) { + if key != apiKey.Key { + return nil, service.ErrAPIKeyNotFound + } + clone := *apiKey + return &clone, nil + }, + } + + cfg := &config.Config{RunMode: config.RunModeSimple} + apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, cfg) + router := gin.New() + router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddleware(apiKeyService, nil, cfg))) + + invalidGroup := &service.Group{ + ID: group.ID, + Platform: group.Platform, + Status: group.Status, + } + router.GET("/t", func(c *gin.Context) { + groupFromCtx, ok := c.Request.Context().Value(ctxkey.Group).(*service.Group) + if !ok || groupFromCtx == nil || groupFromCtx.ID != group.ID || !groupFromCtx.Hydrated || groupFromCtx == invalidGroup { + c.JSON(http.StatusInternalServerError, gin.H{"ok": false}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/t", nil) + req.Header.Set("x-api-key", apiKey.Key) + req = req.WithContext(context.WithValue(req.Context(), ctxkey.Group, invalidGroup)) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) +} + func newAuthTestRouter(apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) *gin.Engine { router := gin.New() router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, cfg))) diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 15a1b325..2c0852a4 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -9,6 +9,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/web" "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" ) // SetupRouter 配置路由器中间件和路由 @@ -21,6 +22,7 @@ func SetupRouter( apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config, + redisClient *redis.Client, ) *gin.Engine { // 应用中间件 r.Use(middleware2.Logger()) @@ -33,7 +35,7 @@ func SetupRouter( } // 注册路由 - registerRoutes(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, cfg) + registerRoutes(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, cfg, redisClient) return r } @@ -48,6 +50,7 @@ func registerRoutes( apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config, + redisClient *redis.Client, ) { // 通用路由(健康检查、状态等) routes.RegisterCommonRoutes(r) @@ -56,7 +59,7 @@ func registerRoutes( v1 := r.Group("/api/v1") // 注册各模块路由 - routes.RegisterAuthRoutes(v1, h, jwtAuth) + routes.RegisterAuthRoutes(v1, h, jwtAuth, redisClient) routes.RegisterUserRoutes(v1, h, jwtAuth) routes.RegisterAdminRoutes(v1, h, adminAuth) routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, cfg) diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 663c2d02..6f40c491 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -44,6 +44,9 @@ func RegisterAdminRoutes( // 卡密管理 registerRedeemCodeRoutes(admin, h) + // 优惠码管理 + registerPromoCodeRoutes(admin, h) + // 系统设置 registerSettingsRoutes(admin, h) @@ -201,6 +204,18 @@ func registerRedeemCodeRoutes(admin *gin.RouterGroup, h *handler.Handlers) { } } +func registerPromoCodeRoutes(admin *gin.RouterGroup, h *handler.Handlers) { + promoCodes := admin.Group("/promo-codes") + { + promoCodes.GET("", h.Admin.Promo.List) + promoCodes.GET("/:id", h.Admin.Promo.GetByID) + promoCodes.POST("", h.Admin.Promo.Create) + promoCodes.PUT("/:id", h.Admin.Promo.Update) + promoCodes.DELETE("/:id", h.Admin.Promo.Delete) + promoCodes.GET("/:id/usages", h.Admin.Promo.GetUsages) + } +} + func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { adminSettings := admin.Group("/settings") { diff --git a/backend/internal/server/routes/auth.go b/backend/internal/server/routes/auth.go index 196d8bdb..7d8a79e9 100644 --- a/backend/internal/server/routes/auth.go +++ b/backend/internal/server/routes/auth.go @@ -1,24 +1,36 @@ package routes import ( + "time" + "github.com/Wei-Shaw/sub2api/internal/handler" - "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/middleware" + servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" ) // RegisterAuthRoutes 注册认证相关路由 func RegisterAuthRoutes( v1 *gin.RouterGroup, h *handler.Handlers, - jwtAuth middleware.JWTAuthMiddleware, + jwtAuth servermiddleware.JWTAuthMiddleware, + redisClient *redis.Client, ) { + // 创建速率限制器 + rateLimiter := middleware.NewRateLimiter(redisClient) + // 公开接口 auth := v1.Group("/auth") { auth.POST("/register", h.Auth.Register) auth.POST("/login", h.Auth.Login) auth.POST("/send-verify-code", h.Auth.SendVerifyCode) + // 优惠码验证接口添加速率限制:每分钟最多 10 次 + auth.POST("/validate-promo-code", rateLimiter.Limit("validate-promo", 10, time.Minute), h.Auth.ValidatePromoCode) + auth.GET("/oauth/linuxdo/start", h.Auth.LinuxDoOAuthStart) + auth.GET("/oauth/linuxdo/callback", h.Auth.LinuxDoOAuthCallback) } // 公开设置(无需认证) diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index e1b93fcb..2f138b81 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -49,10 +49,12 @@ type AccountRepository interface { ListSchedulableByGroupIDAndPlatforms(ctx context.Context, groupID int64, platforms []string) ([]Account, error) SetRateLimited(ctx context.Context, id int64, resetAt time.Time) error + SetAntigravityQuotaScopeLimit(ctx context.Context, id int64, scope AntigravityQuotaScope, resetAt time.Time) error SetOverloaded(ctx context.Context, id int64, until time.Time) error SetTempUnschedulable(ctx context.Context, id int64, until time.Time, reason string) error ClearTempUnschedulable(ctx context.Context, id int64) error ClearRateLimit(ctx context.Context, id int64) error + ClearAntigravityQuotaScopes(ctx context.Context, id int64) error UpdateSessionWindow(ctx context.Context, id int64, start, end *time.Time, status string) error UpdateExtra(ctx context.Context, id int64, updates map[string]any) error BulkUpdate(ctx context.Context, ids []int64, updates AccountBulkUpdate) (int64, error) @@ -66,6 +68,7 @@ type AccountBulkUpdate struct { Concurrency *int Priority *int Status *string + Schedulable *bool Credentials map[string]any Extra map[string]any } diff --git a/backend/internal/service/account_service_delete_test.go b/backend/internal/service/account_service_delete_test.go index edad8672..6923067d 100644 --- a/backend/internal/service/account_service_delete_test.go +++ b/backend/internal/service/account_service_delete_test.go @@ -139,6 +139,10 @@ func (s *accountRepoStub) SetRateLimited(ctx context.Context, id int64, resetAt panic("unexpected SetRateLimited call") } +func (s *accountRepoStub) SetAntigravityQuotaScopeLimit(ctx context.Context, id int64, scope AntigravityQuotaScope, resetAt time.Time) error { + panic("unexpected SetAntigravityQuotaScopeLimit call") +} + func (s *accountRepoStub) SetOverloaded(ctx context.Context, id int64, until time.Time) error { panic("unexpected SetOverloaded call") } @@ -155,6 +159,10 @@ func (s *accountRepoStub) ClearRateLimit(ctx context.Context, id int64) error { panic("unexpected ClearRateLimit call") } +func (s *accountRepoStub) ClearAntigravityQuotaScopes(ctx context.Context, id int64) error { + panic("unexpected ClearAntigravityQuotaScopes call") +} + func (s *accountRepoStub) UpdateSessionWindow(ctx context.Context, id int64, start, end *time.Time, status string) error { panic("unexpected UpdateSessionWindow call") } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index e29bbdb4..14bb6daf 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -24,7 +24,7 @@ type AdminService interface { GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error) // Group management - ListGroups(ctx context.Context, page, pageSize int, platform, status string, isExclusive *bool) ([]Group, int64, error) + ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool) ([]Group, int64, error) GetAllGroups(ctx context.Context) ([]Group, error) GetAllGroupsByPlatform(ctx context.Context, platform string) ([]Group, error) GetGroup(ctx context.Context, id int64) (*Group, error) @@ -168,6 +168,7 @@ type BulkUpdateAccountsInput struct { Concurrency *int Priority *int Status string + Schedulable *bool GroupIDs *[]int64 Credentials map[string]any Extra map[string]any @@ -478,9 +479,9 @@ func (s *adminServiceImpl) GetUserUsageStats(ctx context.Context, userID int64, } // Group management implementations -func (s *adminServiceImpl) ListGroups(ctx context.Context, page, pageSize int, platform, status string, isExclusive *bool) ([]Group, int64, error) { +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} - groups, result, err := s.groupRepo.ListWithFilters(ctx, params, platform, status, isExclusive) + groups, result, err := s.groupRepo.ListWithFilters(ctx, params, platform, status, search, isExclusive) if err != nil { return nil, 0, err } @@ -575,18 +576,33 @@ func (s *adminServiceImpl) validateFallbackGroup(ctx context.Context, currentGro return fmt.Errorf("cannot set self as fallback group") } - // 检查降级分组是否存在 - fallbackGroup, err := s.groupRepo.GetByID(ctx, fallbackGroupID) - if err != nil { - return fmt.Errorf("fallback group not found: %w", err) - } + visited := map[int64]struct{}{} + nextID := fallbackGroupID + for { + if _, seen := visited[nextID]; seen { + return fmt.Errorf("fallback group cycle detected") + } + visited[nextID] = struct{}{} + if currentGroupID > 0 && nextID == currentGroupID { + return fmt.Errorf("fallback group cycle detected") + } - // 降级分组不能启用 claude_code_only,否则会造成死循环 - if fallbackGroup.ClaudeCodeOnly { - return fmt.Errorf("fallback group cannot have claude_code_only enabled") - } + // 检查降级分组是否存在 + fallbackGroup, err := s.groupRepo.GetByIDLite(ctx, nextID) + if err != nil { + return fmt.Errorf("fallback group not found: %w", err) + } - return nil + // 降级分组不能启用 claude_code_only,否则会造成死循环 + if nextID == fallbackGroupID && fallbackGroup.ClaudeCodeOnly { + return fmt.Errorf("fallback group cannot have claude_code_only enabled") + } + + if fallbackGroup.FallbackGroupID == nil { + return nil + } + nextID = *fallbackGroup.FallbackGroupID + } } func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *UpdateGroupInput) (*Group, error) { @@ -910,6 +926,9 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp if input.Status != "" { repoUpdates.Status = &input.Status } + if input.Schedulable != nil { + repoUpdates.Schedulable = input.Schedulable + } // Run bulk update for column/jsonb fields first. if _, err := s.accountRepo.BulkUpdate(ctx, input.AccountIDs, repoUpdates); err != nil { diff --git a/backend/internal/service/admin_service_delete_test.go b/backend/internal/service/admin_service_delete_test.go index c1d2e4c9..31639472 100644 --- a/backend/internal/service/admin_service_delete_test.go +++ b/backend/internal/service/admin_service_delete_test.go @@ -107,6 +107,10 @@ func (s *groupRepoStub) GetByID(ctx context.Context, id int64) (*Group, error) { panic("unexpected GetByID call") } +func (s *groupRepoStub) GetByIDLite(ctx context.Context, id int64) (*Group, error) { + panic("unexpected GetByIDLite call") +} + func (s *groupRepoStub) Update(ctx context.Context, group *Group) error { panic("unexpected Update call") } @@ -124,7 +128,7 @@ func (s *groupRepoStub) List(ctx context.Context, params pagination.PaginationPa panic("unexpected List call") } -func (s *groupRepoStub) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, status string, isExclusive *bool) ([]Group, *pagination.PaginationResult, error) { +func (s *groupRepoStub) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, status, search string, isExclusive *bool) ([]Group, *pagination.PaginationResult, error) { panic("unexpected ListWithFilters call") } diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index 3171de11..e0574e2e 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -16,6 +16,16 @@ type groupRepoStubForAdmin struct { updated *Group // 记录 Update 调用的参数 getByID *Group // GetByID 返回值 getErr error // GetByID 返回的错误 + + listWithFiltersCalls int + listWithFiltersParams pagination.PaginationParams + listWithFiltersPlatform string + listWithFiltersStatus string + listWithFiltersSearch string + listWithFiltersIsExclusive *bool + listWithFiltersGroups []Group + listWithFiltersResult *pagination.PaginationResult + listWithFiltersErr error } func (s *groupRepoStubForAdmin) Create(_ context.Context, g *Group) error { @@ -35,6 +45,13 @@ func (s *groupRepoStubForAdmin) GetByID(_ context.Context, _ int64) (*Group, err return s.getByID, nil } +func (s *groupRepoStubForAdmin) GetByIDLite(_ context.Context, _ int64) (*Group, error) { + if s.getErr != nil { + return nil, s.getErr + } + return s.getByID, nil +} + func (s *groupRepoStubForAdmin) Delete(_ context.Context, _ int64) error { panic("unexpected Delete call") } @@ -47,8 +64,28 @@ func (s *groupRepoStubForAdmin) List(_ context.Context, _ pagination.PaginationP panic("unexpected List call") } -func (s *groupRepoStubForAdmin) ListWithFilters(_ context.Context, _ pagination.PaginationParams, _, _ string, _ *bool) ([]Group, *pagination.PaginationResult, error) { - panic("unexpected ListWithFilters call") +func (s *groupRepoStubForAdmin) ListWithFilters(_ context.Context, params pagination.PaginationParams, platform, status, search string, isExclusive *bool) ([]Group, *pagination.PaginationResult, error) { + s.listWithFiltersCalls++ + s.listWithFiltersParams = params + s.listWithFiltersPlatform = platform + s.listWithFiltersStatus = status + s.listWithFiltersSearch = search + s.listWithFiltersIsExclusive = isExclusive + + if s.listWithFiltersErr != nil { + return nil, nil, s.listWithFiltersErr + } + + result := s.listWithFiltersResult + if result == nil { + result = &pagination.PaginationResult{ + Total: int64(len(s.listWithFiltersGroups)), + Page: params.Page, + PageSize: params.PageSize, + } + } + + return s.listWithFiltersGroups, result, nil } func (s *groupRepoStubForAdmin) ListActive(_ context.Context) ([]Group, error) { @@ -195,3 +232,149 @@ func TestAdminService_UpdateGroup_PartialImagePricing(t *testing.T) { require.InDelta(t, 0.15, *repo.updated.ImagePrice2K, 0.0001) // 原值保持 require.Nil(t, repo.updated.ImagePrice4K) } + +func TestAdminService_ListGroups_WithSearch(t *testing.T) { + // 测试: + // 1. search 参数正常传递到 repository 层 + // 2. search 为空字符串时的行为 + // 3. search 与其他过滤条件组合使用 + + t.Run("search 参数正常传递到 repository 层", func(t *testing.T) { + repo := &groupRepoStubForAdmin{ + listWithFiltersGroups: []Group{{ID: 1, Name: "alpha"}}, + listWithFiltersResult: &pagination.PaginationResult{Total: 1}, + } + svc := &adminServiceImpl{groupRepo: repo} + + groups, total, err := svc.ListGroups(context.Background(), 1, 20, "", "", "alpha", nil) + require.NoError(t, err) + require.Equal(t, int64(1), total) + require.Equal(t, []Group{{ID: 1, Name: "alpha"}}, groups) + + require.Equal(t, 1, repo.listWithFiltersCalls) + require.Equal(t, pagination.PaginationParams{Page: 1, PageSize: 20}, repo.listWithFiltersParams) + require.Equal(t, "alpha", repo.listWithFiltersSearch) + require.Nil(t, repo.listWithFiltersIsExclusive) + }) + + t.Run("search 为空字符串时传递空字符串", func(t *testing.T) { + repo := &groupRepoStubForAdmin{ + listWithFiltersGroups: []Group{}, + listWithFiltersResult: &pagination.PaginationResult{Total: 0}, + } + svc := &adminServiceImpl{groupRepo: repo} + + groups, total, err := svc.ListGroups(context.Background(), 2, 10, "", "", "", nil) + require.NoError(t, err) + require.Empty(t, groups) + require.Equal(t, int64(0), total) + + require.Equal(t, 1, repo.listWithFiltersCalls) + require.Equal(t, pagination.PaginationParams{Page: 2, PageSize: 10}, repo.listWithFiltersParams) + require.Equal(t, "", repo.listWithFiltersSearch) + require.Nil(t, repo.listWithFiltersIsExclusive) + }) + + t.Run("search 与其他过滤条件组合使用", func(t *testing.T) { + isExclusive := true + repo := &groupRepoStubForAdmin{ + listWithFiltersGroups: []Group{{ID: 2, Name: "beta"}}, + listWithFiltersResult: &pagination.PaginationResult{Total: 42}, + } + svc := &adminServiceImpl{groupRepo: repo} + + groups, total, err := svc.ListGroups(context.Background(), 3, 50, PlatformAntigravity, StatusActive, "beta", &isExclusive) + require.NoError(t, err) + require.Equal(t, int64(42), total) + require.Equal(t, []Group{{ID: 2, Name: "beta"}}, groups) + + require.Equal(t, 1, repo.listWithFiltersCalls) + require.Equal(t, pagination.PaginationParams{Page: 3, PageSize: 50}, repo.listWithFiltersParams) + require.Equal(t, PlatformAntigravity, repo.listWithFiltersPlatform) + require.Equal(t, StatusActive, repo.listWithFiltersStatus) + require.Equal(t, "beta", repo.listWithFiltersSearch) + require.NotNil(t, repo.listWithFiltersIsExclusive) + require.True(t, *repo.listWithFiltersIsExclusive) + }) +} + +func TestAdminService_ValidateFallbackGroup_DetectsCycle(t *testing.T) { + groupID := int64(1) + fallbackID := int64(2) + repo := &groupRepoStubForFallbackCycle{ + groups: map[int64]*Group{ + groupID: { + ID: groupID, + FallbackGroupID: &fallbackID, + }, + fallbackID: { + ID: fallbackID, + FallbackGroupID: &groupID, + }, + }, + } + svc := &adminServiceImpl{groupRepo: repo} + + err := svc.validateFallbackGroup(context.Background(), groupID, fallbackID) + require.Error(t, err) + require.Contains(t, err.Error(), "fallback group cycle") +} + +type groupRepoStubForFallbackCycle struct { + groups map[int64]*Group +} + +func (s *groupRepoStubForFallbackCycle) Create(_ context.Context, _ *Group) error { + panic("unexpected Create call") +} + +func (s *groupRepoStubForFallbackCycle) Update(_ context.Context, _ *Group) error { + panic("unexpected Update call") +} + +func (s *groupRepoStubForFallbackCycle) GetByID(ctx context.Context, id int64) (*Group, error) { + return s.GetByIDLite(ctx, id) +} + +func (s *groupRepoStubForFallbackCycle) GetByIDLite(_ context.Context, id int64) (*Group, error) { + if g, ok := s.groups[id]; ok { + return g, nil + } + return nil, ErrGroupNotFound +} + +func (s *groupRepoStubForFallbackCycle) Delete(_ context.Context, _ int64) error { + panic("unexpected Delete call") +} + +func (s *groupRepoStubForFallbackCycle) DeleteCascade(_ context.Context, _ int64) ([]int64, error) { + panic("unexpected DeleteCascade call") +} + +func (s *groupRepoStubForFallbackCycle) List(_ context.Context, _ pagination.PaginationParams) ([]Group, *pagination.PaginationResult, error) { + panic("unexpected List call") +} + +func (s *groupRepoStubForFallbackCycle) ListWithFilters(_ context.Context, _ pagination.PaginationParams, _, _, _ string, _ *bool) ([]Group, *pagination.PaginationResult, error) { + panic("unexpected ListWithFilters call") +} + +func (s *groupRepoStubForFallbackCycle) ListActive(_ context.Context) ([]Group, error) { + panic("unexpected ListActive call") +} + +func (s *groupRepoStubForFallbackCycle) ListActiveByPlatform(_ context.Context, _ string) ([]Group, error) { + panic("unexpected ListActiveByPlatform call") +} + +func (s *groupRepoStubForFallbackCycle) ExistsByName(_ context.Context, _ string) (bool, error) { + panic("unexpected ExistsByName call") +} + +func (s *groupRepoStubForFallbackCycle) GetAccountCount(_ context.Context, _ int64) (int64, error) { + panic("unexpected GetAccountCount call") +} + +func (s *groupRepoStubForFallbackCycle) DeleteAccountGroupsByGroupID(_ context.Context, _ int64) (int64, error) { + panic("unexpected DeleteAccountGroupsByGroupID call") +} diff --git a/backend/internal/service/admin_service_search_test.go b/backend/internal/service/admin_service_search_test.go new file mode 100644 index 00000000..7506c6db --- /dev/null +++ b/backend/internal/service/admin_service_search_test.go @@ -0,0 +1,238 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/stretchr/testify/require" +) + +type accountRepoStubForAdminList struct { + accountRepoStub + + listWithFiltersCalls int + listWithFiltersParams pagination.PaginationParams + listWithFiltersPlatform string + listWithFiltersType string + listWithFiltersStatus string + listWithFiltersSearch string + listWithFiltersAccounts []Account + listWithFiltersResult *pagination.PaginationResult + listWithFiltersErr error +} + +func (s *accountRepoStubForAdminList) ListWithFilters(_ context.Context, params pagination.PaginationParams, platform, accountType, status, search string) ([]Account, *pagination.PaginationResult, error) { + s.listWithFiltersCalls++ + s.listWithFiltersParams = params + s.listWithFiltersPlatform = platform + s.listWithFiltersType = accountType + s.listWithFiltersStatus = status + s.listWithFiltersSearch = search + + if s.listWithFiltersErr != nil { + return nil, nil, s.listWithFiltersErr + } + + result := s.listWithFiltersResult + if result == nil { + result = &pagination.PaginationResult{ + Total: int64(len(s.listWithFiltersAccounts)), + Page: params.Page, + PageSize: params.PageSize, + } + } + + return s.listWithFiltersAccounts, result, nil +} + +type proxyRepoStubForAdminList struct { + proxyRepoStub + + listWithFiltersCalls int + listWithFiltersParams pagination.PaginationParams + listWithFiltersProtocol string + listWithFiltersStatus string + listWithFiltersSearch string + listWithFiltersProxies []Proxy + listWithFiltersResult *pagination.PaginationResult + listWithFiltersErr error + + listWithFiltersAndAccountCountCalls int + listWithFiltersAndAccountCountParams pagination.PaginationParams + listWithFiltersAndAccountCountProtocol string + listWithFiltersAndAccountCountStatus string + listWithFiltersAndAccountCountSearch string + listWithFiltersAndAccountCountProxies []ProxyWithAccountCount + listWithFiltersAndAccountCountResult *pagination.PaginationResult + listWithFiltersAndAccountCountErr error +} + +func (s *proxyRepoStubForAdminList) ListWithFilters(_ context.Context, params pagination.PaginationParams, protocol, status, search string) ([]Proxy, *pagination.PaginationResult, error) { + s.listWithFiltersCalls++ + s.listWithFiltersParams = params + s.listWithFiltersProtocol = protocol + s.listWithFiltersStatus = status + s.listWithFiltersSearch = search + + if s.listWithFiltersErr != nil { + return nil, nil, s.listWithFiltersErr + } + + result := s.listWithFiltersResult + if result == nil { + result = &pagination.PaginationResult{ + Total: int64(len(s.listWithFiltersProxies)), + Page: params.Page, + PageSize: params.PageSize, + } + } + + return s.listWithFiltersProxies, result, nil +} + +func (s *proxyRepoStubForAdminList) ListWithFiltersAndAccountCount(_ context.Context, params pagination.PaginationParams, protocol, status, search string) ([]ProxyWithAccountCount, *pagination.PaginationResult, error) { + s.listWithFiltersAndAccountCountCalls++ + s.listWithFiltersAndAccountCountParams = params + s.listWithFiltersAndAccountCountProtocol = protocol + s.listWithFiltersAndAccountCountStatus = status + s.listWithFiltersAndAccountCountSearch = search + + if s.listWithFiltersAndAccountCountErr != nil { + return nil, nil, s.listWithFiltersAndAccountCountErr + } + + result := s.listWithFiltersAndAccountCountResult + if result == nil { + result = &pagination.PaginationResult{ + Total: int64(len(s.listWithFiltersAndAccountCountProxies)), + Page: params.Page, + PageSize: params.PageSize, + } + } + + return s.listWithFiltersAndAccountCountProxies, result, nil +} + +type redeemRepoStubForAdminList struct { + redeemRepoStub + + listWithFiltersCalls int + listWithFiltersParams pagination.PaginationParams + listWithFiltersType string + listWithFiltersStatus string + listWithFiltersSearch string + listWithFiltersCodes []RedeemCode + listWithFiltersResult *pagination.PaginationResult + listWithFiltersErr error +} + +func (s *redeemRepoStubForAdminList) ListWithFilters(_ context.Context, params pagination.PaginationParams, codeType, status, search string) ([]RedeemCode, *pagination.PaginationResult, error) { + s.listWithFiltersCalls++ + s.listWithFiltersParams = params + s.listWithFiltersType = codeType + s.listWithFiltersStatus = status + s.listWithFiltersSearch = search + + if s.listWithFiltersErr != nil { + return nil, nil, s.listWithFiltersErr + } + + result := s.listWithFiltersResult + if result == nil { + result = &pagination.PaginationResult{ + Total: int64(len(s.listWithFiltersCodes)), + Page: params.Page, + PageSize: params.PageSize, + } + } + + return s.listWithFiltersCodes, result, nil +} + +func TestAdminService_ListAccounts_WithSearch(t *testing.T) { + t.Run("search 参数正常传递到 repository 层", func(t *testing.T) { + repo := &accountRepoStubForAdminList{ + listWithFiltersAccounts: []Account{{ID: 1, Name: "acc"}}, + listWithFiltersResult: &pagination.PaginationResult{Total: 10}, + } + svc := &adminServiceImpl{accountRepo: repo} + + accounts, total, err := svc.ListAccounts(context.Background(), 1, 20, PlatformGemini, AccountTypeOAuth, StatusActive, "acc") + require.NoError(t, err) + require.Equal(t, int64(10), total) + require.Equal(t, []Account{{ID: 1, Name: "acc"}}, accounts) + + require.Equal(t, 1, repo.listWithFiltersCalls) + require.Equal(t, pagination.PaginationParams{Page: 1, PageSize: 20}, repo.listWithFiltersParams) + require.Equal(t, PlatformGemini, repo.listWithFiltersPlatform) + require.Equal(t, AccountTypeOAuth, repo.listWithFiltersType) + require.Equal(t, StatusActive, repo.listWithFiltersStatus) + require.Equal(t, "acc", repo.listWithFiltersSearch) + }) +} + +func TestAdminService_ListProxies_WithSearch(t *testing.T) { + t.Run("search 参数正常传递到 repository 层", func(t *testing.T) { + repo := &proxyRepoStubForAdminList{ + listWithFiltersProxies: []Proxy{{ID: 2, Name: "p1"}}, + listWithFiltersResult: &pagination.PaginationResult{Total: 7}, + } + svc := &adminServiceImpl{proxyRepo: repo} + + proxies, total, err := svc.ListProxies(context.Background(), 3, 50, "http", StatusActive, "p1") + require.NoError(t, err) + require.Equal(t, int64(7), total) + require.Equal(t, []Proxy{{ID: 2, Name: "p1"}}, proxies) + + require.Equal(t, 1, repo.listWithFiltersCalls) + require.Equal(t, pagination.PaginationParams{Page: 3, PageSize: 50}, repo.listWithFiltersParams) + require.Equal(t, "http", repo.listWithFiltersProtocol) + require.Equal(t, StatusActive, repo.listWithFiltersStatus) + require.Equal(t, "p1", repo.listWithFiltersSearch) + }) +} + +func TestAdminService_ListProxiesWithAccountCount_WithSearch(t *testing.T) { + t.Run("search 参数正常传递到 repository 层", func(t *testing.T) { + repo := &proxyRepoStubForAdminList{ + listWithFiltersAndAccountCountProxies: []ProxyWithAccountCount{{Proxy: Proxy{ID: 3, Name: "p2"}, AccountCount: 5}}, + listWithFiltersAndAccountCountResult: &pagination.PaginationResult{Total: 9}, + } + svc := &adminServiceImpl{proxyRepo: repo} + + proxies, total, err := svc.ListProxiesWithAccountCount(context.Background(), 2, 10, "socks5", StatusDisabled, "p2") + require.NoError(t, err) + require.Equal(t, int64(9), total) + require.Equal(t, []ProxyWithAccountCount{{Proxy: Proxy{ID: 3, Name: "p2"}, AccountCount: 5}}, proxies) + + require.Equal(t, 1, repo.listWithFiltersAndAccountCountCalls) + require.Equal(t, pagination.PaginationParams{Page: 2, PageSize: 10}, repo.listWithFiltersAndAccountCountParams) + require.Equal(t, "socks5", repo.listWithFiltersAndAccountCountProtocol) + require.Equal(t, StatusDisabled, repo.listWithFiltersAndAccountCountStatus) + require.Equal(t, "p2", repo.listWithFiltersAndAccountCountSearch) + }) +} + +func TestAdminService_ListRedeemCodes_WithSearch(t *testing.T) { + t.Run("search 参数正常传递到 repository 层", func(t *testing.T) { + repo := &redeemRepoStubForAdminList{ + listWithFiltersCodes: []RedeemCode{{ID: 4, Code: "ABC"}}, + listWithFiltersResult: &pagination.PaginationResult{Total: 3}, + } + svc := &adminServiceImpl{redeemCodeRepo: repo} + + codes, total, err := svc.ListRedeemCodes(context.Background(), 1, 20, RedeemTypeBalance, StatusUnused, "ABC") + require.NoError(t, err) + require.Equal(t, int64(3), total) + require.Equal(t, []RedeemCode{{ID: 4, Code: "ABC"}}, codes) + + require.Equal(t, 1, repo.listWithFiltersCalls) + require.Equal(t, pagination.PaginationParams{Page: 1, PageSize: 20}, repo.listWithFiltersParams) + require.Equal(t, RedeemTypeBalance, repo.listWithFiltersType) + require.Equal(t, StatusUnused, repo.listWithFiltersStatus) + require.Equal(t, "ABC", repo.listWithFiltersSearch) + }) +} diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index 573017cd..4fd55757 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -93,6 +93,7 @@ var antigravityPrefixMapping = []struct { // 长前缀优先 {"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 @@ -502,6 +503,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, originalModel := claudeReq.Model mappedModel := s.getMappedModel(account, claudeReq.Model) + quotaScope, _ := resolveAntigravityQuotaScope(originalModel) // 获取 access_token if s.tokenProvider == nil { @@ -603,7 +605,7 @@ urlFallbackLoop: } // 所有重试都失败,标记限流状态 if resp.StatusCode == 429 { - s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody) + s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody, quotaScope) } // 最后一次尝试也失败 resp = &http.Response{ @@ -696,7 +698,7 @@ urlFallbackLoop: // 处理错误响应(重试后仍失败或不触发重试) if resp.StatusCode >= 400 { - s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody) + s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody, quotaScope) if s.shouldFailoverUpstreamError(resp.StatusCode) { return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode} @@ -1021,6 +1023,7 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co if len(body) == 0 { return nil, s.writeGoogleError(c, http.StatusBadRequest, "Request body is empty") } + quotaScope, _ := resolveAntigravityQuotaScope(originalModel) // 解析请求以获取 image_size(用于图片计费) imageSize := s.extractImageSize(body) @@ -1146,7 +1149,7 @@ urlFallbackLoop: } // 所有重试都失败,标记限流状态 if resp.StatusCode == 429 { - s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody) + s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody, quotaScope) } resp = &http.Response{ StatusCode: resp.StatusCode, @@ -1200,7 +1203,7 @@ urlFallbackLoop: goto handleSuccess } - s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody) + s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody, quotaScope) if s.shouldFailoverUpstreamError(resp.StatusCode) { return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode} @@ -1314,7 +1317,7 @@ func sleepAntigravityBackoffWithContext(ctx context.Context, attempt int) bool { } } -func (s *AntigravityGatewayService) handleUpstreamError(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte) { +func (s *AntigravityGatewayService) handleUpstreamError(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, quotaScope AntigravityQuotaScope) { // 429 使用 Gemini 格式解析(从 body 解析重置时间) if statusCode == 429 { resetAt := ParseGeminiRateLimitResetTime(body) @@ -1325,13 +1328,23 @@ func (s *AntigravityGatewayService) handleUpstreamError(ctx context.Context, pre defaultDur = 5 * time.Minute } ra := time.Now().Add(defaultDur) - log.Printf("%s status=429 rate_limited reset_in=%v (fallback)", prefix, defaultDur) - _ = s.accountRepo.SetRateLimited(ctx, account.ID, ra) + log.Printf("%s status=429 rate_limited scope=%s reset_in=%v (fallback)", prefix, quotaScope, defaultDur) + if quotaScope == "" { + return + } + if err := s.accountRepo.SetAntigravityQuotaScopeLimit(ctx, account.ID, quotaScope, ra); err != nil { + log.Printf("%s status=429 rate_limit_set_failed scope=%s error=%v", prefix, quotaScope, err) + } return } resetTime := time.Unix(*resetAt, 0) - log.Printf("%s status=429 rate_limited reset_at=%v reset_in=%v", prefix, resetTime.Format("15:04:05"), time.Until(resetTime).Truncate(time.Second)) - _ = s.accountRepo.SetRateLimited(ctx, account.ID, resetTime) + log.Printf("%s status=429 rate_limited scope=%s reset_at=%v reset_in=%v", prefix, quotaScope, resetTime.Format("15:04:05"), time.Until(resetTime).Truncate(time.Second)) + if quotaScope == "" { + return + } + if err := s.accountRepo.SetAntigravityQuotaScopeLimit(ctx, account.ID, quotaScope, resetTime); err != nil { + log.Printf("%s status=429 rate_limit_set_failed scope=%s error=%v", prefix, quotaScope, err) + } return } // 其他错误码继续使用 rateLimitService diff --git a/backend/internal/service/antigravity_quota_scope.go b/backend/internal/service/antigravity_quota_scope.go new file mode 100644 index 00000000..e9f7184b --- /dev/null +++ b/backend/internal/service/antigravity_quota_scope.go @@ -0,0 +1,88 @@ +package service + +import ( + "strings" + "time" +) + +const antigravityQuotaScopesKey = "antigravity_quota_scopes" + +// AntigravityQuotaScope 表示 Antigravity 的配额域 +type AntigravityQuotaScope string + +const ( + AntigravityQuotaScopeClaude AntigravityQuotaScope = "claude" + AntigravityQuotaScopeGeminiText AntigravityQuotaScope = "gemini_text" + AntigravityQuotaScopeGeminiImage AntigravityQuotaScope = "gemini_image" +) + +// resolveAntigravityQuotaScope 根据模型名称解析配额域 +func resolveAntigravityQuotaScope(requestedModel string) (AntigravityQuotaScope, bool) { + model := normalizeAntigravityModelName(requestedModel) + if model == "" { + return "", false + } + switch { + case strings.HasPrefix(model, "claude-"): + return AntigravityQuotaScopeClaude, true + case strings.HasPrefix(model, "gemini-"): + if isImageGenerationModel(model) { + return AntigravityQuotaScopeGeminiImage, true + } + return AntigravityQuotaScopeGeminiText, true + default: + return "", false + } +} + +func normalizeAntigravityModelName(model string) string { + normalized := strings.ToLower(strings.TrimSpace(model)) + normalized = strings.TrimPrefix(normalized, "models/") + return normalized +} + +// IsSchedulableForModel 结合 Antigravity 配额域限流判断是否可调度 +func (a *Account) IsSchedulableForModel(requestedModel string) bool { + if a == nil { + return false + } + if !a.IsSchedulable() { + return false + } + if a.Platform != PlatformAntigravity { + return true + } + scope, ok := resolveAntigravityQuotaScope(requestedModel) + if !ok { + return true + } + resetAt := a.antigravityQuotaScopeResetAt(scope) + if resetAt == nil { + return true + } + now := time.Now() + return !now.Before(*resetAt) +} + +func (a *Account) antigravityQuotaScopeResetAt(scope AntigravityQuotaScope) *time.Time { + if a == nil || a.Extra == nil || scope == "" { + return nil + } + rawScopes, ok := a.Extra[antigravityQuotaScopesKey].(map[string]any) + if !ok { + return nil + } + rawScope, ok := rawScopes[string(scope)].(map[string]any) + if !ok { + return nil + } + resetAtRaw, ok := rawScope["rate_limit_reset_at"].(string) + if !ok || strings.TrimSpace(resetAtRaw) == "" { + return nil + } + resetAt, err := time.Parse(time.RFC3339, resetAtRaw) + if err != nil { + return nil + } + return &resetAt +} diff --git a/backend/internal/service/api_key.go b/backend/internal/service/api_key.go index 0cf0f4f9..8c692d09 100644 --- a/backend/internal/service/api_key.go +++ b/backend/internal/service/api_key.go @@ -3,16 +3,18 @@ package service import "time" type APIKey struct { - ID int64 - UserID int64 - Key string - Name string - GroupID *int64 - Status string - CreatedAt time.Time - UpdatedAt time.Time - User *User - Group *Group + ID int64 + UserID int64 + Key string + Name string + GroupID *int64 + Status string + IPWhitelist []string + IPBlacklist []string + CreatedAt time.Time + UpdatedAt time.Time + User *User + Group *Group } func (k *APIKey) IsActive() bool { diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go index 0ffe8821..578afc1a 100644 --- a/backend/internal/service/api_key_service.go +++ b/backend/internal/service/api_key_service.go @@ -9,6 +9,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" ) @@ -20,6 +21,7 @@ var ( ErrAPIKeyTooShort = infraerrors.BadRequest("API_KEY_TOO_SHORT", "api key must be at least 16 characters") ErrAPIKeyInvalidChars = infraerrors.BadRequest("API_KEY_INVALID_CHARS", "api key can only contain letters, numbers, underscores, and hyphens") ErrAPIKeyRateLimited = infraerrors.TooManyRequests("API_KEY_RATE_LIMITED", "too many failed attempts, please try again later") + ErrInvalidIPPattern = infraerrors.BadRequest("INVALID_IP_PATTERN", "invalid IP or CIDR pattern") ) const ( @@ -57,16 +59,20 @@ type APIKeyCache interface { // CreateAPIKeyRequest 创建API Key请求 type CreateAPIKeyRequest struct { - Name string `json:"name"` - GroupID *int64 `json:"group_id"` - CustomKey *string `json:"custom_key"` // 可选的自定义key + Name string `json:"name"` + GroupID *int64 `json:"group_id"` + CustomKey *string `json:"custom_key"` // 可选的自定义key + IPWhitelist []string `json:"ip_whitelist"` // IP 白名单 + IPBlacklist []string `json:"ip_blacklist"` // IP 黑名单 } // UpdateAPIKeyRequest 更新API Key请求 type UpdateAPIKeyRequest struct { - Name *string `json:"name"` - GroupID *int64 `json:"group_id"` - Status *string `json:"status"` + Name *string `json:"name"` + GroupID *int64 `json:"group_id"` + Status *string `json:"status"` + IPWhitelist []string `json:"ip_whitelist"` // IP 白名单(空数组清空) + IPBlacklist []string `json:"ip_blacklist"` // IP 黑名单(空数组清空) } // APIKeyService API Key服务 @@ -186,6 +192,20 @@ func (s *APIKeyService) Create(ctx context.Context, userID int64, req CreateAPIK return nil, fmt.Errorf("get user: %w", err) } + // 验证 IP 白名单格式 + if len(req.IPWhitelist) > 0 { + if invalid := ip.ValidateIPPatterns(req.IPWhitelist); len(invalid) > 0 { + return nil, fmt.Errorf("%w: %v", ErrInvalidIPPattern, invalid) + } + } + + // 验证 IP 黑名单格式 + if len(req.IPBlacklist) > 0 { + if invalid := ip.ValidateIPPatterns(req.IPBlacklist); len(invalid) > 0 { + return nil, fmt.Errorf("%w: %v", ErrInvalidIPPattern, invalid) + } + } + // 验证分组权限(如果指定了分组) if req.GroupID != nil { group, err := s.groupRepo.GetByID(ctx, *req.GroupID) @@ -236,11 +256,13 @@ func (s *APIKeyService) Create(ctx context.Context, userID int64, req CreateAPIK // 创建API Key记录 apiKey := &APIKey{ - UserID: userID, - Key: key, - Name: req.Name, - GroupID: req.GroupID, - Status: StatusActive, + UserID: userID, + Key: key, + Name: req.Name, + GroupID: req.GroupID, + Status: StatusActive, + IPWhitelist: req.IPWhitelist, + IPBlacklist: req.IPBlacklist, } if err := s.apiKeyRepo.Create(ctx, apiKey); err != nil { @@ -312,6 +334,20 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req return nil, ErrInsufficientPerms } + // 验证 IP 白名单格式 + if len(req.IPWhitelist) > 0 { + if invalid := ip.ValidateIPPatterns(req.IPWhitelist); len(invalid) > 0 { + return nil, fmt.Errorf("%w: %v", ErrInvalidIPPattern, invalid) + } + } + + // 验证 IP 黑名单格式 + if len(req.IPBlacklist) > 0 { + if invalid := ip.ValidateIPPatterns(req.IPBlacklist); len(invalid) > 0 { + return nil, fmt.Errorf("%w: %v", ErrInvalidIPPattern, invalid) + } + } + // 更新字段 if req.Name != nil { apiKey.Name = *req.Name @@ -344,6 +380,10 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req } } + // 更新 IP 限制(空数组会清空设置) + apiKey.IPWhitelist = req.IPWhitelist + apiKey.IPBlacklist = req.IPBlacklist + if err := s.apiKeyRepo.Update(ctx, apiKey); err != nil { return nil, fmt.Errorf("update api key: %w", err) } diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 5a5ca03d..61b15cd8 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -2,9 +2,13 @@ package service import ( "context" + "crypto/rand" + "encoding/hex" "errors" "fmt" "log" + "net/mail" + "strings" "time" "github.com/Wei-Shaw/sub2api/internal/config" @@ -18,6 +22,7 @@ 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") @@ -47,6 +52,7 @@ type AuthService struct { emailService *EmailService turnstileService *TurnstileService emailQueueService *EmailQueueService + promoService *PromoService } // NewAuthService 创建认证服务实例 @@ -57,6 +63,7 @@ func NewAuthService( emailService *EmailService, turnstileService *TurnstileService, emailQueueService *EmailQueueService, + promoService *PromoService, ) *AuthService { return &AuthService{ userRepo: userRepo, @@ -65,21 +72,27 @@ func NewAuthService( emailService: emailService, turnstileService: turnstileService, emailQueueService: emailQueueService, + promoService: promoService, } } // 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 string) (string, *User, error) { - // 检查是否开放注册 - if s.settingService != nil && !s.settingService.IsRegistrationEnabled(ctx) { +// RegisterWithVerification 用户注册(支持邮件验证和优惠码),返回token和用户 +func (s *AuthService) RegisterWithVerification(ctx context.Context, email, password, verifyCode, promoCode string) (string, *User, error) { + // 检查是否开放注册(默认关闭:settingService 未配置时不允许注册) + if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) { return "", nil, ErrRegDisabled } + // 防止用户注册 LinuxDo OAuth 合成邮箱,避免第三方登录与本地账号发生碰撞。 + if isReservedEmail(email) { + return "", nil, ErrEmailReserved + } + // 检查是否需要邮件验证 if s.settingService != nil && s.settingService.IsEmailVerifyEnabled(ctx) { // 如果邮件验证已开启但邮件服务未配置,拒绝注册 @@ -132,10 +145,27 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw } if err := s.userRepo.Create(ctx, user); err != nil { + // 优先检查邮箱冲突错误(竞态条件下可能发生) + if errors.Is(err, ErrEmailExists) { + return "", nil, ErrEmailExists + } log.Printf("[Auth] Database error creating user: %v", err) return "", nil, ErrServiceUnavailable } + // 应用优惠码(如果提供) + if promoCode != "" && s.promoService != nil { + if err := s.promoService.ApplyPromoCode(ctx, user.ID, promoCode); err != nil { + // 优惠码应用失败不影响注册,只记录日志 + log.Printf("[Auth] Failed to apply promo code for user %d: %v", user.ID, err) + } else { + // 重新获取用户信息以获取更新后的余额 + if updatedUser, err := s.userRepo.GetByID(ctx, user.ID); err == nil { + user = updatedUser + } + } + } + // 生成token token, err := s.GenerateToken(user) if err != nil { @@ -152,11 +182,15 @@ type SendVerifyCodeResult struct { // SendVerifyCode 发送邮箱验证码(同步方式) func (s *AuthService) SendVerifyCode(ctx context.Context, email string) error { - // 检查是否开放注册 - if s.settingService != nil && !s.settingService.IsRegistrationEnabled(ctx) { + // 检查是否开放注册(默认关闭) + if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) { return ErrRegDisabled } + if isReservedEmail(email) { + return ErrEmailReserved + } + // 检查邮箱是否已存在 existsEmail, err := s.userRepo.ExistsByEmail(ctx, email) if err != nil { @@ -185,12 +219,16 @@ func (s *AuthService) SendVerifyCode(ctx context.Context, email string) error { func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string) (*SendVerifyCodeResult, error) { log.Printf("[Auth] SendVerifyCodeAsync called for email: %s", email) - // 检查是否开放注册 - if s.settingService != nil && !s.settingService.IsRegistrationEnabled(ctx) { + // 检查是否开放注册(默认关闭) + if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) { log.Println("[Auth] Registration is disabled") return nil, ErrRegDisabled } + if isReservedEmail(email) { + return nil, ErrEmailReserved + } + // 检查邮箱是否已存在 existsEmail, err := s.userRepo.ExistsByEmail(ctx, email) if err != nil { @@ -270,7 +308,7 @@ func (s *AuthService) IsTurnstileEnabled(ctx context.Context) bool { // IsRegistrationEnabled 检查是否开放注册 func (s *AuthService) IsRegistrationEnabled(ctx context.Context) bool { if s.settingService == nil { - return true + return false // 安全默认:settingService 未配置时关闭注册 } return s.settingService.IsRegistrationEnabled(ctx) } @@ -315,6 +353,102 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (string return token, user, nil } +// LoginOrRegisterOAuth 用于第三方 OAuth/SSO 登录: +// - 如果邮箱已存在:直接登录(不需要本地密码) +// - 如果邮箱不存在:创建新用户并登录 +// +// 注意:该函数用于“终端用户登录 Sub2API 本身”的场景(不同于上游账号的 OAuth,例如 OpenAI/Gemini)。 +// 为了满足现有数据库约束(需要密码哈希),新用户会生成随机密码并进行哈希保存。 +func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username string) (string, *User, error) { + email = strings.TrimSpace(email) + if email == "" || len(email) > 255 { + return "", nil, infraerrors.BadRequest("INVALID_EMAIL", "invalid email") + } + if _, err := mail.ParseAddress(email); err != nil { + return "", nil, infraerrors.BadRequest("INVALID_EMAIL", "invalid email") + } + + username = strings.TrimSpace(username) + if len([]rune(username)) > 100 { + username = string([]rune(username)[:100]) + } + + user, err := s.userRepo.GetByEmail(ctx, email) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + // OAuth 首次登录视为注册。 + if s.settingService != nil && !s.settingService.IsRegistrationEnabled(ctx) { + return "", nil, ErrRegDisabled + } + + randomPassword, err := randomHexString(32) + if err != nil { + log.Printf("[Auth] Failed to generate random password for oauth signup: %v", err) + return "", nil, ErrServiceUnavailable + } + hashedPassword, err := s.HashPassword(randomPassword) + if err != nil { + return "", nil, fmt.Errorf("hash password: %w", err) + } + + // 新用户默认值。 + defaultBalance := s.cfg.Default.UserBalance + defaultConcurrency := s.cfg.Default.UserConcurrency + if s.settingService != nil { + defaultBalance = s.settingService.GetDefaultBalance(ctx) + defaultConcurrency = s.settingService.GetDefaultConcurrency(ctx) + } + + newUser := &User{ + Email: email, + Username: username, + PasswordHash: hashedPassword, + Role: RoleUser, + Balance: defaultBalance, + Concurrency: defaultConcurrency, + Status: StatusActive, + } + + if err := s.userRepo.Create(ctx, newUser); err != nil { + if errors.Is(err, ErrEmailExists) { + // 并发场景:GetByEmail 与 Create 之间用户被创建。 + user, err = s.userRepo.GetByEmail(ctx, email) + if err != nil { + log.Printf("[Auth] Database error getting user after conflict: %v", err) + return "", nil, ErrServiceUnavailable + } + } else { + log.Printf("[Auth] Database error creating oauth user: %v", err) + return "", nil, ErrServiceUnavailable + } + } else { + user = newUser + } + } else { + log.Printf("[Auth] Database error during oauth login: %v", err) + return "", nil, ErrServiceUnavailable + } + } + + if !user.IsActive() { + return "", nil, ErrUserNotActive + } + + // 尽力补全:当用户名为空时,使用第三方返回的用户名回填。 + if user.Username == "" && username != "" { + user.Username = username + if err := s.userRepo.Update(ctx, user); err != nil { + log.Printf("[Auth] Failed to update username after oauth login: %v", err) + } + } + + token, err := s.GenerateToken(user) + if err != nil { + return "", nil, fmt.Errorf("generate token: %w", err) + } + return token, user, nil +} + // ValidateToken 验证JWT token并返回用户声明 func (s *AuthService) ValidateToken(tokenString string) (*JWTClaims, error) { // 先做长度校验,尽早拒绝异常超长 token,降低 DoS 风险。 @@ -357,6 +491,22 @@ func (s *AuthService) ValidateToken(tokenString string) (*JWTClaims, error) { return nil, ErrInvalidToken } +func randomHexString(byteLength int) (string, error) { + if byteLength <= 0 { + byteLength = 16 + } + buf := make([]byte, byteLength) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +func isReservedEmail(email string) bool { + normalized := strings.ToLower(strings.TrimSpace(email)) + return strings.HasSuffix(normalized, LinuxDoConnectSyntheticEmailDomain) +} + // GenerateToken 生成JWT token func (s *AuthService) GenerateToken(user *User) (string, error) { now := time.Now() diff --git a/backend/internal/service/auth_service_register_test.go b/backend/internal/service/auth_service_register_test.go index a31267ab..bc8f6f68 100644 --- a/backend/internal/service/auth_service_register_test.go +++ b/backend/internal/service/auth_service_register_test.go @@ -100,6 +100,7 @@ func newAuthService(repo *userRepoStub, settings map[string]string, emailCache E emailService, nil, nil, + nil, // promoService ) } @@ -113,6 +114,15 @@ func TestAuthService_Register_Disabled(t *testing.T) { require.ErrorIs(t, err, ErrRegDisabled) } +func TestAuthService_Register_DisabledByDefault(t *testing.T) { + // 当 settings 为 nil(设置项不存在)时,注册应该默认关闭 + repo := &userRepoStub{} + service := newAuthService(repo, nil, nil) + + _, _, err := service.Register(context.Background(), "user@test.com", "password") + require.ErrorIs(t, err, ErrRegDisabled) +} + func TestAuthService_Register_EmailVerifyEnabledButServiceNotConfigured(t *testing.T) { repo := &userRepoStub{} // 邮件验证开启但 emailCache 为 nil(emailService 未配置) @@ -122,7 +132,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) } @@ -134,7 +144,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) } @@ -148,14 +158,16 @@ 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") } func TestAuthService_Register_EmailExists(t *testing.T) { repo := &userRepoStub{exists: true} - service := newAuthService(repo, nil, nil) + service := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + }, nil) _, _, err := service.Register(context.Background(), "user@test.com", "password") require.ErrorIs(t, err, ErrEmailExists) @@ -163,23 +175,50 @@ func TestAuthService_Register_EmailExists(t *testing.T) { func TestAuthService_Register_CheckEmailError(t *testing.T) { repo := &userRepoStub{existsErr: errors.New("db down")} - service := newAuthService(repo, nil, nil) + service := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + }, nil) _, _, err := service.Register(context.Background(), "user@test.com", "password") require.ErrorIs(t, err, ErrServiceUnavailable) } +func TestAuthService_Register_ReservedEmail(t *testing.T) { + repo := &userRepoStub{} + service := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + }, nil) + + _, _, err := service.Register(context.Background(), "linuxdo-123@linuxdo-connect.invalid", "password") + require.ErrorIs(t, err, ErrEmailReserved) +} + func TestAuthService_Register_CreateError(t *testing.T) { repo := &userRepoStub{createErr: errors.New("create failed")} - service := newAuthService(repo, nil, nil) + service := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + }, nil) _, _, err := service.Register(context.Background(), "user@test.com", "password") require.ErrorIs(t, err, ErrServiceUnavailable) } +func TestAuthService_Register_CreateEmailExistsRace(t *testing.T) { + // 模拟竞态条件:ExistsByEmail 返回 false,但 Create 时因唯一约束失败 + repo := &userRepoStub{createErr: ErrEmailExists} + service := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + }, nil) + + _, _, err := service.Register(context.Background(), "user@test.com", "password") + require.ErrorIs(t, err, ErrEmailExists) +} + func TestAuthService_Register_Success(t *testing.T) { repo := &userRepoStub{nextID: 5} - service := newAuthService(repo, nil, nil) + service := newAuthService(repo, map[string]string{ + SettingKeyRegistrationEnabled: "true", + }, nil) token, user, err := service.Register(context.Background(), "user@test.com", "password") require.NoError(t, err) diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 9c61ea2e..9014670d 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -38,6 +38,12 @@ const ( RedeemTypeSubscription = "subscription" ) +// PromoCode status constants +const ( + PromoCodeStatusActive = "active" + PromoCodeStatusDisabled = "disabled" +) + // Admin adjustment type constants const ( AdjustmentTypeAdminBalance = "admin_balance" // 管理员调整余额 @@ -105,7 +111,17 @@ const ( // Request identity patch (Claude -> Gemini systemInstruction injection) SettingKeyEnableIdentityPatch = "enable_identity_patch" SettingKeyIdentityPatchPrompt = "identity_patch_prompt" + + // LinuxDo Connect OAuth 登录(终端用户 SSO) + SettingKeyLinuxDoConnectEnabled = "linuxdo_connect_enabled" + SettingKeyLinuxDoConnectClientID = "linuxdo_connect_client_id" + SettingKeyLinuxDoConnectClientSecret = "linuxdo_connect_client_secret" + SettingKeyLinuxDoConnectRedirectURL = "linuxdo_connect_redirect_url" ) +// LinuxDoConnectSyntheticEmailDomain 是 LinuxDo Connect 用户的合成邮箱后缀(RFC 保留域名)。 +// 目的:避免第三方登录返回的用户标识与本地真实邮箱发生碰撞,进而造成账号被接管的风险。 +const LinuxDoConnectSyntheticEmailDomain = "@linuxdo-connect.invalid" + // AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys). const AdminAPIKeyPrefix = "admin-" diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index afd8907c..55e137d6 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "crypto/tls" "fmt" + "log" "math/big" "net/smtp" "strconv" @@ -256,7 +257,9 @@ func (s *EmailService) VerifyCode(ctx context.Context, email, code string) error // 验证码不匹配 if data.Code != code { data.Attempts++ - _ = s.cache.SetVerificationCode(ctx, email, data, verifyCodeTTL) + if err := s.cache.SetVerificationCode(ctx, email, data, verifyCodeTTL); err != nil { + log.Printf("[Email] Failed to update verification attempt count: %v", err) + } if data.Attempts >= maxVerifyCodeAttempts { return ErrVerifyCodeMaxAttempts } @@ -264,7 +267,9 @@ func (s *EmailService) VerifyCode(ctx context.Context, email, code string) error } // 验证成功,删除验证码 - _ = s.cache.DeleteVerificationCode(ctx, email) + if err := s.cache.DeleteVerificationCode(ctx, email); err != nil { + log.Printf("[Email] Failed to delete verification code after success: %v", err) + } return nil } diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index 66c40e25..d863291a 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/stretchr/testify/require" ) @@ -23,9 +24,11 @@ type mockAccountRepoForPlatform struct { accounts []Account accountsByID map[int64]*Account listPlatformFunc func(ctx context.Context, platform string) ([]Account, error) + getByIDCalls int } func (m *mockAccountRepoForPlatform) GetByID(ctx context.Context, id int64) (*Account, error) { + m.getByIDCalls++ if acc, ok := m.accountsByID[id]; ok { return acc, nil } @@ -136,6 +139,9 @@ func (m *mockAccountRepoForPlatform) ListSchedulableByGroupIDAndPlatforms(ctx co func (m *mockAccountRepoForPlatform) SetRateLimited(ctx context.Context, id int64, resetAt time.Time) error { return nil } +func (m *mockAccountRepoForPlatform) SetAntigravityQuotaScopeLimit(ctx context.Context, id int64, scope AntigravityQuotaScope, resetAt time.Time) error { + return nil +} func (m *mockAccountRepoForPlatform) SetOverloaded(ctx context.Context, id int64, until time.Time) error { return nil } @@ -148,6 +154,9 @@ func (m *mockAccountRepoForPlatform) ClearTempUnschedulable(ctx context.Context, func (m *mockAccountRepoForPlatform) ClearRateLimit(ctx context.Context, id int64) error { return nil } +func (m *mockAccountRepoForPlatform) ClearAntigravityQuotaScopes(ctx context.Context, id int64) error { + return nil +} func (m *mockAccountRepoForPlatform) UpdateSessionWindow(ctx context.Context, id int64, start, end *time.Time, status string) error { return nil } @@ -185,6 +194,56 @@ func (m *mockGatewayCacheForPlatform) RefreshSessionTTL(ctx context.Context, gro return nil } +type mockGroupRepoForGateway struct { + groups map[int64]*Group + getByIDCalls int + getByIDLiteCalls int +} + +func (m *mockGroupRepoForGateway) GetByID(ctx context.Context, id int64) (*Group, error) { + m.getByIDCalls++ + if g, ok := m.groups[id]; ok { + return g, nil + } + return nil, ErrGroupNotFound +} + +func (m *mockGroupRepoForGateway) GetByIDLite(ctx context.Context, id int64) (*Group, error) { + m.getByIDLiteCalls++ + if g, ok := m.groups[id]; ok { + return g, nil + } + return nil, ErrGroupNotFound +} + +func (m *mockGroupRepoForGateway) Create(ctx context.Context, group *Group) error { return nil } +func (m *mockGroupRepoForGateway) Update(ctx context.Context, group *Group) error { return nil } +func (m *mockGroupRepoForGateway) Delete(ctx context.Context, id int64) error { return nil } +func (m *mockGroupRepoForGateway) DeleteCascade(ctx context.Context, id int64) ([]int64, error) { + return nil, nil +} +func (m *mockGroupRepoForGateway) List(ctx context.Context, params pagination.PaginationParams) ([]Group, *pagination.PaginationResult, error) { + return nil, nil, nil +} +func (m *mockGroupRepoForGateway) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, status, search string, isExclusive *bool) ([]Group, *pagination.PaginationResult, error) { + return nil, nil, nil +} +func (m *mockGroupRepoForGateway) ListActive(ctx context.Context) ([]Group, error) { + return nil, nil +} +func (m *mockGroupRepoForGateway) ListActiveByPlatform(ctx context.Context, platform string) ([]Group, error) { + return nil, nil +} +func (m *mockGroupRepoForGateway) ExistsByName(ctx context.Context, name string) (bool, error) { + return false, nil +} +func (m *mockGroupRepoForGateway) GetAccountCount(ctx context.Context, groupID int64) (int64, error) { + return 0, nil +} +func (m *mockGroupRepoForGateway) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) { + return 0, nil +} + func ptr[T any](v T) *T { return &v } @@ -891,6 +950,74 @@ func (m *mockConcurrencyService) GetAccountWaitingCount(ctx context.Context, acc return m.accountWaitCounts[accountID], nil } +type mockConcurrencyCache struct { + acquireAccountCalls int + loadBatchCalls int +} + +func (m *mockConcurrencyCache) AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) { + m.acquireAccountCalls++ + return true, nil +} + +func (m *mockConcurrencyCache) ReleaseAccountSlot(ctx context.Context, accountID int64, requestID string) error { + return nil +} + +func (m *mockConcurrencyCache) GetAccountConcurrency(ctx context.Context, accountID int64) (int, error) { + return 0, nil +} + +func (m *mockConcurrencyCache) IncrementAccountWaitCount(ctx context.Context, accountID int64, maxWait int) (bool, error) { + return true, nil +} + +func (m *mockConcurrencyCache) DecrementAccountWaitCount(ctx context.Context, accountID int64) error { + return nil +} + +func (m *mockConcurrencyCache) GetAccountWaitingCount(ctx context.Context, accountID int64) (int, error) { + return 0, nil +} + +func (m *mockConcurrencyCache) AcquireUserSlot(ctx context.Context, userID int64, maxConcurrency int, requestID string) (bool, error) { + return true, nil +} + +func (m *mockConcurrencyCache) ReleaseUserSlot(ctx context.Context, userID int64, requestID string) error { + return nil +} + +func (m *mockConcurrencyCache) GetUserConcurrency(ctx context.Context, userID int64) (int, error) { + return 0, nil +} + +func (m *mockConcurrencyCache) IncrementWaitCount(ctx context.Context, userID int64, maxWait int) (bool, error) { + return true, nil +} + +func (m *mockConcurrencyCache) DecrementWaitCount(ctx context.Context, userID int64) error { + return nil +} + +func (m *mockConcurrencyCache) GetAccountsLoadBatch(ctx context.Context, accounts []AccountWithConcurrency) (map[int64]*AccountLoadInfo, error) { + m.loadBatchCalls++ + result := make(map[int64]*AccountLoadInfo, len(accounts)) + for _, acc := range accounts { + result[acc.ID] = &AccountLoadInfo{ + AccountID: acc.ID, + CurrentConcurrency: 0, + WaitingCount: 0, + LoadRate: 0, + } + } + return result, nil +} + +func (m *mockConcurrencyCache) CleanupExpiredAccountSlots(ctx context.Context, accountID int64) error { + return nil +} + // TestGatewayService_SelectAccountWithLoadAwareness tests load-aware account selection func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { ctx := context.Background() @@ -989,6 +1116,78 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { require.Equal(t, int64(2), result.Account.ID, "不应选择被排除的账号") }) + t.Run("粘性命中-不调用GetByID", func(t *testing.T) { + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + {ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true, Concurrency: 5}, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + cache := &mockGatewayCacheForPlatform{ + sessionBindings: map[string]int64{"sticky": 1}, + } + + cfg := testConfig() + cfg.Gateway.Scheduling.LoadBatchEnabled = true + + concurrencyCache := &mockConcurrencyCache{} + + svc := &GatewayService{ + accountRepo: repo, + cache: cache, + cfg: cfg, + concurrencyService: NewConcurrencyService(concurrencyCache), + } + + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sticky", "claude-3-5-sonnet-20241022", nil) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Account) + require.Equal(t, int64(1), result.Account.ID) + require.Equal(t, 0, repo.getByIDCalls, "粘性命中不应调用GetByID") + require.Equal(t, 0, concurrencyCache.loadBatchCalls, "粘性命中应在负载批量查询前返回") + }) + + t.Run("粘性账号不在候选集-回退负载感知选择", func(t *testing.T) { + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + {ID: 2, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true, Concurrency: 5}, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + cache := &mockGatewayCacheForPlatform{ + sessionBindings: map[string]int64{"sticky": 1}, + } + + cfg := testConfig() + cfg.Gateway.Scheduling.LoadBatchEnabled = true + + concurrencyCache := &mockConcurrencyCache{} + + svc := &GatewayService{ + accountRepo: repo, + cache: cache, + cfg: cfg, + concurrencyService: NewConcurrencyService(concurrencyCache), + } + + result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sticky", "claude-3-5-sonnet-20241022", nil) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Account) + require.Equal(t, int64(2), result.Account.ID, "粘性账号不在候选集时应回退到可用账号") + require.Equal(t, 0, repo.getByIDCalls, "粘性账号缺失不应回退到GetByID") + require.Equal(t, 1, concurrencyCache.loadBatchCalls, "应继续进行负载批量查询") + }) + t.Run("无可用账号-返回错误", func(t *testing.T) { repo := &mockAccountRepoForPlatform{ accounts: []Account{}, @@ -1013,3 +1212,190 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { require.Contains(t, err.Error(), "no available accounts") }) } + +func TestGatewayService_GroupResolution_ReusesContextGroup(t *testing.T) { + ctx := context.Background() + groupID := int64(42) + group := &Group{ + ID: groupID, + Platform: PlatformAnthropic, + Status: StatusActive, + Hydrated: true, + } + ctx = context.WithValue(ctx, ctxkey.Group, group) + + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + {ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true}, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + groupRepo := &mockGroupRepoForGateway{ + groups: map[int64]*Group{groupID: group}, + } + + svc := &GatewayService{ + accountRepo: repo, + groupRepo: groupRepo, + cfg: testConfig(), + } + + account, err := svc.SelectAccountForModelWithExclusions(ctx, &groupID, "", "claude-3-5-sonnet-20241022", nil) + require.NoError(t, err) + require.NotNil(t, account) + require.Equal(t, 0, groupRepo.getByIDCalls) + require.Equal(t, 0, groupRepo.getByIDLiteCalls) +} + +func TestGatewayService_GroupResolution_IgnoresInvalidContextGroup(t *testing.T) { + ctx := context.Background() + groupID := int64(42) + ctxGroup := &Group{ + ID: groupID, + Platform: PlatformAnthropic, + Status: StatusActive, + } + ctx = context.WithValue(ctx, ctxkey.Group, ctxGroup) + + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + {ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true}, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + group := &Group{ + ID: groupID, + Platform: PlatformAnthropic, + Status: StatusActive, + } + groupRepo := &mockGroupRepoForGateway{ + groups: map[int64]*Group{groupID: group}, + } + + svc := &GatewayService{ + accountRepo: repo, + groupRepo: groupRepo, + cfg: testConfig(), + } + + account, err := svc.SelectAccountForModelWithExclusions(ctx, &groupID, "", "claude-3-5-sonnet-20241022", nil) + require.NoError(t, err) + require.NotNil(t, account) + require.Equal(t, 0, groupRepo.getByIDCalls) + require.Equal(t, 1, groupRepo.getByIDLiteCalls) +} + +func TestGatewayService_GroupContext_OverwritesInvalidContextGroup(t *testing.T) { + groupID := int64(42) + invalidGroup := &Group{ + ID: groupID, + Platform: PlatformAnthropic, + Status: StatusActive, + } + hydratedGroup := &Group{ + ID: groupID, + Platform: PlatformAnthropic, + Status: StatusActive, + Hydrated: true, + } + + ctx := context.WithValue(context.Background(), ctxkey.Group, invalidGroup) + svc := &GatewayService{} + ctx = svc.withGroupContext(ctx, hydratedGroup) + + got, ok := ctx.Value(ctxkey.Group).(*Group) + require.True(t, ok) + require.Same(t, hydratedGroup, got) +} + +func TestGatewayService_GroupResolution_FallbackUsesLiteOnce(t *testing.T) { + ctx := context.Background() + groupID := int64(10) + fallbackID := int64(11) + group := &Group{ + ID: groupID, + Platform: PlatformAnthropic, + Status: StatusActive, + ClaudeCodeOnly: true, + FallbackGroupID: &fallbackID, + Hydrated: true, + } + fallbackGroup := &Group{ + ID: fallbackID, + Platform: PlatformAnthropic, + Status: StatusActive, + } + ctx = context.WithValue(ctx, ctxkey.Group, group) + + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + {ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true}, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + groupRepo := &mockGroupRepoForGateway{ + groups: map[int64]*Group{fallbackID: fallbackGroup}, + } + + svc := &GatewayService{ + accountRepo: repo, + groupRepo: groupRepo, + cfg: testConfig(), + } + + account, err := svc.SelectAccountForModelWithExclusions(ctx, &groupID, "", "claude-3-5-sonnet-20241022", nil) + require.NoError(t, err) + require.NotNil(t, account) + require.Equal(t, 0, groupRepo.getByIDCalls) + require.Equal(t, 1, groupRepo.getByIDLiteCalls) +} + +func TestGatewayService_ResolveGatewayGroup_DetectsFallbackCycle(t *testing.T) { + ctx := context.Background() + groupID := int64(10) + fallbackID := int64(11) + + group := &Group{ + ID: groupID, + Platform: PlatformAnthropic, + Status: StatusActive, + ClaudeCodeOnly: true, + FallbackGroupID: &fallbackID, + } + fallbackGroup := &Group{ + ID: fallbackID, + Platform: PlatformAnthropic, + Status: StatusActive, + ClaudeCodeOnly: true, + FallbackGroupID: &groupID, + } + + groupRepo := &mockGroupRepoForGateway{ + groups: map[int64]*Group{ + groupID: group, + fallbackID: fallbackGroup, + }, + } + + svc := &GatewayService{ + groupRepo: groupRepo, + } + + gotGroup, gotID, err := svc.resolveGatewayGroup(ctx, &groupID) + require.Error(t, err) + require.Nil(t, gotGroup) + require.Nil(t, gotID) + require.Contains(t, err.Error(), "fallback group cycle") +} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index e73e9406..31148b17 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -33,7 +33,7 @@ const ( claudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true" claudeAPICountTokensURL = "https://api.anthropic.com/v1/messages/count_tokens?beta=true" stickySessionTTL = time.Hour // 粘性会话TTL - defaultMaxLineSize = 10 * 1024 * 1024 + defaultMaxLineSize = 40 * 1024 * 1024 claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude." maxCacheControlBlocks = 4 // Anthropic API 允许的最大 cache_control 块数量 ) @@ -361,27 +361,13 @@ func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context if hasForcePlatform && forcePlatform != "" { platform = forcePlatform } else if groupID != nil { - // 根据分组 platform 决定查询哪种账号 - group, err := s.groupRepo.GetByID(ctx, *groupID) + group, resolvedGroupID, err := s.resolveGatewayGroup(ctx, groupID) if err != nil { - return nil, fmt.Errorf("get group failed: %w", err) + return nil, err } + groupID = resolvedGroupID + ctx = s.withGroupContext(ctx, group) platform = group.Platform - - // 检查 Claude Code 客户端限制 - if group.ClaudeCodeOnly { - isClaudeCode := IsClaudeCodeClient(ctx) - if !isClaudeCode { - // 非 Claude Code 客户端,检查是否有降级分组 - if group.FallbackGroupID != nil { - // 使用降级分组重新调度 - fallbackGroupID := *group.FallbackGroupID - return s.SelectAccountForModelWithExclusions(ctx, &fallbackGroupID, sessionHash, requestedModel, excludedIDs) - } - // 无降级分组,拒绝访问 - return nil, ErrClaudeCodeOnly - } - } } else { // 无分组时只使用原生 anthropic 平台 platform = PlatformAnthropic @@ -409,10 +395,11 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro } // 检查 Claude Code 客户端限制(可能会替换 groupID 为降级分组) - groupID, err := s.checkClaudeCodeRestriction(ctx, groupID) + group, groupID, err := s.checkClaudeCodeRestriction(ctx, groupID) if err != nil { return nil, err } + ctx = s.withGroupContext(ctx, group) if s.concurrencyService == nil || !cfg.LoadBatchEnabled { account, err := s.SelectAccountForModelWithExclusions(ctx, groupID, sessionHash, requestedModel, excludedIDs) @@ -452,7 +439,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro }, nil } - platform, hasForcePlatform, err := s.resolvePlatform(ctx, groupID) + platform, hasForcePlatform, err := s.resolvePlatform(ctx, groupID, group) if err != nil { return nil, err } @@ -478,10 +465,15 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro if sessionHash != "" && s.cache != nil { accountID, err := s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), sessionHash) if err == nil && accountID > 0 && !isExcluded(accountID) { - account, err := s.accountRepo.GetByID(ctx, accountID) - if err == nil && s.isAccountInGroup(account, groupID) && + // 粘性命中仅在当前可调度候选集中生效。 + accountByID := make(map[int64]*Account, len(accounts)) + for i := range accounts { + accountByID[accounts[i].ID] = &accounts[i] + } + account, ok := accountByID[accountID] + if ok && s.isAccountInGroup(account, groupID) && s.isAccountAllowedForPlatform(account, platform, useMixed) && - account.IsSchedulable() && + account.IsSchedulableForModel(requestedModel) && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) { result, err := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency) if err == nil && result.Acquired { @@ -519,6 +511,9 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro if !s.isAccountAllowedForPlatform(acc, platform, useMixed) { continue } + if !acc.IsSchedulableForModel(requestedModel) { + continue + } if requestedModel != "" && !s.isModelSupportedByAccount(acc, requestedModel) { continue } @@ -652,51 +647,97 @@ func (s *GatewayService) schedulingConfig() config.GatewaySchedulingConfig { } } +func (s *GatewayService) withGroupContext(ctx context.Context, group *Group) context.Context { + if !IsGroupContextValid(group) { + return ctx + } + if existing, ok := ctx.Value(ctxkey.Group).(*Group); ok && existing != nil && existing.ID == group.ID && IsGroupContextValid(existing) { + return ctx + } + return context.WithValue(ctx, ctxkey.Group, group) +} + +func (s *GatewayService) groupFromContext(ctx context.Context, groupID int64) *Group { + if group, ok := ctx.Value(ctxkey.Group).(*Group); ok && IsGroupContextValid(group) && group.ID == groupID { + return group + } + return nil +} + +func (s *GatewayService) resolveGroupByID(ctx context.Context, groupID int64) (*Group, error) { + if group := s.groupFromContext(ctx, groupID); group != nil { + return group, nil + } + group, err := s.groupRepo.GetByIDLite(ctx, groupID) + if err != nil { + return nil, fmt.Errorf("get group failed: %w", err) + } + return group, nil +} + +func (s *GatewayService) resolveGatewayGroup(ctx context.Context, groupID *int64) (*Group, *int64, error) { + if groupID == nil { + return nil, nil, nil + } + + currentID := *groupID + visited := map[int64]struct{}{} + for { + if _, seen := visited[currentID]; seen { + return nil, nil, fmt.Errorf("fallback group cycle detected") + } + visited[currentID] = struct{}{} + + group, err := s.resolveGroupByID(ctx, currentID) + if err != nil { + return nil, nil, err + } + + if !group.ClaudeCodeOnly || IsClaudeCodeClient(ctx) { + return group, ¤tID, nil + } + + if group.FallbackGroupID == nil { + return nil, nil, ErrClaudeCodeOnly + } + currentID = *group.FallbackGroupID + } +} + // checkClaudeCodeRestriction 检查分组的 Claude Code 客户端限制 // 如果分组启用了 claude_code_only 且请求不是来自 Claude Code 客户端: // - 有降级分组:返回降级分组的 ID // - 无降级分组:返回 ErrClaudeCodeOnly 错误 -func (s *GatewayService) checkClaudeCodeRestriction(ctx context.Context, groupID *int64) (*int64, error) { +func (s *GatewayService) checkClaudeCodeRestriction(ctx context.Context, groupID *int64) (*Group, *int64, error) { if groupID == nil { - return groupID, nil + return nil, groupID, nil } // 强制平台模式不检查 Claude Code 限制 if _, hasForcePlatform := ctx.Value(ctxkey.ForcePlatform).(string); hasForcePlatform { - return groupID, nil + return nil, groupID, nil } - group, err := s.groupRepo.GetByID(ctx, *groupID) + group, resolvedID, err := s.resolveGatewayGroup(ctx, groupID) if err != nil { - return nil, fmt.Errorf("get group failed: %w", err) + return nil, nil, err } - if !group.ClaudeCodeOnly { - return groupID, nil - } - - // 分组启用了 Claude Code 限制 - if IsClaudeCodeClient(ctx) { - return groupID, nil - } - - // 非 Claude Code 客户端,检查降级分组 - if group.FallbackGroupID != nil { - return group.FallbackGroupID, nil - } - - return nil, ErrClaudeCodeOnly + return group, resolvedID, nil } -func (s *GatewayService) resolvePlatform(ctx context.Context, groupID *int64) (string, bool, error) { +func (s *GatewayService) resolvePlatform(ctx context.Context, groupID *int64, group *Group) (string, bool, error) { forcePlatform, hasForcePlatform := ctx.Value(ctxkey.ForcePlatform).(string) if hasForcePlatform && forcePlatform != "" { return forcePlatform, true, nil } + if group != nil { + return group.Platform, false, nil + } if groupID != nil { - group, err := s.groupRepo.GetByID(ctx, *groupID) + group, err := s.resolveGroupByID(ctx, *groupID) if err != nil { - return "", false, fmt.Errorf("get group failed: %w", err) + return "", false, err } return group.Platform, false, nil } @@ -812,7 +853,7 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context, if _, excluded := excludedIDs[accountID]; !excluded { account, err := s.accountRepo.GetByID(ctx, accountID) // 检查账号分组归属和平台匹配(确保粘性会话不会跨分组或跨平台) - if err == nil && s.isAccountInGroup(account, groupID) && account.Platform == platform && account.IsSchedulable() && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) { + if err == nil && s.isAccountInGroup(account, groupID) && account.Platform == platform && account.IsSchedulableForModel(requestedModel) && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) { if err := s.cache.RefreshSessionTTL(ctx, derefGroupID(groupID), sessionHash, stickySessionTTL); err != nil { log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err) } @@ -844,6 +885,9 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context, if _, excluded := excludedIDs[acc.ID]; excluded { continue } + if !acc.IsSchedulableForModel(requestedModel) { + continue + } if requestedModel != "" && !s.isModelSupportedByAccount(acc, requestedModel) { continue } @@ -901,7 +945,7 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g if _, excluded := excludedIDs[accountID]; !excluded { account, err := s.accountRepo.GetByID(ctx, accountID) // 检查账号分组归属和有效性:原生平台直接匹配,antigravity 需要启用混合调度 - if err == nil && s.isAccountInGroup(account, groupID) && account.IsSchedulable() && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) { + if err == nil && s.isAccountInGroup(account, groupID) && account.IsSchedulableForModel(requestedModel) && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) { if account.Platform == nativePlatform || (account.Platform == PlatformAntigravity && account.IsMixedSchedulingEnabled()) { if err := s.cache.RefreshSessionTTL(ctx, derefGroupID(groupID), sessionHash, stickySessionTTL); err != nil { log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err) @@ -936,6 +980,9 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g if acc.Platform == PlatformAntigravity && !acc.IsMixedSchedulingEnabled() { continue } + if !acc.IsSchedulableForModel(requestedModel) { + continue + } if requestedModel != "" && !s.isModelSupportedByAccount(acc, requestedModel) { continue } @@ -2247,6 +2294,7 @@ type RecordUsageInput struct { Account *Account Subscription *UserSubscription // 可选:订阅信息 UserAgent string // 请求的 User-Agent + IPAddress string // 请求的客户端 IP 地址 } // RecordUsage 记录使用量并扣费(或更新订阅用量) @@ -2337,6 +2385,11 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu usageLog.UserAgent = &input.UserAgent } + // 添加 IPAddress + if input.IPAddress != "" { + usageLog.IPAddress = &input.IPAddress + } + // 添加分组和订阅关联 if apiKey.GroupID != nil { usageLog.GroupID = apiKey.GroupID diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index f2b5bafd..78452b1e 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -86,9 +86,15 @@ func (s *GeminiMessagesCompatService) SelectAccountForModelWithExclusions(ctx co platform = forcePlatform } else if groupID != nil { // 根据分组 platform 决定查询哪种账号 - group, err := s.groupRepo.GetByID(ctx, *groupID) - if err != nil { - return nil, fmt.Errorf("get group failed: %w", err) + var group *Group + if ctxGroup, ok := ctx.Value(ctxkey.Group).(*Group); ok && IsGroupContextValid(ctxGroup) && ctxGroup.ID == *groupID { + group = ctxGroup + } else { + var err error + group, err = s.groupRepo.GetByIDLite(ctx, *groupID) + if err != nil { + return nil, fmt.Errorf("get group failed: %w", err) + } } platform = group.Platform } else { @@ -114,7 +120,7 @@ func (s *GeminiMessagesCompatService) SelectAccountForModelWithExclusions(ctx co if _, excluded := excludedIDs[accountID]; !excluded { account, err := s.accountRepo.GetByID(ctx, accountID) // 检查账号是否有效:原生平台直接匹配,antigravity 需要启用混合调度 - if err == nil && account.IsSchedulable() && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) { + if err == nil && account.IsSchedulableForModel(requestedModel) && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) { valid := false if account.Platform == platform { valid = true @@ -172,6 +178,9 @@ func (s *GeminiMessagesCompatService) SelectAccountForModelWithExclusions(ctx co if useMixedScheduling && acc.Platform == PlatformAntigravity && !acc.IsMixedSchedulingEnabled() { continue } + if !acc.IsSchedulableForModel(requestedModel) { + continue + } if requestedModel != "" && !s.isModelSupportedByAccount(acc, requestedModel) { continue } diff --git a/backend/internal/service/gemini_multiplatform_test.go b/backend/internal/service/gemini_multiplatform_test.go index 6007bce8..c99cb87d 100644 --- a/backend/internal/service/gemini_multiplatform_test.go +++ b/backend/internal/service/gemini_multiplatform_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/stretchr/testify/require" ) @@ -121,6 +122,9 @@ func (m *mockAccountRepoForGemini) ListSchedulableByGroupIDAndPlatforms(ctx cont func (m *mockAccountRepoForGemini) SetRateLimited(ctx context.Context, id int64, resetAt time.Time) error { return nil } +func (m *mockAccountRepoForGemini) SetAntigravityQuotaScopeLimit(ctx context.Context, id int64, scope AntigravityQuotaScope, resetAt time.Time) error { + return nil +} func (m *mockAccountRepoForGemini) SetOverloaded(ctx context.Context, id int64, until time.Time) error { return nil } @@ -131,6 +135,9 @@ func (m *mockAccountRepoForGemini) ClearTempUnschedulable(ctx context.Context, i return nil } func (m *mockAccountRepoForGemini) ClearRateLimit(ctx context.Context, id int64) error { return nil } +func (m *mockAccountRepoForGemini) ClearAntigravityQuotaScopes(ctx context.Context, id int64) error { + return nil +} func (m *mockAccountRepoForGemini) UpdateSessionWindow(ctx context.Context, id int64, start, end *time.Time, status string) error { return nil } @@ -146,10 +153,21 @@ var _ AccountRepository = (*mockAccountRepoForGemini)(nil) // mockGroupRepoForGemini Gemini 测试用的 group repo mock type mockGroupRepoForGemini struct { - groups map[int64]*Group + groups map[int64]*Group + getByIDCalls int + getByIDLiteCalls int } func (m *mockGroupRepoForGemini) GetByID(ctx context.Context, id int64) (*Group, error) { + m.getByIDCalls++ + if g, ok := m.groups[id]; ok { + return g, nil + } + return nil, errors.New("group not found") +} + +func (m *mockGroupRepoForGemini) GetByIDLite(ctx context.Context, id int64) (*Group, error) { + m.getByIDLiteCalls++ if g, ok := m.groups[id]; ok { return g, nil } @@ -166,7 +184,7 @@ func (m *mockGroupRepoForGemini) DeleteCascade(ctx context.Context, id int64) ([ func (m *mockGroupRepoForGemini) List(ctx context.Context, params pagination.PaginationParams) ([]Group, *pagination.PaginationResult, error) { return nil, nil, nil } -func (m *mockGroupRepoForGemini) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, status string, isExclusive *bool) ([]Group, *pagination.PaginationResult, error) { +func (m *mockGroupRepoForGemini) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, status, search string, isExclusive *bool) ([]Group, *pagination.PaginationResult, error) { return nil, nil, nil } func (m *mockGroupRepoForGemini) ListActive(ctx context.Context) ([]Group, error) { return nil, nil } @@ -242,6 +260,77 @@ func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiP require.Equal(t, PlatformGemini, acc.Platform, "无分组时应只返回 gemini 平台账户") } +func TestGeminiMessagesCompatService_GroupResolution_ReusesContextGroup(t *testing.T) { + ctx := context.Background() + groupID := int64(7) + group := &Group{ + ID: groupID, + Platform: PlatformGemini, + Status: StatusActive, + Hydrated: true, + } + ctx = context.WithValue(ctx, ctxkey.Group, group) + + repo := &mockAccountRepoForGemini{ + accounts: []Account{ + {ID: 1, Platform: PlatformGemini, Priority: 1, Status: StatusActive, Schedulable: true}, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + cache := &mockGatewayCacheForGemini{} + groupRepo := &mockGroupRepoForGemini{groups: map[int64]*Group{}} + + svc := &GeminiMessagesCompatService{ + accountRepo: repo, + groupRepo: groupRepo, + cache: cache, + } + + acc, err := svc.SelectAccountForModelWithExclusions(ctx, &groupID, "", "gemini-2.5-flash", nil) + require.NoError(t, err) + require.NotNil(t, acc) + require.Equal(t, 0, groupRepo.getByIDCalls) + require.Equal(t, 0, groupRepo.getByIDLiteCalls) +} + +func TestGeminiMessagesCompatService_GroupResolution_UsesLiteFetch(t *testing.T) { + ctx := context.Background() + groupID := int64(7) + + repo := &mockAccountRepoForGemini{ + accounts: []Account{ + {ID: 1, Platform: PlatformGemini, Priority: 1, Status: StatusActive, Schedulable: true}, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + cache := &mockGatewayCacheForGemini{} + groupRepo := &mockGroupRepoForGemini{ + groups: map[int64]*Group{ + groupID: {ID: groupID, Platform: PlatformGemini}, + }, + } + + svc := &GeminiMessagesCompatService{ + accountRepo: repo, + groupRepo: groupRepo, + cache: cache, + } + + acc, err := svc.SelectAccountForModelWithExclusions(ctx, &groupID, "", "gemini-2.5-flash", nil) + require.NoError(t, err) + require.NotNil(t, acc) + require.Equal(t, 0, groupRepo.getByIDCalls) + require.Equal(t, 1, groupRepo.getByIDLiteCalls) +} + // TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_AntigravityGroup 测试 antigravity 分组 func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_AntigravityGroup(t *testing.T) { ctx := context.Background() diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index 80d89074..8e8d47d6 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -10,6 +10,7 @@ type Group struct { RateMultiplier float64 IsExclusive bool Status string + Hydrated bool // indicates the group was loaded from a trusted repository source SubscriptionType string DailyLimitUSD *float64 @@ -72,3 +73,20 @@ func (g *Group) GetImagePrice(imageSize string) *float64 { return g.ImagePrice2K } } + +// IsGroupContextValid reports whether a group from context has the fields required for routing decisions. +func IsGroupContextValid(group *Group) bool { + if group == nil { + return false + } + if group.ID <= 0 { + return false + } + if !group.Hydrated { + return false + } + if group.Platform == "" || group.Status == "" { + return false + } + return true +} diff --git a/backend/internal/service/group_service.go b/backend/internal/service/group_service.go index 403636e8..2f0f4975 100644 --- a/backend/internal/service/group_service.go +++ b/backend/internal/service/group_service.go @@ -16,12 +16,13 @@ var ( type GroupRepository interface { Create(ctx context.Context, group *Group) error GetByID(ctx context.Context, id int64) (*Group, error) + GetByIDLite(ctx context.Context, id int64) (*Group, error) Update(ctx context.Context, group *Group) error Delete(ctx context.Context, id int64) error DeleteCascade(ctx context.Context, id int64) ([]int64, error) List(ctx context.Context, params pagination.PaginationParams) ([]Group, *pagination.PaginationResult, error) - ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, status string, isExclusive *bool) ([]Group, *pagination.PaginationResult, error) + ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, status, search string, isExclusive *bool) ([]Group, *pagination.PaginationResult, error) ListActive(ctx context.Context) ([]Group, error) ListActiveByPlatform(ctx context.Context, platform string) ([]Group, error) diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index fc9d30cd..3514dc7a 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -455,6 +455,10 @@ func getOpenCodeCodexHeader() string { return getOpenCodeCachedPrompt(opencodeCodexHeaderURL, "opencode-codex-header.txt", "opencode-codex-header-meta.json") } +func GetOpenCodeInstructions() string { + return getOpenCodeCodexHeader() +} + func filterCodexInput(input []any) []any { filtered := make([]any, 0, len(input)) for _, item := range input { diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 76aaa6cd..8b1f214b 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -1251,6 +1251,7 @@ type OpenAIRecordUsageInput struct { Account *Account Subscription *UserSubscription UserAgent string // 请求的 User-Agent + IPAddress string // 请求的客户端 IP 地址 } // RecordUsage records usage and deducts balance @@ -1325,6 +1326,11 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec usageLog.UserAgent = &input.UserAgent } + // 添加 IPAddress + if input.IPAddress != "" { + usageLog.IPAddress = &input.IPAddress + } + if apiKey.GroupID != nil { usageLog.GroupID = apiKey.GroupID } diff --git a/backend/internal/service/promo_code.go b/backend/internal/service/promo_code.go new file mode 100644 index 00000000..94e733a8 --- /dev/null +++ b/backend/internal/service/promo_code.go @@ -0,0 +1,73 @@ +package service + +import ( + "time" +) + +// PromoCode 注册优惠码 +type PromoCode struct { + ID int64 + Code string + BonusAmount float64 + MaxUses int + UsedCount int + Status string + ExpiresAt *time.Time + Notes string + CreatedAt time.Time + UpdatedAt time.Time + + // 关联 + UsageRecords []PromoCodeUsage +} + +// PromoCodeUsage 优惠码使用记录 +type PromoCodeUsage struct { + ID int64 + PromoCodeID int64 + UserID int64 + BonusAmount float64 + UsedAt time.Time + + // 关联 + PromoCode *PromoCode + User *User +} + +// CanUse 检查优惠码是否可用 +func (p *PromoCode) CanUse() bool { + if p.Status != PromoCodeStatusActive { + return false + } + if p.ExpiresAt != nil && time.Now().After(*p.ExpiresAt) { + return false + } + if p.MaxUses > 0 && p.UsedCount >= p.MaxUses { + return false + } + return true +} + +// IsExpired 检查是否已过期 +func (p *PromoCode) IsExpired() bool { + return p.ExpiresAt != nil && time.Now().After(*p.ExpiresAt) +} + +// CreatePromoCodeInput 创建优惠码输入 +type CreatePromoCodeInput struct { + Code string + BonusAmount float64 + MaxUses int + ExpiresAt *time.Time + Notes string +} + +// UpdatePromoCodeInput 更新优惠码输入 +type UpdatePromoCodeInput struct { + Code *string + BonusAmount *float64 + MaxUses *int + Status *string + ExpiresAt *time.Time + Notes *string +} diff --git a/backend/internal/service/promo_code_repository.go b/backend/internal/service/promo_code_repository.go new file mode 100644 index 00000000..f55f9a6b --- /dev/null +++ b/backend/internal/service/promo_code_repository.go @@ -0,0 +1,30 @@ +package service + +import ( + "context" + + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" +) + +// PromoCodeRepository 优惠码仓储接口 +type PromoCodeRepository interface { + // 基础 CRUD + Create(ctx context.Context, code *PromoCode) error + GetByID(ctx context.Context, id int64) (*PromoCode, error) + GetByCode(ctx context.Context, code string) (*PromoCode, error) + GetByCodeForUpdate(ctx context.Context, code string) (*PromoCode, error) // 带行锁的查询,用于并发控制 + Update(ctx context.Context, code *PromoCode) error + Delete(ctx context.Context, id int64) error + + // 列表查询 + List(ctx context.Context, params pagination.PaginationParams) ([]PromoCode, *pagination.PaginationResult, error) + ListWithFilters(ctx context.Context, params pagination.PaginationParams, status, search string) ([]PromoCode, *pagination.PaginationResult, error) + + // 使用记录 + CreateUsage(ctx context.Context, usage *PromoCodeUsage) error + GetUsageByPromoCodeAndUser(ctx context.Context, promoCodeID, userID int64) (*PromoCodeUsage, error) + ListUsagesByPromoCode(ctx context.Context, promoCodeID int64, params pagination.PaginationParams) ([]PromoCodeUsage, *pagination.PaginationResult, error) + + // 计数操作 + IncrementUsedCount(ctx context.Context, id int64) error +} diff --git a/backend/internal/service/promo_service.go b/backend/internal/service/promo_service.go new file mode 100644 index 00000000..9acd5868 --- /dev/null +++ b/backend/internal/service/promo_service.go @@ -0,0 +1,256 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "strings" + "time" + + dbent "github.com/Wei-Shaw/sub2api/ent" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" +) + +var ( + ErrPromoCodeNotFound = infraerrors.NotFound("PROMO_CODE_NOT_FOUND", "promo code not found") + ErrPromoCodeExpired = infraerrors.BadRequest("PROMO_CODE_EXPIRED", "promo code has expired") + ErrPromoCodeDisabled = infraerrors.BadRequest("PROMO_CODE_DISABLED", "promo code is disabled") + ErrPromoCodeMaxUsed = infraerrors.BadRequest("PROMO_CODE_MAX_USED", "promo code has reached maximum uses") + ErrPromoCodeAlreadyUsed = infraerrors.Conflict("PROMO_CODE_ALREADY_USED", "you have already used this promo code") + ErrPromoCodeInvalid = infraerrors.BadRequest("PROMO_CODE_INVALID", "invalid promo code") +) + +// PromoService 优惠码服务 +type PromoService struct { + promoRepo PromoCodeRepository + userRepo UserRepository + billingCacheService *BillingCacheService + entClient *dbent.Client +} + +// NewPromoService 创建优惠码服务实例 +func NewPromoService( + promoRepo PromoCodeRepository, + userRepo UserRepository, + billingCacheService *BillingCacheService, + entClient *dbent.Client, +) *PromoService { + return &PromoService{ + promoRepo: promoRepo, + userRepo: userRepo, + billingCacheService: billingCacheService, + entClient: entClient, + } +} + +// ValidatePromoCode 验证优惠码(注册前调用) +// 返回 nil, nil 表示空码(不报错) +func (s *PromoService) ValidatePromoCode(ctx context.Context, code string) (*PromoCode, error) { + code = strings.TrimSpace(code) + if code == "" { + return nil, nil // 空码不报错,直接返回 + } + + promoCode, err := s.promoRepo.GetByCode(ctx, code) + if err != nil { + // 保留原始错误类型,不要统一映射为 NotFound + return nil, err + } + + if err := s.validatePromoCodeStatus(promoCode); err != nil { + return nil, err + } + + return promoCode, nil +} + +// validatePromoCodeStatus 验证优惠码状态 +func (s *PromoService) validatePromoCodeStatus(promoCode *PromoCode) error { + if !promoCode.CanUse() { + if promoCode.IsExpired() { + return ErrPromoCodeExpired + } + if promoCode.Status == PromoCodeStatusDisabled { + return ErrPromoCodeDisabled + } + if promoCode.MaxUses > 0 && promoCode.UsedCount >= promoCode.MaxUses { + return ErrPromoCodeMaxUsed + } + return ErrPromoCodeInvalid + } + return nil +} + +// ApplyPromoCode 应用优惠码(注册成功后调用) +// 使用事务和行锁确保并发安全 +func (s *PromoService) ApplyPromoCode(ctx context.Context, userID int64, code string) error { + code = strings.TrimSpace(code) + if code == "" { + return nil + } + + // 开启事务 + tx, err := s.entClient.Tx(ctx) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + txCtx := dbent.NewTxContext(ctx, tx) + + // 在事务中获取并锁定优惠码记录(FOR UPDATE) + promoCode, err := s.promoRepo.GetByCodeForUpdate(txCtx, code) + if err != nil { + return err + } + + // 在事务中验证优惠码状态 + if err := s.validatePromoCodeStatus(promoCode); err != nil { + return err + } + + // 在事务中检查用户是否已使用过此优惠码 + existing, err := s.promoRepo.GetUsageByPromoCodeAndUser(txCtx, promoCode.ID, userID) + if err != nil { + return fmt.Errorf("check existing usage: %w", err) + } + if existing != nil { + return ErrPromoCodeAlreadyUsed + } + + // 增加用户余额 + if err := s.userRepo.UpdateBalance(txCtx, userID, promoCode.BonusAmount); err != nil { + return fmt.Errorf("update user balance: %w", err) + } + + // 创建使用记录 + usage := &PromoCodeUsage{ + PromoCodeID: promoCode.ID, + UserID: userID, + BonusAmount: promoCode.BonusAmount, + UsedAt: time.Now(), + } + if err := s.promoRepo.CreateUsage(txCtx, usage); err != nil { + return fmt.Errorf("create usage record: %w", err) + } + + // 增加使用次数 + if err := s.promoRepo.IncrementUsedCount(txCtx, promoCode.ID); err != nil { + return fmt.Errorf("increment used count: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + + // 失效余额缓存 + if s.billingCacheService != nil { + go func() { + cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = s.billingCacheService.InvalidateUserBalance(cacheCtx, userID) + }() + } + + return nil +} + +// GenerateRandomCode 生成随机优惠码 +func (s *PromoService) GenerateRandomCode() (string, error) { + bytes := make([]byte, 8) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("generate random bytes: %w", err) + } + return strings.ToUpper(hex.EncodeToString(bytes)), nil +} + +// Create 创建优惠码 +func (s *PromoService) Create(ctx context.Context, input *CreatePromoCodeInput) (*PromoCode, error) { + code := strings.TrimSpace(input.Code) + if code == "" { + // 自动生成 + var err error + code, err = s.GenerateRandomCode() + if err != nil { + return nil, err + } + } + + promoCode := &PromoCode{ + Code: strings.ToUpper(code), + BonusAmount: input.BonusAmount, + MaxUses: input.MaxUses, + UsedCount: 0, + Status: PromoCodeStatusActive, + ExpiresAt: input.ExpiresAt, + Notes: input.Notes, + } + + if err := s.promoRepo.Create(ctx, promoCode); err != nil { + return nil, fmt.Errorf("create promo code: %w", err) + } + + return promoCode, nil +} + +// GetByID 根据ID获取优惠码 +func (s *PromoService) GetByID(ctx context.Context, id int64) (*PromoCode, error) { + code, err := s.promoRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + return code, nil +} + +// Update 更新优惠码 +func (s *PromoService) Update(ctx context.Context, id int64, input *UpdatePromoCodeInput) (*PromoCode, error) { + promoCode, err := s.promoRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + if input.Code != nil { + promoCode.Code = strings.ToUpper(strings.TrimSpace(*input.Code)) + } + if input.BonusAmount != nil { + promoCode.BonusAmount = *input.BonusAmount + } + if input.MaxUses != nil { + promoCode.MaxUses = *input.MaxUses + } + if input.Status != nil { + promoCode.Status = *input.Status + } + if input.ExpiresAt != nil { + promoCode.ExpiresAt = input.ExpiresAt + } + if input.Notes != nil { + promoCode.Notes = *input.Notes + } + + if err := s.promoRepo.Update(ctx, promoCode); err != nil { + return nil, fmt.Errorf("update promo code: %w", err) + } + + return promoCode, nil +} + +// Delete 删除优惠码 +func (s *PromoService) Delete(ctx context.Context, id int64) error { + if err := s.promoRepo.Delete(ctx, id); err != nil { + return fmt.Errorf("delete promo code: %w", err) + } + return nil +} + +// List 获取优惠码列表 +func (s *PromoService) List(ctx context.Context, params pagination.PaginationParams, status, search string) ([]PromoCode, *pagination.PaginationResult, error) { + return s.promoRepo.ListWithFilters(ctx, params, status, search) +} + +// ListUsages 获取使用记录 +func (s *PromoService) ListUsages(ctx context.Context, promoCodeID int64, params pagination.PaginationParams) ([]PromoCodeUsage, *pagination.PaginationResult, error) { + return s.promoRepo.ListUsagesByPromoCode(ctx, promoCodeID, params) +} diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 196f1643..f1362646 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -345,7 +345,7 @@ func (s *RateLimitService) UpdateSessionWindow(ctx context.Context, account *Acc // 如果状态为allowed且之前有限流,说明窗口已重置,清除限流状态 if status == "allowed" && account.IsRateLimited() { - if err := s.accountRepo.ClearRateLimit(ctx, account.ID); err != nil { + if err := s.ClearRateLimit(ctx, account.ID); err != nil { log.Printf("ClearRateLimit failed for account %d: %v", account.ID, err) } } @@ -353,7 +353,10 @@ func (s *RateLimitService) UpdateSessionWindow(ctx context.Context, account *Acc // ClearRateLimit 清除账号的限流状态 func (s *RateLimitService) ClearRateLimit(ctx context.Context, accountID int64) error { - return s.accountRepo.ClearRateLimit(ctx, accountID) + if err := s.accountRepo.ClearRateLimit(ctx, accountID); err != nil { + return err + } + return s.accountRepo.ClearAntigravityQuotaScopes(ctx, accountID) } func (s *RateLimitService) ClearTempUnschedulable(ctx context.Context, accountID int64) error { diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 6ce8ba2b..d25698de 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "strconv" + "strings" "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" @@ -64,6 +65,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyAPIBaseURL, SettingKeyContactInfo, SettingKeyDocURL, + SettingKeyLinuxDoConnectEnabled, } settings, err := s.settingRepo.GetMultiple(ctx, keys) @@ -71,6 +73,13 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings return nil, fmt.Errorf("get public settings: %w", err) } + linuxDoEnabled := false + if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok { + linuxDoEnabled = raw == "true" + } else { + linuxDoEnabled = s.cfg != nil && s.cfg.LinuxDo.Enabled + } + return &PublicSettings{ RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", EmailVerifyEnabled: settings[SettingKeyEmailVerifyEnabled] == "true", @@ -82,6 +91,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings APIBaseURL: settings[SettingKeyAPIBaseURL], ContactInfo: settings[SettingKeyContactInfo], DocURL: settings[SettingKeyDocURL], + LinuxDoOAuthEnabled: linuxDoEnabled, }, nil } @@ -111,6 +121,14 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet updates[SettingKeyTurnstileSecretKey] = settings.TurnstileSecretKey } + // LinuxDo Connect OAuth 登录(终端用户 SSO) + updates[SettingKeyLinuxDoConnectEnabled] = strconv.FormatBool(settings.LinuxDoConnectEnabled) + updates[SettingKeyLinuxDoConnectClientID] = settings.LinuxDoConnectClientID + updates[SettingKeyLinuxDoConnectRedirectURL] = settings.LinuxDoConnectRedirectURL + if settings.LinuxDoConnectClientSecret != "" { + updates[SettingKeyLinuxDoConnectClientSecret] = settings.LinuxDoConnectClientSecret + } + // OEM设置 updates[SettingKeySiteName] = settings.SiteName updates[SettingKeySiteLogo] = settings.SiteLogo @@ -141,8 +159,8 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet func (s *SettingService) IsRegistrationEnabled(ctx context.Context) bool { value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled) if err != nil { - // 默认开放注册 - return true + // 安全默认:如果设置不存在或查询出错,默认关闭注册 + return false } return value == "true" } @@ -271,6 +289,38 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin result.SMTPPassword = settings[SettingKeySMTPPassword] result.TurnstileSecretKey = settings[SettingKeyTurnstileSecretKey] + // LinuxDo Connect 设置: + // - 兼容 config.yaml/env(避免老部署因为未迁移到数据库设置而被意外关闭) + // - 支持在后台“系统设置”中覆盖并持久化(存储于 DB) + linuxDoBase := config.LinuxDoConnectConfig{} + if s.cfg != nil { + linuxDoBase = s.cfg.LinuxDo + } + + if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok { + result.LinuxDoConnectEnabled = raw == "true" + } else { + result.LinuxDoConnectEnabled = linuxDoBase.Enabled + } + + if v, ok := settings[SettingKeyLinuxDoConnectClientID]; ok && strings.TrimSpace(v) != "" { + result.LinuxDoConnectClientID = strings.TrimSpace(v) + } else { + result.LinuxDoConnectClientID = linuxDoBase.ClientID + } + + if v, ok := settings[SettingKeyLinuxDoConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { + result.LinuxDoConnectRedirectURL = strings.TrimSpace(v) + } else { + result.LinuxDoConnectRedirectURL = linuxDoBase.RedirectURL + } + + result.LinuxDoConnectClientSecret = strings.TrimSpace(settings[SettingKeyLinuxDoConnectClientSecret]) + if result.LinuxDoConnectClientSecret == "" { + result.LinuxDoConnectClientSecret = strings.TrimSpace(linuxDoBase.ClientSecret) + } + result.LinuxDoConnectClientSecretConfigured = result.LinuxDoConnectClientSecret != "" + // Model fallback settings result.EnableModelFallback = settings[SettingKeyEnableModelFallback] == "true" result.FallbackModelAnthropic = s.getStringOrDefault(settings, SettingKeyFallbackModelAnthropic, "claude-3-5-sonnet-20241022") @@ -289,6 +339,99 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin return result } +// GetLinuxDoConnectOAuthConfig 返回用于登录的“最终生效” LinuxDo Connect 配置。 +// +// 优先级: +// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值 +// - 否则回退到 config.yaml/env 的值 +func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (config.LinuxDoConnectConfig, error) { + if s == nil || s.cfg == nil { + return config.LinuxDoConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") + } + + effective := s.cfg.LinuxDo + + keys := []string{ + SettingKeyLinuxDoConnectEnabled, + SettingKeyLinuxDoConnectClientID, + SettingKeyLinuxDoConnectClientSecret, + SettingKeyLinuxDoConnectRedirectURL, + } + settings, err := s.settingRepo.GetMultiple(ctx, keys) + if err != nil { + return config.LinuxDoConnectConfig{}, fmt.Errorf("get linuxdo connect settings: %w", err) + } + + if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok { + effective.Enabled = raw == "true" + } + if v, ok := settings[SettingKeyLinuxDoConnectClientID]; ok && strings.TrimSpace(v) != "" { + effective.ClientID = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyLinuxDoConnectClientSecret]; ok && strings.TrimSpace(v) != "" { + effective.ClientSecret = strings.TrimSpace(v) + } + if v, ok := settings[SettingKeyLinuxDoConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { + effective.RedirectURL = strings.TrimSpace(v) + } + + if !effective.Enabled { + return config.LinuxDoConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled") + } + + // 基础健壮性校验(避免把用户重定向到一个必然失败或不安全的 OAuth 流程里)。 + if strings.TrimSpace(effective.ClientID) == "" { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured") + } + if strings.TrimSpace(effective.AuthorizeURL) == "" { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url not configured") + } + if strings.TrimSpace(effective.TokenURL) == "" { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url not configured") + } + if strings.TrimSpace(effective.UserInfoURL) == "" { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url not configured") + } + if strings.TrimSpace(effective.RedirectURL) == "" { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured") + } + if strings.TrimSpace(effective.FrontendRedirectURL) == "" { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url not configured") + } + + if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url invalid") + } + if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url invalid") + } + if err := config.ValidateAbsoluteHTTPURL(effective.UserInfoURL); err != nil { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url invalid") + } + if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url invalid") + } + if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid") + } + + method := strings.ToLower(strings.TrimSpace(effective.TokenAuthMethod)) + switch method { + case "", "client_secret_post", "client_secret_basic": + if strings.TrimSpace(effective.ClientSecret) == "" { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured") + } + case "none": + if !effective.UsePKCE { + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth pkce must be enabled when token_auth_method=none") + } + default: + return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid") + } + + return effective, nil +} + // getStringOrDefault 获取字符串值或默认值 func (s *SettingService) getStringOrDefault(settings map[string]string, key, defaultValue string) string { if value, ok := settings[key]; ok && value != "" { diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index de0331f7..26051418 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -18,6 +18,13 @@ type SystemSettings struct { TurnstileSecretKey string TurnstileSecretKeyConfigured bool + // LinuxDo Connect OAuth 登录(终端用户 SSO) + LinuxDoConnectEnabled bool + LinuxDoConnectClientID string + LinuxDoConnectClientSecret string + LinuxDoConnectClientSecretConfigured bool + LinuxDoConnectRedirectURL string + SiteName string SiteLogo string SiteSubtitle string @@ -51,5 +58,6 @@ type PublicSettings struct { APIBaseURL string ContactInfo string DocURL string + LinuxDoOAuthEnabled bool Version string } diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go index 9ecb7098..62d7fae0 100644 --- a/backend/internal/service/usage_log.go +++ b/backend/internal/service/usage_log.go @@ -39,6 +39,7 @@ type UsageLog struct { DurationMs *int FirstTokenMs *int UserAgent *string + IPAddress *string // 图片生成字段 ImageCount int diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index cb73409b..512d2550 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -87,6 +87,7 @@ var ProviderSet = wire.NewSet( NewAccountService, NewProxyService, NewRedeemService, + NewPromoService, NewUsageService, NewDashboardService, ProvidePricingService, diff --git a/backend/migrations/031_add_ip_address.sql b/backend/migrations/031_add_ip_address.sql new file mode 100644 index 00000000..7f557830 --- /dev/null +++ b/backend/migrations/031_add_ip_address.sql @@ -0,0 +1,5 @@ +-- Add IP address field to usage_logs table for request tracking (admin-only visibility) +ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS ip_address VARCHAR(45); + +-- Create index for IP address queries +CREATE INDEX IF NOT EXISTS idx_usage_logs_ip_address ON usage_logs(ip_address); diff --git a/backend/migrations/032_add_api_key_ip_restriction.sql b/backend/migrations/032_add_api_key_ip_restriction.sql new file mode 100644 index 00000000..2dfe2c92 --- /dev/null +++ b/backend/migrations/032_add_api_key_ip_restriction.sql @@ -0,0 +1,9 @@ +-- Add IP restriction fields to api_keys table +-- ip_whitelist: JSON array of allowed IPs/CIDRs (if set, only these IPs can use the key) +-- ip_blacklist: JSON array of blocked IPs/CIDRs (these IPs are always blocked) + +ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS ip_whitelist JSONB DEFAULT NULL; +ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS ip_blacklist JSONB DEFAULT NULL; + +COMMENT ON COLUMN api_keys.ip_whitelist IS 'JSON array of allowed IPs/CIDRs, e.g. ["192.168.1.100", "10.0.0.0/8"]'; +COMMENT ON COLUMN api_keys.ip_blacklist IS 'JSON array of blocked IPs/CIDRs, e.g. ["1.2.3.4", "5.6.0.0/16"]'; diff --git a/backend/migrations/033_add_promo_codes.sql b/backend/migrations/033_add_promo_codes.sql new file mode 100644 index 00000000..7f6ae9a0 --- /dev/null +++ b/backend/migrations/033_add_promo_codes.sql @@ -0,0 +1,34 @@ +-- 创建注册优惠码表 +CREATE TABLE IF NOT EXISTS promo_codes ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(32) NOT NULL UNIQUE, + bonus_amount DECIMAL(20,8) NOT NULL DEFAULT 0, + max_uses INT NOT NULL DEFAULT 0, + used_count INT NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'active', + expires_at TIMESTAMPTZ DEFAULT NULL, + notes TEXT DEFAULT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 创建优惠码使用记录表 +CREATE TABLE IF NOT EXISTS promo_code_usages ( + id BIGSERIAL PRIMARY KEY, + promo_code_id BIGINT NOT NULL REFERENCES promo_codes(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + bonus_amount DECIMAL(20,8) NOT NULL, + used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(promo_code_id, user_id) +); + +-- 索引 +CREATE INDEX IF NOT EXISTS idx_promo_codes_status ON promo_codes(status); +CREATE INDEX IF NOT EXISTS idx_promo_codes_expires_at ON promo_codes(expires_at); +CREATE INDEX IF NOT EXISTS idx_promo_code_usages_promo_code_id ON promo_code_usages(promo_code_id); +CREATE INDEX IF NOT EXISTS idx_promo_code_usages_user_id ON promo_code_usages(user_id); + +COMMENT ON TABLE promo_codes IS '注册优惠码'; +COMMENT ON TABLE promo_code_usages IS '优惠码使用记录'; +COMMENT ON COLUMN promo_codes.max_uses IS '最大使用次数,0表示无限制'; +COMMENT ON COLUMN promo_codes.status IS '状态: active, disabled'; diff --git a/config.yaml b/config.yaml index f43c9c19..54b591f3 100644 --- a/config.yaml +++ b/config.yaml @@ -154,9 +154,9 @@ gateway: # Stream keepalive interval (seconds), 0=disable # 流式 keepalive 间隔(秒),0=禁用 stream_keepalive_interval: 10 - # SSE max line size in bytes (default: 10MB) - # SSE 单行最大字节数(默认 10MB) - max_line_size: 10485760 + # SSE max line size in bytes (default: 40MB) + # SSE 单行最大字节数(默认 40MB) + max_line_size: 41943040 # Log upstream error response body summary (safe/truncated; does not log request content) # 记录上游错误响应体摘要(安全/截断;不记录请求内容) log_upstream_error_body: false diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 49bf0afa..87ff3148 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -154,9 +154,9 @@ gateway: # Stream keepalive interval (seconds), 0=disable # 流式 keepalive 间隔(秒),0=禁用 stream_keepalive_interval: 10 - # SSE max line size in bytes (default: 10MB) - # SSE 单行最大字节数(默认 10MB) - max_line_size: 10485760 + # SSE max line size in bytes (default: 40MB) + # SSE 单行最大字节数(默认 40MB) + max_line_size: 41943040 # Log upstream error response body summary (safe/truncated; does not log request content) # 记录上游错误响应体摘要(安全/截断;不记录请求内容) log_upstream_error_body: false @@ -234,6 +234,31 @@ jwt: # 令牌过期时间(小时,最大 24) expire_hour: 24 +# ============================================================================= +# LinuxDo Connect OAuth Login (SSO) +# LinuxDo Connect OAuth 登录(用于 Sub2API 用户登录) +# ============================================================================= +linuxdo_connect: + enabled: false + client_id: "" + client_secret: "" + authorize_url: "https://connect.linux.do/oauth2/authorize" + token_url: "https://connect.linux.do/oauth2/token" + userinfo_url: "https://connect.linux.do/api/user" + scopes: "user" + # 示例: "https://your-domain.com/api/v1/auth/oauth/linuxdo/callback" + redirect_url: "" + # 安全提示: + # - 建议使用同源相对路径(以 / 开头),避免把 token 重定向到意外的第三方域名 + # - 该地址不应包含 #fragment(本实现使用 URL fragment 传递 access_token) + frontend_redirect_url: "/auth/linuxdo/callback" + token_auth_method: "client_secret_post" # client_secret_post | client_secret_basic | none + # 注意:当 token_auth_method=none(public client)时,必须启用 PKCE + use_pkce: false + userinfo_email_path: "" + userinfo_id_path: "" + userinfo_username_path: "" + # ============================================================================= # Default Settings # 默认设置 diff --git a/deploy/docker-compose.standalone.yml b/deploy/docker-compose.standalone.yml new file mode 100644 index 00000000..1bf247c7 --- /dev/null +++ b/deploy/docker-compose.standalone.yml @@ -0,0 +1,93 @@ +# ============================================================================= +# Sub2API Docker Compose - Standalone Configuration +# ============================================================================= +# This configuration runs only the Sub2API application. +# PostgreSQL and Redis must be provided externally. +# +# Usage: +# 1. Copy .env.example to .env and configure database/redis connection +# 2. docker-compose -f docker-compose.standalone.yml up -d +# 3. Access: http://localhost:8080 +# ============================================================================= + +services: + 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: + - sub2api_data:/app/data + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + # ======================================================================= + # Auto Setup + # ======================================================================= + - 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) - Required + # ======================================================================= + - DATABASE_HOST=${DATABASE_HOST:?DATABASE_HOST is required} + - DATABASE_PORT=${DATABASE_PORT:-5432} + - DATABASE_USER=${DATABASE_USER:-sub2api} + - DATABASE_PASSWORD=${DATABASE_PASSWORD:?DATABASE_PASSWORD is required} + - DATABASE_DBNAME=${DATABASE_DBNAME:-sub2api} + - DATABASE_SSLMODE=${DATABASE_SSLMODE:-disable} + + # ======================================================================= + # Redis Configuration - Required + # ======================================================================= + - REDIS_HOST=${REDIS_HOST:?REDIS_HOST is required} + - REDIS_PORT=${REDIS_PORT:-6379} + - REDIS_PASSWORD=${REDIS_PASSWORD:-} + - REDIS_DB=${REDIS_DB:-0} + + # ======================================================================= + # Admin Account (auto-created on first run) + # ======================================================================= + - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@sub2api.local} + - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} + + # ======================================================================= + # JWT Configuration + # ======================================================================= + - JWT_SECRET=${JWT_SECRET:-} + - JWT_EXPIRE_HOUR=${JWT_EXPIRE_HOUR:-24} + + # ======================================================================= + # Timezone Configuration + # ======================================================================= + - TZ=${TZ:-Asia/Shanghai} + + # ======================================================================= + # Gemini OAuth Configuration (optional) + # ======================================================================= + - 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:-} + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + +volumes: + sub2api_data: + driver: local diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 6a370e9a..484df3a8 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -173,11 +173,12 @@ services: volumes: - redis_data:/data command: > - redis-server - --save 60 1 - --appendonly yes - --appendfsync everysec - ${REDIS_PASSWORD:+--requirepass ${REDIS_PASSWORD}} + 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) diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 00000000..e7e1288d --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,5304 @@ +{ + "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", + "autoprefixer": "^10.4.16", + "eslint": "^8.57.0", + "eslint-plugin-vue": "^9.25.0", + "postcss": "^8.4.32", + "tailwindcss": "^3.4.0", + "typescript": "~5.6.0", + "vite": "^5.0.10", + "vite-plugin-checker": "^0.9.1", + "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/@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/@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/@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/@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/@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/@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/@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/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/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/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/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/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/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/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/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/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/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/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/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-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/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/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/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/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-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/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/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-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/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/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/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/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/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/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/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/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-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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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-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/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-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/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/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/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/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/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/src/api/admin/groups.ts b/frontend/src/api/admin/groups.ts index 23db9104..44eebc99 100644 --- a/frontend/src/api/admin/groups.ts +++ b/frontend/src/api/admin/groups.ts @@ -16,7 +16,7 @@ import type { * List all groups with pagination * @param page - Page number (default: 1) * @param pageSize - Items per page (default: 20) - * @param filters - Optional filters (platform, status, is_exclusive) + * @param filters - Optional filters (platform, status, is_exclusive, search) * @returns Paginated list of groups */ export async function list( @@ -26,6 +26,7 @@ export async function list( platform?: GroupPlatform status?: 'active' | 'inactive' is_exclusive?: boolean + search?: string }, options?: { signal?: AbortSignal diff --git a/frontend/src/api/admin/index.ts b/frontend/src/api/admin/index.ts index ea12f6d2..c90017a8 100644 --- a/frontend/src/api/admin/index.ts +++ b/frontend/src/api/admin/index.ts @@ -9,6 +9,7 @@ import groupsAPI from './groups' import accountsAPI from './accounts' import proxiesAPI from './proxies' import redeemAPI from './redeem' +import promoAPI from './promo' import settingsAPI from './settings' import systemAPI from './system' import subscriptionsAPI from './subscriptions' @@ -27,6 +28,7 @@ export const adminAPI = { accounts: accountsAPI, proxies: proxiesAPI, redeem: redeemAPI, + promo: promoAPI, settings: settingsAPI, system: systemAPI, subscriptions: subscriptionsAPI, @@ -43,6 +45,7 @@ export { accountsAPI, proxiesAPI, redeemAPI, + promoAPI, settingsAPI, systemAPI, subscriptionsAPI, diff --git a/frontend/src/api/admin/promo.ts b/frontend/src/api/admin/promo.ts new file mode 100644 index 00000000..6a8c4559 --- /dev/null +++ b/frontend/src/api/admin/promo.ts @@ -0,0 +1,69 @@ +/** + * Admin Promo Codes API endpoints + */ + +import { apiClient } from '../client' +import type { + PromoCode, + PromoCodeUsage, + CreatePromoCodeRequest, + UpdatePromoCodeRequest, + BasePaginationResponse +} from '@/types' + +export async function list( + page: number = 1, + pageSize: number = 20, + filters?: { + status?: string + search?: string + } +): Promise> { + const { data } = await apiClient.get>('/admin/promo-codes', { + params: { page, page_size: pageSize, ...filters } + }) + return data +} + +export async function getById(id: number): Promise { + const { data } = await apiClient.get(`/admin/promo-codes/${id}`) + return data +} + +export async function create(request: CreatePromoCodeRequest): Promise { + const { data } = await apiClient.post('/admin/promo-codes', request) + return data +} + +export async function update(id: number, request: UpdatePromoCodeRequest): Promise { + const { data } = await apiClient.put(`/admin/promo-codes/${id}`, request) + return data +} + +export async function deleteCode(id: number): Promise<{ message: string }> { + const { data } = await apiClient.delete<{ message: string }>(`/admin/promo-codes/${id}`) + return data +} + +export async function getUsages( + id: number, + page: number = 1, + pageSize: number = 20 +): Promise> { + const { data } = await apiClient.get>( + `/admin/promo-codes/${id}/usages`, + { params: { page, page_size: pageSize } } + ) + return data +} + +const promoAPI = { + list, + getById, + create, + update, + delete: deleteCode, + getUsages +} + +export default promoAPI diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 6b46de7d..2f6991e7 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -34,6 +34,11 @@ export interface SystemSettings { turnstile_enabled: boolean turnstile_site_key: string turnstile_secret_key_configured: boolean + // LinuxDo Connect OAuth 登录(终端用户 SSO) + linuxdo_connect_enabled: boolean + linuxdo_connect_client_id: string + linuxdo_connect_client_secret_configured: boolean + linuxdo_connect_redirect_url: string // Identity patch configuration (Claude -> Gemini) enable_identity_patch: boolean identity_patch_prompt: string @@ -60,6 +65,10 @@ export interface UpdateSettingsRequest { turnstile_enabled?: boolean turnstile_site_key?: string turnstile_secret_key?: string + linuxdo_connect_enabled?: boolean + linuxdo_connect_client_id?: string + linuxdo_connect_client_secret?: string + linuxdo_connect_redirect_url?: string enable_identity_patch?: boolean identity_patch_prompt?: string } diff --git a/frontend/src/api/admin/usage.ts b/frontend/src/api/admin/usage.ts index 4712dafd..ca76234b 100644 --- a/frontend/src/api/admin/usage.ts +++ b/frontend/src/api/admin/usage.ts @@ -64,7 +64,6 @@ export async function getStats(params: { group_id?: number model?: string stream?: boolean - billing_type?: number period?: string start_date?: string end_date?: string diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 9c5379f2..fddc23ef 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -113,6 +113,26 @@ export async function sendVerifyCode( return data } +/** + * Validate promo code response + */ +export interface ValidatePromoCodeResponse { + valid: boolean + bonus_amount?: number + error_code?: string + message?: string +} + +/** + * Validate promo code (public endpoint, no auth required) + * @param code - Promo code to validate + * @returns Validation result with bonus amount if valid + */ +export async function validatePromoCode(code: string): Promise { + const { data } = await apiClient.post('/auth/validate-promo-code', { code }) + return data +} + export const authAPI = { login, register, @@ -123,7 +143,8 @@ export const authAPI = { getAuthToken, clearAuthToken, getPublicSettings, - sendVerifyCode + sendVerifyCode, + validatePromoCode } export default authAPI diff --git a/frontend/src/api/keys.ts b/frontend/src/api/keys.ts index caa339e4..cdae1359 100644 --- a/frontend/src/api/keys.ts +++ b/frontend/src/api/keys.ts @@ -42,12 +42,16 @@ export async function getById(id: number): Promise { * @param name - Key name * @param groupId - Optional group ID * @param customKey - Optional custom key value + * @param ipWhitelist - Optional IP whitelist + * @param ipBlacklist - Optional IP blacklist * @returns Created API key */ export async function create( name: string, groupId?: number | null, - customKey?: string + customKey?: string, + ipWhitelist?: string[], + ipBlacklist?: string[] ): Promise { const payload: CreateApiKeyRequest = { name } if (groupId !== undefined) { @@ -56,6 +60,12 @@ export async function create( if (customKey) { payload.custom_key = customKey } + if (ipWhitelist && ipWhitelist.length > 0) { + payload.ip_whitelist = ipWhitelist + } + if (ipBlacklist && ipBlacklist.length > 0) { + payload.ip_blacklist = ipBlacklist + } const { data } = await apiClient.post('/keys', payload) return data diff --git a/frontend/src/components/admin/account/AccountBulkActionsBar.vue b/frontend/src/components/admin/account/AccountBulkActionsBar.vue index 17bd634d..41111484 100644 --- a/frontend/src/components/admin/account/AccountBulkActionsBar.vue +++ b/frontend/src/components/admin/account/AccountBulkActionsBar.vue @@ -1,8 +1,27 @@ - - + + @@ -249,11 +248,11 @@ const cols = computed(() => [ { key: 'stream', label: t('usage.type'), sortable: false }, { key: 'tokens', label: t('usage.tokens'), sortable: false }, { key: 'cost', label: t('usage.cost'), sortable: false }, - { key: 'billing_type', label: t('usage.billingType'), sortable: false }, { key: 'first_token', label: t('usage.firstToken'), sortable: false }, { key: 'duration', label: t('usage.duration'), sortable: false }, { key: 'created_at', label: t('usage.time'), sortable: true }, - { key: 'user_agent', label: t('usage.userAgent'), sortable: false } + { key: 'user_agent', label: t('usage.userAgent'), sortable: false }, + { key: 'ip_address', label: t('admin.usage.ipAddress'), sortable: false } ]) const formatCacheTokens = (tokens: number): string => { diff --git a/frontend/src/components/auth/LinuxDoOAuthSection.vue b/frontend/src/components/auth/LinuxDoOAuthSection.vue new file mode 100644 index 00000000..8012b101 --- /dev/null +++ b/frontend/src/components/auth/LinuxDoOAuthSection.vue @@ -0,0 +1,61 @@ + + + + diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 791327a1..d8c91475 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -448,6 +448,7 @@ const adminNavItems = computed(() => { { path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon }, { 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 }, { path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon }, ] diff --git a/frontend/src/composables/useTableLoader.ts b/frontend/src/composables/useTableLoader.ts index 01703ee1..5fb6c5e0 100644 --- a/frontend/src/composables/useTableLoader.ts +++ b/frontend/src/composables/useTableLoader.ts @@ -43,7 +43,8 @@ export function useTableLoader>(options: TableL if (abortController) { abortController.abort() } - abortController = new AbortController() + const currentController = new AbortController() + abortController = currentController loading.value = true try { @@ -51,9 +52,9 @@ export function useTableLoader>(options: TableL pagination.page, pagination.page_size, toRaw(params) as P, - { signal: abortController.signal } + { signal: currentController.signal } ) - + items.value = response.items || [] pagination.total = response.total || 0 pagination.pages = response.pages || 0 @@ -63,7 +64,7 @@ export function useTableLoader>(options: TableL throw error } } finally { - if (abortController && !abortController.signal.aborted) { + if (abortController === currentController) { loading.value = false } } @@ -77,7 +78,9 @@ export function useTableLoader>(options: TableL const debouncedReload = useDebounceFn(reload, debounceMs) const handlePageChange = (page: number) => { - pagination.page = page + // 确保页码在有效范围内 + const validPage = Math.max(1, Math.min(page, pagination.pages || 1)) + pagination.page = validPage load() } diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index c4cf6cc6..c9633e38 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -145,7 +145,8 @@ export default { copiedToClipboard: 'Copied to clipboard', copyFailed: 'Failed to copy', contactSupport: 'Contact Support', - selectOption: 'Select an option', + optional: 'optional', + selectOption: 'Select an option', searchPlaceholder: 'Search...', noOptionsFound: 'No options found', noGroupsAvailable: 'No groups available', @@ -177,6 +178,7 @@ export default { accounts: 'Accounts', proxies: 'Proxies', redeemCodes: 'Redeem Codes', + promoCodes: 'Promo Codes', settings: 'Settings', myAccount: 'My Account', lightMode: 'Light Mode', @@ -229,6 +231,26 @@ export default { sendingCode: 'Sending...', clickToResend: 'Click to resend code', resendCode: 'Resend verification code', + promoCodeLabel: 'Promo Code', + promoCodePlaceholder: 'Enter promo code (optional)', + promoCodeValid: 'Valid! You will receive ${amount} bonus balance', + promoCodeInvalid: 'Invalid promo code', + promoCodeNotFound: 'Promo code not found', + promoCodeExpired: 'This promo code has expired', + promoCodeDisabled: 'This promo code is disabled', + promoCodeMaxUsed: 'This promo code has reached its usage limit', + 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', + linuxdo: { + signIn: 'Continue with Linux.do', + orContinue: 'or continue with email', + callbackTitle: 'Signing you in', + callbackProcessing: 'Completing login, please wait...', + callbackHint: 'If you are not redirected automatically, go back to the login page and try again.', + callbackMissingToken: 'Missing login token, please try again.', + backToLogin: 'Back to Login' + }, oauth: { code: 'Code', state: 'State', @@ -361,6 +383,14 @@ export default { customKeyTooShort: 'Custom key must be at least 16 characters', customKeyInvalidChars: 'Custom key can only contain letters, numbers, underscores, and hyphens', customKeyRequired: 'Please enter a custom key', + ipRestriction: 'IP Restriction', + ipWhitelist: 'IP Whitelist', + ipWhitelistPlaceholder: '192.168.1.100\n10.0.0.0/8', + ipWhitelistHint: 'One IP or CIDR per line. Only these IPs can use this key when set.', + ipBlacklist: 'IP Blacklist', + ipBlacklistPlaceholder: '1.2.3.4\n5.6.0.0/16', + ipBlacklistHint: 'One IP or CIDR per line. These IPs will be blocked from using this key.', + ipRestrictionEnabled: 'IP restriction enabled', ccSwitchNotInstalled: 'CC-Switch is not installed or the protocol handler is not registered. Please install CC-Switch first or manually copy the API key.', ccsClientSelect: { title: 'Select Client', @@ -421,9 +451,6 @@ export default { exportFailed: 'Failed to export usage data', exportExcelSuccess: 'Usage data exported successfully (Excel format)', exportExcelFailed: 'Failed to export usage data', - billingType: 'Billing', - balance: 'Balance', - subscription: 'Subscription', imageUnit: ' images', userAgent: 'User-Agent' }, @@ -1076,12 +1103,16 @@ export default { tokenRefreshed: 'Token refreshed successfully', accountDeleted: 'Account deleted successfully', rateLimitCleared: 'Rate limit cleared successfully', + bulkSchedulableEnabled: 'Successfully enabled scheduling for {count} account(s)', + bulkSchedulableDisabled: 'Successfully disabled scheduling for {count} account(s)', bulkActions: { selected: '{count} account(s) selected', selectCurrentPage: 'Select this page', clear: 'Clear selection', edit: 'Bulk Edit', - delete: 'Bulk Delete' + delete: 'Bulk Delete', + enableScheduling: 'Enable Scheduling', + disableScheduling: 'Disable Scheduling' }, bulkEdit: { title: 'Bulk Edit Accounts', @@ -1486,6 +1517,7 @@ export default { testing: 'Testing...', retry: 'Retry', copyOutput: 'Copy output', + outputCopied: 'Output copied', startingTestForAccount: 'Starting test for account: {name}', testAccountTypeLabel: 'Account type: {type}', selectTestModel: 'Select Test Model', @@ -1703,6 +1735,65 @@ export default { } }, + // Promo Codes + promo: { + title: 'Promo Code Management', + description: 'Create and manage registration promo codes', + createCode: 'Create Promo Code', + editCode: 'Edit Promo Code', + deleteCode: 'Delete Promo Code', + searchCodes: 'Search codes...', + allStatus: 'All Status', + columns: { + code: 'Code', + bonusAmount: 'Bonus Amount', + maxUses: 'Max Uses', + usedCount: 'Used', + usage: 'Usage', + status: 'Status', + expiresAt: 'Expires At', + createdAt: 'Created At', + actions: 'Actions' + }, + // Form labels (flat structure for template usage) + code: 'Promo Code', + autoGenerate: 'auto-generate if empty', + codePlaceholder: 'Enter promo code or leave empty', + bonusAmount: 'Bonus Amount ($)', + maxUses: 'Max Uses', + zeroUnlimited: '0 = unlimited', + expiresAt: 'Expires At', + notes: 'Notes', + notesPlaceholder: 'Optional notes for this code', + status: 'Status', + neverExpires: 'Never expires', + // Status labels + statusActive: 'Active', + statusDisabled: 'Disabled', + statusExpired: 'Expired', + statusMaxUsed: 'Used Up', + // Usage records + usageRecords: 'Usage Records', + viewUsages: 'View Usages', + noUsages: 'No usage records yet', + userPrefix: 'User #{id}', + copied: 'Copied!', + // Messages + noCodesYet: 'No promo codes yet', + createFirstCode: 'Create your first promo code to offer registration bonuses.', + codeCreated: 'Promo code created successfully', + codeUpdated: 'Promo code updated successfully', + codeDeleted: 'Promo code deleted successfully', + deleteCodeConfirm: 'Are you sure you want to delete this promo code? This action cannot be undone.', + copyRegisterLink: 'Copy register link', + registerLinkCopied: 'Register link copied to clipboard', + failedToLoad: 'Failed to load promo codes', + failedToCreate: 'Failed to create promo code', + failedToUpdate: 'Failed to update promo code', + failedToDelete: 'Failed to delete promo code', + failedToLoadUsages: 'Failed to load usage records' + }, + // Usage Records usage: { title: 'Usage Records', @@ -1721,7 +1812,6 @@ export default { allAccounts: 'All Accounts', allGroups: 'All Groups', allTypes: 'All Types', - allBillingTypes: 'All Billing', inputCost: 'Input Cost', outputCost: 'Output Cost', cacheCreationCost: 'Cache Creation Cost', @@ -1730,7 +1820,8 @@ export default { outputTokens: 'Output Tokens', cacheCreationTokens: 'Cache Creation Tokens', cacheReadTokens: 'Cache Read Tokens', - failedToLoad: 'Failed to load usage records' + failedToLoad: 'Failed to load usage records', + ipAddress: 'IP' }, // Settings @@ -1756,6 +1847,26 @@ export default { cloudflareDashboard: 'Cloudflare Dashboard', secretKeyHint: 'Server-side verification key (keep this secret)', secretKeyConfiguredHint: 'Secret key configured. Leave empty to keep the current value.' }, + linuxdo: { + title: 'LinuxDo Connect Login', + description: 'Configure LinuxDo Connect OAuth for Sub2API end-user login', + enable: 'Enable LinuxDo Login', + enableHint: 'Show LinuxDo login on the login/register pages', + clientId: 'Client ID', + clientIdPlaceholder: 'e.g., hprJ5pC3...', + clientIdHint: 'Get this from Connect.Linux.Do', + clientSecret: 'Client Secret', + clientSecretPlaceholder: '********', + clientSecretHint: 'Used by backend to exchange tokens (keep it secret)', + clientSecretConfiguredPlaceholder: '********', + clientSecretConfiguredHint: 'Secret configured. Leave empty to keep the current value.', + redirectUrl: 'Redirect URL', + redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/linuxdo/callback', + redirectUrlHint: + 'Must match the redirect URL configured in Connect.Linux.Do (must be an absolute http(s) URL)', + quickSetCopy: 'Generate & Copy (current site)', + redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard' + }, defaults: { title: 'Default User Settings', description: 'Default values for new users', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 79ddf6cc..6571d0e5 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -142,6 +142,7 @@ export default { copiedToClipboard: '已复制到剪贴板', copyFailed: '复制失败', contactSupport: '联系客服', + optional: '可选', selectOption: '请选择', searchPlaceholder: '搜索...', noOptionsFound: '无匹配选项', @@ -175,6 +176,7 @@ export default { accounts: '账号管理', proxies: 'IP管理', redeemCodes: '兑换码', + promoCodes: '优惠码', settings: '系统设置', myAccount: '我的账户', lightMode: '浅色模式', @@ -227,6 +229,26 @@ export default { sendingCode: '发送中...', clickToResend: '点击重新发送验证码', resendCode: '重新发送验证码', + promoCodeLabel: '优惠码', + promoCodePlaceholder: '输入优惠码(可选)', + promoCodeValid: '有效!注册后将获得 ${amount} 赠送余额', + promoCodeInvalid: '无效的优惠码', + promoCodeNotFound: '优惠码不存在', + promoCodeExpired: '此优惠码已过期', + promoCodeDisabled: '此优惠码已被禁用', + promoCodeMaxUsed: '此优惠码已达到使用上限', + promoCodeAlreadyUsed: '您已使用过此优惠码', + promoCodeValidating: '优惠码正在验证中,请稍候', + promoCodeInvalidCannotRegister: '优惠码无效,请检查后重试或清空优惠码', + linuxdo: { + signIn: '使用 Linux.do 登录', + orContinue: '或使用邮箱密码继续', + callbackTitle: '正在完成登录', + callbackProcessing: '正在验证登录信息,请稍候...', + callbackHint: '如果页面未自动跳转,请返回登录页重试。', + callbackMissingToken: '登录信息缺失,请返回重试。', + backToLogin: '返回登录' + }, oauth: { code: '授权码', state: '状态', @@ -358,6 +380,14 @@ export default { customKeyTooShort: '自定义密钥至少需要16个字符', customKeyInvalidChars: '自定义密钥只能包含字母、数字、下划线和连字符', customKeyRequired: '请输入自定义密钥', + ipRestriction: 'IP 限制', + ipWhitelist: 'IP 白名单', + ipWhitelistPlaceholder: '192.168.1.100\n10.0.0.0/8', + ipWhitelistHint: '每行一个 IP 或 CIDR,设置后仅允许这些 IP 使用此密钥', + ipBlacklist: 'IP 黑名单', + ipBlacklistPlaceholder: '1.2.3.4\n5.6.0.0/16', + ipBlacklistHint: '每行一个 IP 或 CIDR,这些 IP 将被禁止使用此密钥', + ipRestrictionEnabled: '已配置 IP 限制', ccSwitchNotInstalled: 'CC-Switch 未安装或协议处理程序未注册。请先安装 CC-Switch 或手动复制 API 密钥。', ccsClientSelect: { title: '选择客户端', @@ -418,9 +448,6 @@ export default { exportFailed: '使用数据导出失败', exportExcelSuccess: '使用数据导出成功(Excel格式)', exportExcelFailed: '使用数据导出失败', - billingType: '消费类型', - balance: '余额', - subscription: '订阅', imageUnit: '张', userAgent: 'User-Agent' }, @@ -1212,12 +1239,16 @@ export default { accountCreatedSuccess: '账号添加成功', accountUpdatedSuccess: '账号更新成功', accountDeletedSuccess: '账号删除成功', + bulkSchedulableEnabled: '成功启用 {count} 个账号的调度', + bulkSchedulableDisabled: '成功停止 {count} 个账号的调度', bulkActions: { selected: '已选择 {count} 个账号', selectCurrentPage: '本页全选', clear: '清除选择', edit: '批量编辑账号', - delete: '批量删除' + delete: '批量删除', + enableScheduling: '批量启用调度', + disableScheduling: '批量停止调度' }, bulkEdit: { title: '批量编辑账号', @@ -1601,6 +1632,7 @@ export default { startTest: '开始测试', retry: '重试', copyOutput: '复制输出', + outputCopied: '输出已复制', startingTestForAccount: '开始测试账号:{name}', testAccountTypeLabel: '账号类型:{type}', selectTestModel: '选择测试模型', @@ -1848,6 +1880,65 @@ export default { failedToDelete: '删除兑换码失败' }, + // Promo Codes + promo: { + title: '优惠码管理', + description: '创建和管理注册优惠码', + createCode: '创建优惠码', + editCode: '编辑优惠码', + deleteCode: '删除优惠码', + searchCodes: '搜索优惠码...', + allStatus: '全部状态', + columns: { + code: '优惠码', + bonusAmount: '赠送金额', + maxUses: '最大使用次数', + usedCount: '已使用', + usage: '使用量', + status: '状态', + expiresAt: '过期时间', + createdAt: '创建时间', + actions: '操作' + }, + // 表单标签(扁平结构便于模板使用) + code: '优惠码', + autoGenerate: '留空自动生成', + codePlaceholder: '输入优惠码或留空', + bonusAmount: '赠送金额 ($)', + maxUses: '最大使用次数', + zeroUnlimited: '0 = 无限制', + expiresAt: '过期时间', + notes: '备注', + notesPlaceholder: '可选备注信息', + status: '状态', + neverExpires: '永不过期', + // 状态标签 + statusActive: '启用', + statusDisabled: '禁用', + statusExpired: '已过期', + statusMaxUsed: '已用完', + // 使用记录 + usageRecords: '使用记录', + viewUsages: '查看使用记录', + noUsages: '暂无使用记录', + userPrefix: '用户 #{id}', + copied: '已复制!', + // 消息 + noCodesYet: '暂无优惠码', + createFirstCode: '创建您的第一个优惠码,为新用户提供注册奖励。', + codeCreated: '优惠码创建成功', + codeUpdated: '优惠码更新成功', + codeDeleted: '优惠码删除成功', + deleteCodeConfirm: '确定要删除此优惠码吗?此操作无法撤销。', + copyRegisterLink: '复制注册链接', + registerLinkCopied: '注册链接已复制到剪贴板', + failedToLoad: '加载优惠码失败', + failedToCreate: '创建优惠码失败', + failedToUpdate: '更新优惠码失败', + failedToDelete: '删除优惠码失败', + failedToLoadUsages: '加载使用记录失败' + }, + // Usage Records usage: { title: '使用记录', @@ -1866,7 +1957,6 @@ export default { allAccounts: '全部账户', allGroups: '全部分组', allTypes: '全部类型', - allBillingTypes: '全部计费', inputCost: '输入成本', outputCost: '输出成本', cacheCreationCost: '缓存创建成本', @@ -1875,7 +1965,8 @@ export default { outputTokens: '输出 Token', cacheCreationTokens: '缓存创建 Token', cacheReadTokens: '缓存读取 Token', - failedToLoad: '加载使用记录失败' + failedToLoad: '加载使用记录失败', + ipAddress: 'IP' }, // Settings @@ -1901,6 +1992,25 @@ export default { cloudflareDashboard: 'Cloudflare Dashboard', secretKeyHint: '服务端验证密钥(请保密)', secretKeyConfiguredHint: '密钥已配置,留空以保留当前值。' }, + linuxdo: { + title: 'LinuxDo Connect 登录', + description: '配置 LinuxDo Connect OAuth,用于 Sub2API 用户登录', + enable: '启用 LinuxDo 登录', + enableHint: '在登录/注册页面显示 LinuxDo 登录入口', + clientId: 'Client ID', + clientIdPlaceholder: '例如:hprJ5pC3...', + clientIdHint: '从 Connect.Linux.Do 后台获取', + clientSecret: 'Client Secret', + clientSecretPlaceholder: '********', + clientSecretHint: '用于后端交换 token(请保密)', + clientSecretConfiguredPlaceholder: '********', + clientSecretConfiguredHint: '密钥已配置,留空以保留当前值。', + redirectUrl: '回调地址(Redirect URL)', + redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/linuxdo/callback', + redirectUrlHint: '需与 Connect.Linux.Do 中配置的回调地址一致(必须是 http(s) 完整 URL)', + quickSetCopy: '使用当前站点生成并复制', + redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板' + }, defaults: { title: '用户默认设置', description: '新用户的默认值', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 48a6f0fd..6886704d 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -67,6 +67,15 @@ const routes: RouteRecordRaw[] = [ title: 'OAuth Callback' } }, + { + path: '/auth/linuxdo/callback', + name: 'LinuxDoOAuthCallback', + component: () => import('@/views/auth/LinuxDoCallbackView.vue'), + meta: { + requiresAuth: false, + title: 'LinuxDo OAuth Callback' + } + }, // ==================== User Routes ==================== { @@ -235,6 +244,18 @@ const routes: RouteRecordRaw[] = [ descriptionKey: 'admin.redeem.description' } }, + { + path: '/admin/promo-codes', + name: 'AdminPromoCodes', + component: () => import('@/views/admin/PromoCodesView.vue'), + meta: { + requiresAuth: true, + requiresAdmin: true, + title: 'Promo Code Management', + titleKey: 'admin.promo.title', + descriptionKey: 'admin.promo.description' + } + }, { path: '/admin/settings', name: 'AdminSettings', diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index cfc9d677..ce7081e1 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -30,6 +30,7 @@ export const useAppStore = defineStore('app', () => { const contactInfo = ref('') const apiBaseUrl = ref('') const docUrl = ref('') + const cachedPublicSettings = ref(null) // Version cache state const versionLoaded = ref(false) @@ -285,6 +286,9 @@ export const useAppStore = defineStore('app', () => { async function fetchPublicSettings(force = false): Promise { // Return cached data if available and not forcing refresh if (publicSettingsLoaded.value && !force) { + if (cachedPublicSettings.value) { + return { ...cachedPublicSettings.value } + } return { registration_enabled: false, email_verify_enabled: false, @@ -296,6 +300,7 @@ export const useAppStore = defineStore('app', () => { api_base_url: apiBaseUrl.value, contact_info: contactInfo.value, doc_url: docUrl.value, + linuxdo_oauth_enabled: false, version: siteVersion.value } } @@ -308,6 +313,7 @@ export const useAppStore = defineStore('app', () => { publicSettingsLoading.value = true try { const data = await fetchPublicSettingsAPI() + cachedPublicSettings.value = data siteName.value = data.site_name || 'Sub2API' siteLogo.value = data.site_logo || '' siteVersion.value = data.version || '' @@ -329,6 +335,7 @@ export const useAppStore = defineStore('app', () => { */ function clearPublicSettingsCache(): void { publicSettingsLoaded.value = false + cachedPublicSettings.value = null } // ==================== Return Store API ==================== diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 27faaf4b..4076e154 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -159,6 +159,27 @@ export const useAuthStore = defineStore('auth', () => { } } + /** + * 直接设置 token(用于 OAuth/SSO 回调),并加载当前用户信息。 + * @param newToken - 后端签发的 JWT access token + */ + async function setToken(newToken: string): Promise { + // Clear any previous state first (avoid mixing sessions) + clearAuth() + + token.value = newToken + localStorage.setItem(AUTH_TOKEN_KEY, newToken) + + try { + const userData = await refreshUser() + startAutoRefresh() + return userData + } catch (error) { + clearAuth() + throw error + } + } + /** * User logout * Clears all authentication state and persisted data @@ -233,6 +254,7 @@ export const useAuthStore = defineStore('auth', () => { // Actions login, register, + setToken, logout, checkAuth, refreshUser diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index eaea24be..3d1b17f6 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -50,6 +50,7 @@ export interface RegisterRequest { password: string verify_code?: string turnstile_token?: string + promo_code?: string } export interface SendVerifyCodeRequest { @@ -73,6 +74,7 @@ export interface PublicSettings { api_base_url: string contact_info: string doc_url: string + linuxdo_oauth_enabled: boolean version: string } @@ -278,6 +280,8 @@ export interface ApiKey { name: string group_id: number | null status: 'active' | 'inactive' + ip_whitelist: string[] + ip_blacklist: string[] created_at: string updated_at: string group?: Group @@ -287,12 +291,16 @@ export interface CreateApiKeyRequest { name: string group_id?: number | null custom_key?: string // Optional custom API Key + ip_whitelist?: string[] + ip_blacklist?: string[] } export interface UpdateApiKeyRequest { name?: string group_id?: number | null status?: 'active' | 'inactive' + ip_whitelist?: string[] + ip_blacklist?: string[] } export interface CreateGroupRequest { @@ -559,9 +567,6 @@ export interface UpdateProxyRequest { export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' -// 消费类型: 0=钱包余额, 1=订阅套餐 -export type BillingType = 0 | 1 - export interface UsageLog { id: number user_id: number @@ -588,7 +593,6 @@ export interface UsageLog { actual_cost: number rate_multiplier: number - billing_type: BillingType stream: boolean duration_ms: number first_token_ms: number | null @@ -600,6 +604,9 @@ export interface UsageLog { // User-Agent user_agent: string | null + // IP 地址(仅管理员可见) + ip_address: string | null + created_at: string user?: User @@ -829,7 +836,6 @@ export interface UsageQueryParams { group_id?: number model?: string stream?: boolean - billing_type?: number start_date?: string end_date?: string } @@ -955,3 +961,44 @@ export interface UpdateUserAttributeRequest { export interface UserAttributeValuesMap { [attributeId: number]: string } + +// ==================== Promo Code Types ==================== + +export interface PromoCode { + id: number + code: string + bonus_amount: number + max_uses: number + used_count: number + status: 'active' | 'disabled' + expires_at: string | null + notes: string | null + created_at: string + updated_at: string +} + +export interface PromoCodeUsage { + id: number + promo_code_id: number + user_id: number + bonus_amount: number + used_at: string + user?: User +} + +export interface CreatePromoCodeRequest { + code?: string + bonus_amount: number + max_uses?: number + expires_at?: number | null + notes?: string +} + +export interface UpdatePromoCodeRequest { + code?: string + bonus_amount?: number + max_uses?: number + status?: 'active' | 'disabled' + expires_at?: number | null + notes?: string +} diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 0ca22a76..79c6072c 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -7,7 +7,7 @@ v-model:searchQuery="params.search" :filters="params" @update:filters="(newFilters) => Object.assign(params, newFilters)" - @change="reload" + @change="debouncedReload" @update:searchQuery="debouncedReload" /> - + @@ -175,7 +175,7 @@ const statsAcc = ref(null) const togglingSchedulable = ref(null) const menu = reactive<{show:boolean, acc:Account|null, pos:{top:number, left:number}|null}>({ show: false, acc: null, pos: null }) -const { items: accounts, loading, params, pagination, load, reload, debouncedReload, handlePageChange } = useTableLoader({ +const { items: accounts, loading, params, pagination, load, reload, debouncedReload, handlePageChange, handlePageSizeChange } = useTableLoader({ fetchFn: adminAPI.accounts.list, initialParams: { platform: '', type: '', status: '', search: '' } }) @@ -209,6 +209,21 @@ const openMenu = (a: Account, e: MouseEvent) => { menu.acc = a; menu.pos = { top const toggleSel = (id: number) => { const i = selIds.value.indexOf(id); if(i === -1) selIds.value.push(id); else selIds.value.splice(i, 1) } const selectPage = () => { selIds.value = [...new Set([...selIds.value, ...accounts.value.map(a => a.id)])] } const handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); selIds.value = []; reload() } catch (error) { console.error('Failed to bulk delete accounts:', error) } } +const handleBulkToggleSchedulable = async (schedulable: boolean) => { + const count = selIds.value.length + try { + const result = await adminAPI.accounts.bulkUpdate(selIds.value, { schedulable }); + const message = schedulable + ? t('admin.accounts.bulkSchedulableEnabled', { count: result.success || count }) + : t('admin.accounts.bulkSchedulableDisabled', { count: result.success || count }); + appStore.showSuccess(message); + selIds.value = []; + reload() + } catch (error) { + console.error('Failed to bulk toggle schedulable:', error); + appStore.showError(t('common.error')) + } +} const handleBulkUpdated = () => { showBulkEdit.value = false; selIds.value = []; reload() } const closeTestModal = () => { showTest.value = false; testingAcc.value = null } const closeStatsModal = () => { showStats.value = false; statsAcc.value = null } diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index f7ef2339..d8322154 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -16,6 +16,7 @@ type="text" :placeholder="t('admin.groups.searchGroups')" class="input pl-10" + @input="handleSearch" /> +

+ {{ t('admin.settings.linuxdo.clientIdHint') }} +

+ + +
+ + +

+ {{ + form.linuxdo_connect_client_secret_configured + ? t('admin.settings.linuxdo.clientSecretConfiguredHint') + : t('admin.settings.linuxdo.clientSecretHint') + }} +

+
+ +
+ + +
+ + + {{ linuxdoRedirectUrlSuggestion }} + +
+

+ {{ t('admin.settings.linuxdo.redirectUrlHint') }} +

+
+ + + + +
@@ -692,17 +792,19 @@ diff --git a/frontend/src/views/auth/EmailVerifyView.vue b/frontend/src/views/auth/EmailVerifyView.vue index abb43cab..3b6b45ba 100644 --- a/frontend/src/views/auth/EmailVerifyView.vue +++ b/frontend/src/views/auth/EmailVerifyView.vue @@ -200,6 +200,7 @@ let countdownTimer: ReturnType | null = null const email = ref('') const password = ref('') const initialTurnstileToken = ref('') +const promoCode = ref('') const hasRegisterData = ref(false) // Public settings @@ -228,6 +229,7 @@ onMounted(async () => { email.value = registerData.email || '' password.value = registerData.password || '' initialTurnstileToken.value = registerData.turnstile_token || '' + promoCode.value = registerData.promo_code || '' hasRegisterData.value = !!(email.value && password.value) } catch { hasRegisterData.value = false @@ -381,7 +383,8 @@ async function handleVerify(): Promise { email: email.value, password: password.value, verify_code: verifyCode.value.trim(), - turnstile_token: initialTurnstileToken.value || undefined + turnstile_token: initialTurnstileToken.value || undefined, + promo_code: promoCode.value || undefined }) // Clear session data diff --git a/frontend/src/views/auth/LinuxDoCallbackView.vue b/frontend/src/views/auth/LinuxDoCallbackView.vue new file mode 100644 index 00000000..c6f93e6b --- /dev/null +++ b/frontend/src/views/auth/LinuxDoCallbackView.vue @@ -0,0 +1,119 @@ + + + + + + diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue index 903db100..6e6cee27 100644 --- a/frontend/src/views/auth/LoginView.vue +++ b/frontend/src/views/auth/LoginView.vue @@ -11,6 +11,9 @@

+ + +
@@ -157,6 +160,7 @@ import { ref, reactive, onMounted } from 'vue' import { useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { AuthLayout } from '@/components/layout' +import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue' import Icon from '@/components/icons/Icon.vue' import TurnstileWidget from '@/components/TurnstileWidget.vue' import { useAuthStore, useAppStore } from '@/stores' @@ -179,6 +183,7 @@ const showPassword = ref(false) // Public settings const turnstileEnabled = ref(false) const turnstileSiteKey = ref('') +const linuxdoOAuthEnabled = ref(false) // Turnstile const turnstileRef = ref | null>(null) @@ -210,6 +215,7 @@ onMounted(async () => { const settings = await getPublicSettings() turnstileEnabled.value = settings.turnstile_enabled turnstileSiteKey.value = settings.turnstile_site_key || '' + linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled } catch (error) { console.error('Failed to load public settings:', error) } diff --git a/frontend/src/views/auth/RegisterView.vue b/frontend/src/views/auth/RegisterView.vue index 9f3555d4..bfdc08e8 100644 --- a/frontend/src/views/auth/RegisterView.vue +++ b/frontend/src/views/auth/RegisterView.vue @@ -11,6 +11,9 @@

+ + +
+ +
+ +
+
+ +
+ + +
+ + + + +
+
+ +
+
+ +
+
+ + +
+ + + {{ t('auth.promoCodeValid', { amount: promoValidation.bonusAmount?.toFixed(2) }) }} + +
+

+ {{ promoValidation.message }} +

+
+
+