Compare commits

..

27 Commits

Author SHA1 Message Date
shaw
a3b81ef7bc fix(test): 补充 stubUsageLogRepo 缺失的 GetStatsWithFilters 方法 2026-01-06 22:35:22 +08:00
shaw
015974a27e feat(admin/usage): 优化管理员用量页面功能和体验
后端改进:
- 新增 GetStatsWithFilters 方法支持完整筛选条件
- Stats 端点支持 account_id, group_id, model, stream, billing_type 参数
- 统一使用 filters 结构体,移除冗余的分支逻辑

前端改进:
- 统计卡片添加"所选范围内"文字提示
- 优化总消费显示格式,清晰展示实际费用和标准计费
- Token 和费用列添加问号图标 tooltip 显示详细信息
- API Key 搜索框体验优化:点击即显示下拉选项
- 选择用户后自动加载该用户的所有 API Key
2026-01-06 22:19:07 +08:00
程序猿MT
4cf756ebe6 Merge branch 'Wei-Shaw:main' into main 2026-01-06 20:33:30 +08:00
yangjianbo
823497a2af fix(并发): 修复 wrapReleaseOnDone goroutine 泄露问题
问题描述:
- wrapReleaseOnDone 函数创建的 goroutine 会持续等待 ctx.Done()
- 即使 release() 已被调用,goroutine 仍不会退出
- 高并发场景下(1000 req/s)会产生 3000+ 个泄露 goroutine

修复方案:
- 添加 quit channel 作为退出信号
- 正常释放时 close(quit) 通知 goroutine 立即退出
- 使用 select 监听 ctx.Done() 和 quit 两个信号
- 确保 goroutine 在正常流程中及时退出

测试覆盖:
- 新增 5 个单元测试验证修复效果
- 验证 goroutine 不泄露
- 验证并发安全性和多次调用保护
- 性能影响:471.9 ns/op, 208 B/op

影响范围:
- gateway_handler.go: 每请求调用 2-4 次
- openai_gateway_handler.go: 每请求调用 2-3 次
- 修复后 goroutine 泄露数量从 3/req 降至 0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 20:31:40 +08:00
yangjianbo
66fe484f0d chore: 删除依赖安全文档 2026-01-06 20:26:32 +08:00
shaw
216321aa9e Merge PR #183: 修复账号管理页面过滤器和错误提示问题 2026-01-06 19:49:18 +08:00
yangjianbo
5a52cb608c fix(前端): 修复账号管理页面平台过滤不生效的问题
添加 @update:filters 事件监听,使过滤器参数能正确同步到数据请求中。
修复了平台、类型、状态三个过滤器全部失效的问题。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 19:20:05 +08:00
yangjianbo
1181b332f7 fix(前端): 修复编辑账号错误提示无法显示具体原因的问题
后端 API 返回 message 字段,但前端读取 detail 字段,导致无法显示具体错误信息。
现在优先读取 message 字段,兼容 detail 字段。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 15:46:36 +08:00
yangjianbo
58b1777198 fix(ci): 修复 frontend-security job 中的 pnpm 安装顺序问题
**问题描述:**
GitHub Actions 在 frontend-security job 中报错:
"Error: Unable to locate executable file: pnpm"

**根本原因:**
setup-node@v4 在尝试使用 pnpm cache 时,pnpm 还未安装

**解决方案:**
1. 调整步骤顺序:先安装 pnpm,再设置 Node.js
2. 升级 pnpm/action-setup 从 v2 到 v4
3. 明确指定 pnpm version: 9

**修改内容:**
- 将 "Set up pnpm" 步骤移到 "Set up Node.js" 之前
- 更新 pnpm/action-setup@v2 → pnpm/action-setup@v4
- 添加 version: 9 配置

**正确的步骤顺序:**
1. Checkout 代码
2. Set up pnpm (指定版本)
3. Set up Node.js (可以使用 pnpm cache)
4. Install dependencies

相关 Issue: #174

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 13:42:47 +08:00
yangjianbo
0c7a58fcc7 fix(配置): 修改 URL 安全配置默认值为开发友好模式
调整以下配置的默认值以匹配 .env.example:
- allow_insecure_http: false → true (允许 HTTP URL)
- allow_private_hosts: false → true (允许本地/私有 IP)

**改动说明:**
- 默认允许 HTTP URL,方便开发测试环境使用
- 默认允许本地和私有 IP 地址
- 与 deploy/.env.example 中的推荐配置保持一致
- 更新相应的单元测试以验证新的默认值

**安全提示:**
⚠️ 这些默认值适合开发/测试环境
⚠️ 生产环境建议显式配置更严格的安全策略
⚠️ HTTP 存在明文传输风险,仅在可信网络中使用

**测试结果:**
-  所有单元测试通过
-  golangci-lint 无问题

相关文件:
- backend/internal/config/config.go:451-452
- backend/internal/config/config_test.go:83-88

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 12:56:29 +08:00
yangjianbo
17ae51c0a0 merge: 合并远程分支并修复代码冲突
合并了远程分支 cb72262 的功能更新,同时保留了 ESLint 修复:

**冲突解决详情:**

1. AccountTableFilters.vue
   -  保留 emit 模式修复(避免 vue/no-mutating-props 错误)
   -  添加第三个筛选器 type(账户类型)
   -  新增 antigravity 平台和 inactive 状态选项

2. UserBalanceModal.vue
   -  保留 console.error 错误日志
   -  添加输入验证(金额校验、余额不足检查)
   -  使用 appStore.showError 向用户显示友好错误

3. AccountsView.vue
   -  保留所有 console.error 错误日志(避免 no-empty 错误)
   -  使用新 API:clearRateLimit 和 setSchedulable

4. UsageView.vue
   -  添加 console.error 错误日志
   -  添加图表功能(模型分布、使用趋势)
   -  添加粒度选择(按天/按小时)
   -  保留 XLSX 动态导入优化

**测试结果:**
-  Go tests: PASS
-  golangci-lint: 0 issues
-  ESLint: 0 errors
-  TypeScript: PASS

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 12:50:51 +08:00
yangjianbo
4790aced15 fix(前端): 修复 ESLint 代码规范问题
- 修复 AccountTableFilters.vue 中的 vue/no-mutating-props 错误,使用 emit 模式替代直接修改 props
- 修复 TypeScript 类型错误,支持 Select 组件的 null 值类型
- 为所有空 catch 块添加错误日志,提升代码可维护性和调试能力
- 涉及文件:AccountTableFilters.vue, UserAllowedGroupsModal.vue, UserApiKeysModal.vue, UserBalanceModal.vue, AccountsView.vue, UsageView.vue, DashboardView.vue, ProfileView.vue

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 12:42:06 +08:00
yangjianbo
3f0017d1f1 fix(安全): 修复依赖漏洞并强化安全扫描
主要改动:
- 固定 Go 1.25.5 与 CI 校验并更新扫描流程
- 升级 quic-go、x/crypto、req 等依赖并通过 govulncheck
- 强化 JWT 校验、TLS 配置与 xlsx 动态加载
- 新增审计豁免清单与校验脚本
2026-01-06 11:36:38 +08:00
shaw
cb72262ad8 Merge PR #166: feat: 图片生成计费功能 2026-01-06 11:29:35 +08:00
song
195e227c04 merge: 合并 upstream/main 并保留本地图片计费功能 2026-01-06 10:49:26 +08:00
Yuhao Jiang
f5603b0780 fix: 修复跨时区用户日期范围查询不准确的问题
问题:当用户时区与服务器时区不同时,日期范围查询使用服务器时区解析,
导致用户看到的数据与预期不符。

修复方案:
- 前端:所有 GET 请求自动携带用户时区参数
- 后端:新增时区辅助函数,所有日期解析和默认日期范围计算都使用用户时区
- 当用户时区为空或无效时,自动回退到服务器时区

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-05 20:43:03 -06:00
shaw
752882a022 fix: Token 统计支持 M 单位并修复 lint 错误
- 用户仪表盘 Token 统计卡片支持 K/M 单位自动切换
- 更新 formatTokensK 工具函数支持百万级显示
- 修复 setup.go 中未检查返回值的 errcheck 错误
2026-01-06 10:13:12 +08:00
shaw
9731b961d0 fix: redeem 页面类型的字段翻译 2026-01-06 09:46:46 +08:00
shaw
2920409404 docs: 更新 Docker 部署文档强调 JWT_SECRET 配置重要性
- docker-compose.yml: 添加注释说明设置固定 JWT_SECRET 可防止容器重启后登录失效
- .env.example: 添加 openssl rand -hex 32 生成安全密钥的命令
2026-01-06 09:44:54 +08:00
shaw
7dbbfc22b6 fix: 移除 release 模式 JWT Secret 必填限制并支持 Docker 数据目录
- 移除 Install() 和 AutoSetupFromEnv() 中 release 模式下 JWT Secret 必填检查
- 移除 config.Validate() 中 release 模式下的 JWT 验证
- 新增 GetDataDir() 函数,自动检测数据目录:DATA_DIR 环境变量 > /app/data > 当前目录
- config.yaml 和 .installed 文件现在写入正确的数据目录
- config.Load() 添加 /app/data 到配置搜索路径

这修复了两个问题:
1. Web Setup Wizard 在 release 模式下无法完成安装
2. Docker 部署时 config.yaml 未被持久化导致每次重启重新初始化
2026-01-06 09:43:56 +08:00
shaw
aaaa68ea7f fix: 修复 CSP 策略阻止 Cloudflare Turnstile 加载的问题
在 script-src 和 frame-src 中添加 challenges.cloudflare.com 域名,
允许 Turnstile 脚本加载和 iframe 渲染。
2026-01-06 09:15:03 +08:00
shaw
af753de481 fix: 修复账号修改代理更新无效的bug 2026-01-06 09:04:01 +08:00
shaw
3956819c78 fix: 数据迁移时长增加到10分钟 2026-01-05 22:24:24 +08:00
song
6fa704d6fc fix: Antigravity 账户刷新 token 500 错误
AccountHandler.Refresh 方法缺少对 Antigravity 平台的处理分支,
导致刷新时错误地走进 Claude 刷新逻辑。
2026-01-05 18:29:37 +08:00
song
0400fcdca4 fix: 更新 API 合同测试,添加 image_count 和 image_size 字段 2026-01-05 17:31:23 +08:00
song
5b1907fe61 fix: 图片计费代码审查问题修复
- isImageGenerationModel 改为精确匹配/前缀匹配,避免误匹配
- 新增 normalizePrice 函数,支持负数清除价格配置
- 更新注释说明 Gemini API 每次请求只生成一张图片
- 添加测试用例验证不会误匹配自定义模型名
2026-01-05 17:14:06 +08:00
song
d4c2b723a5 feat: 图片生成计费功能
- 新增 Group 图片价格配置(image_price_1k/2k/4k)
- BillingService 新增 CalculateImageCost 方法
- AntigravityGatewayService 支持识别图片生成模型并按次计费
- UsageLog 新增 image_count 和 image_size 字段
- 前端分组管理支持配置图片价格(antigravity 和 gemini 平台)
- 图片计费复用通用计费能力(余额检查、扣费、倍率、订阅限额)
2026-01-05 17:07:29 +08:00
92 changed files with 4592 additions and 304 deletions

16
.github/audit-exceptions.yml vendored Normal file
View File

@@ -0,0 +1,16 @@
version: 1
exceptions:
- package: xlsx
advisory: "GHSA-4r6h-8v6p-xvw6"
severity: high
reason: "Admin export only; switched to dynamic import to reduce exposure (CVE-2023-30533)"
mitigation: "Load only on export; restrict export permissions and data scope"
expires_on: "2026-04-05"
owner: "security@your-domain"
- package: xlsx
advisory: "GHSA-5pgg-2g8v-p4x9"
severity: high
reason: "Admin export only; switched to dynamic import to reduce exposure (CVE-2024-22363)"
mitigation: "Load only on export; restrict export permissions and data scope"
expires_on: "2026-04-05"
owner: "security@your-domain"

View File

@@ -15,8 +15,11 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version-file: backend/go.mod
check-latest: true
check-latest: false
cache: true
- name: Verify Go version
run: |
go version | grep -q 'go1.25.5'
- name: Unit tests
working-directory: backend
run: make test-unit
@@ -31,8 +34,11 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version-file: backend/go.mod
check-latest: true
check-latest: false
cache: true
- name: Verify Go version
run: |
go version | grep -q 'go1.25.5'
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:

View File

@@ -109,9 +109,14 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
go-version-file: backend/go.mod
check-latest: false
cache-dependency-path: backend/go.sum
- name: Verify Go version
run: |
go version | grep -q 'go1.25.5'
# Docker setup for GoReleaser
- name: Set up QEMU
uses: docker/setup-qemu-action@v3

62
.github/workflows/security-scan.yml vendored Normal file
View File

@@ -0,0 +1,62 @@
name: Security Scan
on:
push:
pull_request:
schedule:
- cron: '0 3 * * 1'
permissions:
contents: read
jobs:
backend-security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: backend/go.mod
check-latest: false
cache-dependency-path: backend/go.sum
- name: Verify Go version
run: |
go version | grep -q 'go1.25.5'
- name: Run govulncheck
working-directory: backend
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
- name: Run gosec
working-directory: backend
run: |
go install github.com/securego/gosec/v2/cmd/gosec@latest
gosec -severity high -confidence high ./...
frontend-security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install dependencies
working-directory: frontend
run: pnpm install --frozen-lockfile
- name: Run pnpm audit
working-directory: frontend
run: |
pnpm audit --prod --audit-level=high --json > audit.json || true
- name: Check audit exceptions
run: |
python tools/check_pnpm_audit_exceptions.py \
--audit frontend/audit.json \
--exceptions .github/audit-exceptions.yml

3
.gitignore vendored
View File

@@ -123,3 +123,6 @@ backend/cmd/server/server
deploy/docker-compose.override.yml
.gocache/
vite.config.js
!docs/
docs/*
!docs/dependency-security.md

View File

@@ -7,8 +7,8 @@
# =============================================================================
ARG NODE_IMAGE=node:24-alpine
ARG GOLANG_IMAGE=golang:1.25-alpine
ARG ALPINE_IMAGE=alpine:3.19
ARG GOLANG_IMAGE=golang:1.25.5-alpine
ARG ALPINE_IMAGE=alpine:3.20
ARG GOPROXY=https://goproxy.cn,direct
ARG GOSUMDB=sum.golang.google.cn

View File

@@ -2,7 +2,7 @@
<div align="center">
[![Go](https://img.shields.io/badge/Go-1.21+-00ADD8.svg)](https://golang.org/)
[![Go](https://img.shields.io/badge/Go-1.25.5-00ADD8.svg)](https://golang.org/)
[![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/)
[![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/)
@@ -44,13 +44,19 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot
| Component | Technology |
|-----------|------------|
| Backend | Go 1.21+, Gin, GORM |
| Backend | Go 1.25.5, Gin, GORM |
| Frontend | Vue 3.4+, Vite 5+, TailwindCSS |
| Database | PostgreSQL 15+ |
| Cache/Queue | Redis 7+ |
---
## Documentation
- Dependency Security: `docs/dependency-security.md`
---
## Deployment
### Method 1: Script Installation (Recommended)
@@ -160,6 +166,22 @@ ADMIN_PASSWORD=your_admin_password
# Optional: Custom port
SERVER_PORT=8080
# Optional: Security configuration
# Enable URL allowlist validation (false to skip allowlist checks, only basic format validation)
SECURITY_URL_ALLOWLIST_ENABLED=false
# Allow insecure HTTP URLs when allowlist is disabled (default: false, requires https)
# ⚠️ WARNING: Enabling this allows HTTP (plaintext) URLs which can expose API keys
# Only recommended for:
# - Development/testing environments
# - Internal networks with trusted endpoints
# - When using local test servers (http://localhost)
# PRODUCTION: Keep this false or use HTTPS URLs only
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=false
# Allow private IP addresses for upstream/pricing/CRS (for internal deployments)
SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=false
```
```bash
@@ -276,13 +298,48 @@ Additional security-related options are available in `config.yaml`:
- `cors.allowed_origins` for CORS allowlist
- `security.url_allowlist` for upstream/pricing/CRS host allowlists
- `security.url_allowlist.enabled` to disable URL validation (use with caution)
- `security.url_allowlist.allow_insecure_http` to allow http URLs when validation is disabled
- `security.url_allowlist.allow_insecure_http` to allow HTTP URLs when validation is disabled
- `security.url_allowlist.allow_private_hosts` to allow private/local IP addresses
- `security.response_headers.enabled` to enable configurable response header filtering (disabled uses default allowlist)
- `security.csp` to control Content-Security-Policy headers
- `billing.circuit_breaker` to fail closed on billing errors
- `server.trusted_proxies` to enable X-Forwarded-For parsing
- `turnstile.required` to require Turnstile in release mode
**⚠️ Security Warning: HTTP URL Configuration**
When `security.url_allowlist.enabled=false`, the system performs minimal URL validation by default, **rejecting HTTP URLs** and only allowing HTTPS. To allow HTTP URLs (e.g., for development or internal testing), you must explicitly set:
```yaml
security:
url_allowlist:
enabled: false # Disable allowlist checks
allow_insecure_http: true # Allow HTTP URLs (⚠️ INSECURE)
```
**Or via environment variable:**
```bash
SECURITY_URL_ALLOWLIST_ENABLED=false
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true
```
**Risks of allowing HTTP:**
- API keys and data transmitted in **plaintext** (vulnerable to interception)
- Susceptible to **man-in-the-middle (MITM) attacks**
- **NOT suitable for production** environments
**When to use HTTP:**
- ✅ Development/testing with local servers (http://localhost)
- ✅ Internal networks with trusted endpoints
- ✅ Testing account connectivity before obtaining HTTPS
- ❌ Production environments (use HTTPS only)
**Example error without this setting:**
```
Invalid base URL: invalid url scheme: http
```
If you disable URL validation or response header filtering, harden your network layer:
- Enforce an egress allowlist for upstream domains/IPs
- Block private/loopback/link-local ranges

View File

@@ -2,7 +2,7 @@
<div align="center">
[![Go](https://img.shields.io/badge/Go-1.21+-00ADD8.svg)](https://golang.org/)
[![Go](https://img.shields.io/badge/Go-1.25.5-00ADD8.svg)](https://golang.org/)
[![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/)
[![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/)
@@ -44,13 +44,19 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅(
| 组件 | 技术 |
|------|------|
| 后端 | Go 1.21+, Gin, GORM |
| 后端 | Go 1.25.5, Gin, GORM |
| 前端 | Vue 3.4+, Vite 5+, TailwindCSS |
| 数据库 | PostgreSQL 15+ |
| 缓存/队列 | Redis 7+ |
---
## 文档
- 依赖安全:`docs/dependency-security.md`
---
## 部署方式
### 方式一:脚本安装(推荐)
@@ -160,6 +166,22 @@ ADMIN_PASSWORD=your_admin_password
# 可选:自定义端口
SERVER_PORT=8080
# 可选:安全配置
# 启用 URL 白名单验证false 则跳过白名单检查,仅做基本格式校验)
SECURITY_URL_ALLOWLIST_ENABLED=false
# 关闭白名单时,是否允许 http:// URL默认 false只允许 https://
# ⚠️ 警告:允许 HTTP 会暴露 API 密钥(明文传输)
# 仅建议在以下场景使用:
# - 开发/测试环境
# - 内部可信网络
# - 本地测试服务器http://localhost
# 生产环境:保持 false 或仅使用 HTTPS URL
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=false
# 是否允许私有 IP 地址用于上游/定价/CRS内网部署时使用
SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=false
```
```bash
@@ -276,13 +298,48 @@ default:
- `cors.allowed_origins` 配置 CORS 白名单
- `security.url_allowlist` 配置上游/价格数据/CRS 主机白名单
- `security.url_allowlist.enabled` 可关闭 URL 校验(慎用)
- `security.url_allowlist.allow_insecure_http` 关闭校验时允许 http URL
- `security.url_allowlist.allow_insecure_http` 关闭校验时允许 HTTP URL
- `security.url_allowlist.allow_private_hosts` 允许私有/本地 IP 地址
- `security.response_headers.enabled` 可启用可配置响应头过滤(关闭时使用默认白名单)
- `security.csp` 配置 Content-Security-Policy
- `billing.circuit_breaker` 计费异常时 fail-closed
- `server.trusted_proxies` 启用可信代理解析 X-Forwarded-For
- `turnstile.required` 在 release 模式强制启用 Turnstile
**⚠️ 安全警告HTTP URL 配置**
`security.url_allowlist.enabled=false` 时,系统默认执行最小 URL 校验,**拒绝 HTTP URL**,仅允许 HTTPS。要允许 HTTP URL例如用于开发或内网测试必须显式设置
```yaml
security:
url_allowlist:
enabled: false # 禁用白名单检查
allow_insecure_http: true # 允许 HTTP URL 不安全)
```
**或通过环境变量:**
```bash
SECURITY_URL_ALLOWLIST_ENABLED=false
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true
```
**允许 HTTP 的风险:**
- API 密钥和数据以**明文传输**(可被截获)
- 易受**中间人攻击 (MITM)**
- **不适合生产环境**
**适用场景:**
- ✅ 开发/测试环境的本地服务器http://localhost
- ✅ 内网可信端点
- ✅ 获取 HTTPS 前测试账号连通性
- ❌ 生产环境(仅使用 HTTPS
**未设置此项时的错误示例:**
```
Invalid base URL: invalid url scheme: http
```
如关闭 URL 校验或响应头过滤,请加强网络层防护:
- 出站访问白名单限制上游域名/IP
- 阻断私网/回环/链路本地地址

View File

@@ -105,7 +105,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig)
concurrencyService := service.NewConcurrencyService(concurrencyCache)
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService)
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService)
oAuthHandler := admin.NewOAuthHandler(oAuthService)
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)
geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService)

View File

@@ -45,6 +45,12 @@ type Group struct {
MonthlyLimitUsd *float64 `json:"monthly_limit_usd,omitempty"`
// DefaultValidityDays holds the value of the "default_validity_days" field.
DefaultValidityDays int `json:"default_validity_days,omitempty"`
// ImagePrice1k holds the value of the "image_price_1k" field.
ImagePrice1k *float64 `json:"image_price_1k,omitempty"`
// ImagePrice2k holds the value of the "image_price_2k" field.
ImagePrice2k *float64 `json:"image_price_2k,omitempty"`
// ImagePrice4k holds the value of the "image_price_4k" field.
ImagePrice4k *float64 `json:"image_price_4k,omitempty"`
// Edges holds the relations/edges for other nodes in the graph.
// The values are being populated by the GroupQuery when eager-loading is set.
Edges GroupEdges `json:"edges"`
@@ -153,7 +159,7 @@ func (*Group) scanValues(columns []string) ([]any, error) {
switch columns[i] {
case group.FieldIsExclusive:
values[i] = new(sql.NullBool)
case group.FieldRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd:
case group.FieldRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k:
values[i] = new(sql.NullFloat64)
case group.FieldID, group.FieldDefaultValidityDays:
values[i] = new(sql.NullInt64)
@@ -271,6 +277,27 @@ func (_m *Group) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.DefaultValidityDays = int(value.Int64)
}
case group.FieldImagePrice1k:
if value, ok := values[i].(*sql.NullFloat64); !ok {
return fmt.Errorf("unexpected type %T for field image_price_1k", values[i])
} else if value.Valid {
_m.ImagePrice1k = new(float64)
*_m.ImagePrice1k = value.Float64
}
case group.FieldImagePrice2k:
if value, ok := values[i].(*sql.NullFloat64); !ok {
return fmt.Errorf("unexpected type %T for field image_price_2k", values[i])
} else if value.Valid {
_m.ImagePrice2k = new(float64)
*_m.ImagePrice2k = value.Float64
}
case group.FieldImagePrice4k:
if value, ok := values[i].(*sql.NullFloat64); !ok {
return fmt.Errorf("unexpected type %T for field image_price_4k", values[i])
} else if value.Valid {
_m.ImagePrice4k = new(float64)
*_m.ImagePrice4k = value.Float64
}
default:
_m.selectValues.Set(columns[i], values[i])
}
@@ -398,6 +425,21 @@ func (_m *Group) String() string {
builder.WriteString(", ")
builder.WriteString("default_validity_days=")
builder.WriteString(fmt.Sprintf("%v", _m.DefaultValidityDays))
builder.WriteString(", ")
if v := _m.ImagePrice1k; v != nil {
builder.WriteString("image_price_1k=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
if v := _m.ImagePrice2k; v != nil {
builder.WriteString("image_price_2k=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
if v := _m.ImagePrice4k; v != nil {
builder.WriteString("image_price_4k=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteByte(')')
return builder.String()
}

View File

@@ -43,6 +43,12 @@ const (
FieldMonthlyLimitUsd = "monthly_limit_usd"
// FieldDefaultValidityDays holds the string denoting the default_validity_days field in the database.
FieldDefaultValidityDays = "default_validity_days"
// FieldImagePrice1k holds the string denoting the image_price_1k field in the database.
FieldImagePrice1k = "image_price_1k"
// FieldImagePrice2k holds the string denoting the image_price_2k field in the database.
FieldImagePrice2k = "image_price_2k"
// FieldImagePrice4k holds the string denoting the image_price_4k field in the database.
FieldImagePrice4k = "image_price_4k"
// EdgeAPIKeys holds the string denoting the api_keys edge name in mutations.
EdgeAPIKeys = "api_keys"
// EdgeRedeemCodes holds the string denoting the redeem_codes edge name in mutations.
@@ -132,6 +138,9 @@ var Columns = []string{
FieldWeeklyLimitUsd,
FieldMonthlyLimitUsd,
FieldDefaultValidityDays,
FieldImagePrice1k,
FieldImagePrice2k,
FieldImagePrice4k,
}
var (
@@ -267,6 +276,21 @@ func ByDefaultValidityDays(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldDefaultValidityDays, opts...).ToFunc()
}
// ByImagePrice1k orders the results by the image_price_1k field.
func ByImagePrice1k(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldImagePrice1k, opts...).ToFunc()
}
// ByImagePrice2k orders the results by the image_price_2k field.
func ByImagePrice2k(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldImagePrice2k, opts...).ToFunc()
}
// ByImagePrice4k orders the results by the image_price_4k field.
func ByImagePrice4k(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldImagePrice4k, opts...).ToFunc()
}
// ByAPIKeysCount orders the results by api_keys count.
func ByAPIKeysCount(opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {

View File

@@ -125,6 +125,21 @@ func DefaultValidityDays(v int) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldDefaultValidityDays, v))
}
// ImagePrice1k applies equality check predicate on the "image_price_1k" field. It's identical to ImagePrice1kEQ.
func ImagePrice1k(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldImagePrice1k, v))
}
// ImagePrice2k applies equality check predicate on the "image_price_2k" field. It's identical to ImagePrice2kEQ.
func ImagePrice2k(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldImagePrice2k, v))
}
// ImagePrice4k applies equality check predicate on the "image_price_4k" field. It's identical to ImagePrice4kEQ.
func ImagePrice4k(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldImagePrice4k, v))
}
// CreatedAtEQ applies the EQ predicate on the "created_at" field.
func CreatedAtEQ(v time.Time) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldCreatedAt, v))
@@ -830,6 +845,156 @@ func DefaultValidityDaysLTE(v int) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldDefaultValidityDays, v))
}
// ImagePrice1kEQ applies the EQ predicate on the "image_price_1k" field.
func ImagePrice1kEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldImagePrice1k, v))
}
// ImagePrice1kNEQ applies the NEQ predicate on the "image_price_1k" field.
func ImagePrice1kNEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldImagePrice1k, v))
}
// ImagePrice1kIn applies the In predicate on the "image_price_1k" field.
func ImagePrice1kIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldIn(FieldImagePrice1k, vs...))
}
// ImagePrice1kNotIn applies the NotIn predicate on the "image_price_1k" field.
func ImagePrice1kNotIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldNotIn(FieldImagePrice1k, vs...))
}
// ImagePrice1kGT applies the GT predicate on the "image_price_1k" field.
func ImagePrice1kGT(v float64) predicate.Group {
return predicate.Group(sql.FieldGT(FieldImagePrice1k, v))
}
// ImagePrice1kGTE applies the GTE predicate on the "image_price_1k" field.
func ImagePrice1kGTE(v float64) predicate.Group {
return predicate.Group(sql.FieldGTE(FieldImagePrice1k, v))
}
// ImagePrice1kLT applies the LT predicate on the "image_price_1k" field.
func ImagePrice1kLT(v float64) predicate.Group {
return predicate.Group(sql.FieldLT(FieldImagePrice1k, v))
}
// ImagePrice1kLTE applies the LTE predicate on the "image_price_1k" field.
func ImagePrice1kLTE(v float64) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldImagePrice1k, v))
}
// ImagePrice1kIsNil applies the IsNil predicate on the "image_price_1k" field.
func ImagePrice1kIsNil() predicate.Group {
return predicate.Group(sql.FieldIsNull(FieldImagePrice1k))
}
// ImagePrice1kNotNil applies the NotNil predicate on the "image_price_1k" field.
func ImagePrice1kNotNil() predicate.Group {
return predicate.Group(sql.FieldNotNull(FieldImagePrice1k))
}
// ImagePrice2kEQ applies the EQ predicate on the "image_price_2k" field.
func ImagePrice2kEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldImagePrice2k, v))
}
// ImagePrice2kNEQ applies the NEQ predicate on the "image_price_2k" field.
func ImagePrice2kNEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldImagePrice2k, v))
}
// ImagePrice2kIn applies the In predicate on the "image_price_2k" field.
func ImagePrice2kIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldIn(FieldImagePrice2k, vs...))
}
// ImagePrice2kNotIn applies the NotIn predicate on the "image_price_2k" field.
func ImagePrice2kNotIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldNotIn(FieldImagePrice2k, vs...))
}
// ImagePrice2kGT applies the GT predicate on the "image_price_2k" field.
func ImagePrice2kGT(v float64) predicate.Group {
return predicate.Group(sql.FieldGT(FieldImagePrice2k, v))
}
// ImagePrice2kGTE applies the GTE predicate on the "image_price_2k" field.
func ImagePrice2kGTE(v float64) predicate.Group {
return predicate.Group(sql.FieldGTE(FieldImagePrice2k, v))
}
// ImagePrice2kLT applies the LT predicate on the "image_price_2k" field.
func ImagePrice2kLT(v float64) predicate.Group {
return predicate.Group(sql.FieldLT(FieldImagePrice2k, v))
}
// ImagePrice2kLTE applies the LTE predicate on the "image_price_2k" field.
func ImagePrice2kLTE(v float64) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldImagePrice2k, v))
}
// ImagePrice2kIsNil applies the IsNil predicate on the "image_price_2k" field.
func ImagePrice2kIsNil() predicate.Group {
return predicate.Group(sql.FieldIsNull(FieldImagePrice2k))
}
// ImagePrice2kNotNil applies the NotNil predicate on the "image_price_2k" field.
func ImagePrice2kNotNil() predicate.Group {
return predicate.Group(sql.FieldNotNull(FieldImagePrice2k))
}
// ImagePrice4kEQ applies the EQ predicate on the "image_price_4k" field.
func ImagePrice4kEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldImagePrice4k, v))
}
// ImagePrice4kNEQ applies the NEQ predicate on the "image_price_4k" field.
func ImagePrice4kNEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldImagePrice4k, v))
}
// ImagePrice4kIn applies the In predicate on the "image_price_4k" field.
func ImagePrice4kIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldIn(FieldImagePrice4k, vs...))
}
// ImagePrice4kNotIn applies the NotIn predicate on the "image_price_4k" field.
func ImagePrice4kNotIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldNotIn(FieldImagePrice4k, vs...))
}
// ImagePrice4kGT applies the GT predicate on the "image_price_4k" field.
func ImagePrice4kGT(v float64) predicate.Group {
return predicate.Group(sql.FieldGT(FieldImagePrice4k, v))
}
// ImagePrice4kGTE applies the GTE predicate on the "image_price_4k" field.
func ImagePrice4kGTE(v float64) predicate.Group {
return predicate.Group(sql.FieldGTE(FieldImagePrice4k, v))
}
// ImagePrice4kLT applies the LT predicate on the "image_price_4k" field.
func ImagePrice4kLT(v float64) predicate.Group {
return predicate.Group(sql.FieldLT(FieldImagePrice4k, v))
}
// ImagePrice4kLTE applies the LTE predicate on the "image_price_4k" field.
func ImagePrice4kLTE(v float64) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldImagePrice4k, v))
}
// ImagePrice4kIsNil applies the IsNil predicate on the "image_price_4k" field.
func ImagePrice4kIsNil() predicate.Group {
return predicate.Group(sql.FieldIsNull(FieldImagePrice4k))
}
// ImagePrice4kNotNil applies the NotNil predicate on the "image_price_4k" field.
func ImagePrice4kNotNil() predicate.Group {
return predicate.Group(sql.FieldNotNull(FieldImagePrice4k))
}
// HasAPIKeys applies the HasEdge predicate on the "api_keys" edge.
func HasAPIKeys() predicate.Group {
return predicate.Group(func(s *sql.Selector) {

View File

@@ -216,6 +216,48 @@ func (_c *GroupCreate) SetNillableDefaultValidityDays(v *int) *GroupCreate {
return _c
}
// SetImagePrice1k sets the "image_price_1k" field.
func (_c *GroupCreate) SetImagePrice1k(v float64) *GroupCreate {
_c.mutation.SetImagePrice1k(v)
return _c
}
// SetNillableImagePrice1k sets the "image_price_1k" field if the given value is not nil.
func (_c *GroupCreate) SetNillableImagePrice1k(v *float64) *GroupCreate {
if v != nil {
_c.SetImagePrice1k(*v)
}
return _c
}
// SetImagePrice2k sets the "image_price_2k" field.
func (_c *GroupCreate) SetImagePrice2k(v float64) *GroupCreate {
_c.mutation.SetImagePrice2k(v)
return _c
}
// SetNillableImagePrice2k sets the "image_price_2k" field if the given value is not nil.
func (_c *GroupCreate) SetNillableImagePrice2k(v *float64) *GroupCreate {
if v != nil {
_c.SetImagePrice2k(*v)
}
return _c
}
// SetImagePrice4k sets the "image_price_4k" field.
func (_c *GroupCreate) SetImagePrice4k(v float64) *GroupCreate {
_c.mutation.SetImagePrice4k(v)
return _c
}
// SetNillableImagePrice4k sets the "image_price_4k" field if the given value is not nil.
func (_c *GroupCreate) SetNillableImagePrice4k(v *float64) *GroupCreate {
if v != nil {
_c.SetImagePrice4k(*v)
}
return _c
}
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
func (_c *GroupCreate) AddAPIKeyIDs(ids ...int64) *GroupCreate {
_c.mutation.AddAPIKeyIDs(ids...)
@@ -516,6 +558,18 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
_spec.SetField(group.FieldDefaultValidityDays, field.TypeInt, value)
_node.DefaultValidityDays = value
}
if value, ok := _c.mutation.ImagePrice1k(); ok {
_spec.SetField(group.FieldImagePrice1k, field.TypeFloat64, value)
_node.ImagePrice1k = &value
}
if value, ok := _c.mutation.ImagePrice2k(); ok {
_spec.SetField(group.FieldImagePrice2k, field.TypeFloat64, value)
_node.ImagePrice2k = &value
}
if value, ok := _c.mutation.ImagePrice4k(); ok {
_spec.SetField(group.FieldImagePrice4k, field.TypeFloat64, value)
_node.ImagePrice4k = &value
}
if nodes := _c.mutation.APIKeysIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
@@ -888,6 +942,78 @@ func (u *GroupUpsert) AddDefaultValidityDays(v int) *GroupUpsert {
return u
}
// SetImagePrice1k sets the "image_price_1k" field.
func (u *GroupUpsert) SetImagePrice1k(v float64) *GroupUpsert {
u.Set(group.FieldImagePrice1k, v)
return u
}
// UpdateImagePrice1k sets the "image_price_1k" field to the value that was provided on create.
func (u *GroupUpsert) UpdateImagePrice1k() *GroupUpsert {
u.SetExcluded(group.FieldImagePrice1k)
return u
}
// AddImagePrice1k adds v to the "image_price_1k" field.
func (u *GroupUpsert) AddImagePrice1k(v float64) *GroupUpsert {
u.Add(group.FieldImagePrice1k, v)
return u
}
// ClearImagePrice1k clears the value of the "image_price_1k" field.
func (u *GroupUpsert) ClearImagePrice1k() *GroupUpsert {
u.SetNull(group.FieldImagePrice1k)
return u
}
// SetImagePrice2k sets the "image_price_2k" field.
func (u *GroupUpsert) SetImagePrice2k(v float64) *GroupUpsert {
u.Set(group.FieldImagePrice2k, v)
return u
}
// UpdateImagePrice2k sets the "image_price_2k" field to the value that was provided on create.
func (u *GroupUpsert) UpdateImagePrice2k() *GroupUpsert {
u.SetExcluded(group.FieldImagePrice2k)
return u
}
// AddImagePrice2k adds v to the "image_price_2k" field.
func (u *GroupUpsert) AddImagePrice2k(v float64) *GroupUpsert {
u.Add(group.FieldImagePrice2k, v)
return u
}
// ClearImagePrice2k clears the value of the "image_price_2k" field.
func (u *GroupUpsert) ClearImagePrice2k() *GroupUpsert {
u.SetNull(group.FieldImagePrice2k)
return u
}
// SetImagePrice4k sets the "image_price_4k" field.
func (u *GroupUpsert) SetImagePrice4k(v float64) *GroupUpsert {
u.Set(group.FieldImagePrice4k, v)
return u
}
// UpdateImagePrice4k sets the "image_price_4k" field to the value that was provided on create.
func (u *GroupUpsert) UpdateImagePrice4k() *GroupUpsert {
u.SetExcluded(group.FieldImagePrice4k)
return u
}
// AddImagePrice4k adds v to the "image_price_4k" field.
func (u *GroupUpsert) AddImagePrice4k(v float64) *GroupUpsert {
u.Add(group.FieldImagePrice4k, v)
return u
}
// ClearImagePrice4k clears the value of the "image_price_4k" field.
func (u *GroupUpsert) ClearImagePrice4k() *GroupUpsert {
u.SetNull(group.FieldImagePrice4k)
return u
}
// UpdateNewValues updates the mutable fields using the new values that were set on create.
// Using this option is equivalent to using:
//
@@ -1185,6 +1311,90 @@ func (u *GroupUpsertOne) UpdateDefaultValidityDays() *GroupUpsertOne {
})
}
// SetImagePrice1k sets the "image_price_1k" field.
func (u *GroupUpsertOne) SetImagePrice1k(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetImagePrice1k(v)
})
}
// AddImagePrice1k adds v to the "image_price_1k" field.
func (u *GroupUpsertOne) AddImagePrice1k(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.AddImagePrice1k(v)
})
}
// UpdateImagePrice1k sets the "image_price_1k" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateImagePrice1k() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateImagePrice1k()
})
}
// ClearImagePrice1k clears the value of the "image_price_1k" field.
func (u *GroupUpsertOne) ClearImagePrice1k() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.ClearImagePrice1k()
})
}
// SetImagePrice2k sets the "image_price_2k" field.
func (u *GroupUpsertOne) SetImagePrice2k(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetImagePrice2k(v)
})
}
// AddImagePrice2k adds v to the "image_price_2k" field.
func (u *GroupUpsertOne) AddImagePrice2k(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.AddImagePrice2k(v)
})
}
// UpdateImagePrice2k sets the "image_price_2k" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateImagePrice2k() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateImagePrice2k()
})
}
// ClearImagePrice2k clears the value of the "image_price_2k" field.
func (u *GroupUpsertOne) ClearImagePrice2k() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.ClearImagePrice2k()
})
}
// SetImagePrice4k sets the "image_price_4k" field.
func (u *GroupUpsertOne) SetImagePrice4k(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetImagePrice4k(v)
})
}
// AddImagePrice4k adds v to the "image_price_4k" field.
func (u *GroupUpsertOne) AddImagePrice4k(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.AddImagePrice4k(v)
})
}
// UpdateImagePrice4k sets the "image_price_4k" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateImagePrice4k() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateImagePrice4k()
})
}
// ClearImagePrice4k clears the value of the "image_price_4k" field.
func (u *GroupUpsertOne) ClearImagePrice4k() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.ClearImagePrice4k()
})
}
// Exec executes the query.
func (u *GroupUpsertOne) Exec(ctx context.Context) error {
if len(u.create.conflict) == 0 {
@@ -1648,6 +1858,90 @@ func (u *GroupUpsertBulk) UpdateDefaultValidityDays() *GroupUpsertBulk {
})
}
// SetImagePrice1k sets the "image_price_1k" field.
func (u *GroupUpsertBulk) SetImagePrice1k(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetImagePrice1k(v)
})
}
// AddImagePrice1k adds v to the "image_price_1k" field.
func (u *GroupUpsertBulk) AddImagePrice1k(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.AddImagePrice1k(v)
})
}
// UpdateImagePrice1k sets the "image_price_1k" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateImagePrice1k() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateImagePrice1k()
})
}
// ClearImagePrice1k clears the value of the "image_price_1k" field.
func (u *GroupUpsertBulk) ClearImagePrice1k() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.ClearImagePrice1k()
})
}
// SetImagePrice2k sets the "image_price_2k" field.
func (u *GroupUpsertBulk) SetImagePrice2k(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetImagePrice2k(v)
})
}
// AddImagePrice2k adds v to the "image_price_2k" field.
func (u *GroupUpsertBulk) AddImagePrice2k(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.AddImagePrice2k(v)
})
}
// UpdateImagePrice2k sets the "image_price_2k" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateImagePrice2k() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateImagePrice2k()
})
}
// ClearImagePrice2k clears the value of the "image_price_2k" field.
func (u *GroupUpsertBulk) ClearImagePrice2k() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.ClearImagePrice2k()
})
}
// SetImagePrice4k sets the "image_price_4k" field.
func (u *GroupUpsertBulk) SetImagePrice4k(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetImagePrice4k(v)
})
}
// AddImagePrice4k adds v to the "image_price_4k" field.
func (u *GroupUpsertBulk) AddImagePrice4k(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.AddImagePrice4k(v)
})
}
// UpdateImagePrice4k sets the "image_price_4k" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateImagePrice4k() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateImagePrice4k()
})
}
// ClearImagePrice4k clears the value of the "image_price_4k" field.
func (u *GroupUpsertBulk) ClearImagePrice4k() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.ClearImagePrice4k()
})
}
// Exec executes the query.
func (u *GroupUpsertBulk) Exec(ctx context.Context) error {
if u.create.err != nil {

View File

@@ -273,6 +273,87 @@ func (_u *GroupUpdate) AddDefaultValidityDays(v int) *GroupUpdate {
return _u
}
// SetImagePrice1k sets the "image_price_1k" field.
func (_u *GroupUpdate) SetImagePrice1k(v float64) *GroupUpdate {
_u.mutation.ResetImagePrice1k()
_u.mutation.SetImagePrice1k(v)
return _u
}
// SetNillableImagePrice1k sets the "image_price_1k" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableImagePrice1k(v *float64) *GroupUpdate {
if v != nil {
_u.SetImagePrice1k(*v)
}
return _u
}
// AddImagePrice1k adds value to the "image_price_1k" field.
func (_u *GroupUpdate) AddImagePrice1k(v float64) *GroupUpdate {
_u.mutation.AddImagePrice1k(v)
return _u
}
// ClearImagePrice1k clears the value of the "image_price_1k" field.
func (_u *GroupUpdate) ClearImagePrice1k() *GroupUpdate {
_u.mutation.ClearImagePrice1k()
return _u
}
// SetImagePrice2k sets the "image_price_2k" field.
func (_u *GroupUpdate) SetImagePrice2k(v float64) *GroupUpdate {
_u.mutation.ResetImagePrice2k()
_u.mutation.SetImagePrice2k(v)
return _u
}
// SetNillableImagePrice2k sets the "image_price_2k" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableImagePrice2k(v *float64) *GroupUpdate {
if v != nil {
_u.SetImagePrice2k(*v)
}
return _u
}
// AddImagePrice2k adds value to the "image_price_2k" field.
func (_u *GroupUpdate) AddImagePrice2k(v float64) *GroupUpdate {
_u.mutation.AddImagePrice2k(v)
return _u
}
// ClearImagePrice2k clears the value of the "image_price_2k" field.
func (_u *GroupUpdate) ClearImagePrice2k() *GroupUpdate {
_u.mutation.ClearImagePrice2k()
return _u
}
// SetImagePrice4k sets the "image_price_4k" field.
func (_u *GroupUpdate) SetImagePrice4k(v float64) *GroupUpdate {
_u.mutation.ResetImagePrice4k()
_u.mutation.SetImagePrice4k(v)
return _u
}
// SetNillableImagePrice4k sets the "image_price_4k" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableImagePrice4k(v *float64) *GroupUpdate {
if v != nil {
_u.SetImagePrice4k(*v)
}
return _u
}
// AddImagePrice4k adds value to the "image_price_4k" field.
func (_u *GroupUpdate) AddImagePrice4k(v float64) *GroupUpdate {
_u.mutation.AddImagePrice4k(v)
return _u
}
// ClearImagePrice4k clears the value of the "image_price_4k" field.
func (_u *GroupUpdate) ClearImagePrice4k() *GroupUpdate {
_u.mutation.ClearImagePrice4k()
return _u
}
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
func (_u *GroupUpdate) AddAPIKeyIDs(ids ...int64) *GroupUpdate {
_u.mutation.AddAPIKeyIDs(ids...)
@@ -642,6 +723,33 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if value, ok := _u.mutation.AddedDefaultValidityDays(); ok {
_spec.AddField(group.FieldDefaultValidityDays, field.TypeInt, value)
}
if value, ok := _u.mutation.ImagePrice1k(); ok {
_spec.SetField(group.FieldImagePrice1k, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedImagePrice1k(); ok {
_spec.AddField(group.FieldImagePrice1k, field.TypeFloat64, value)
}
if _u.mutation.ImagePrice1kCleared() {
_spec.ClearField(group.FieldImagePrice1k, field.TypeFloat64)
}
if value, ok := _u.mutation.ImagePrice2k(); ok {
_spec.SetField(group.FieldImagePrice2k, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedImagePrice2k(); ok {
_spec.AddField(group.FieldImagePrice2k, field.TypeFloat64, value)
}
if _u.mutation.ImagePrice2kCleared() {
_spec.ClearField(group.FieldImagePrice2k, field.TypeFloat64)
}
if value, ok := _u.mutation.ImagePrice4k(); ok {
_spec.SetField(group.FieldImagePrice4k, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedImagePrice4k(); ok {
_spec.AddField(group.FieldImagePrice4k, field.TypeFloat64, value)
}
if _u.mutation.ImagePrice4kCleared() {
_spec.ClearField(group.FieldImagePrice4k, field.TypeFloat64)
}
if _u.mutation.APIKeysCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
@@ -1195,6 +1303,87 @@ func (_u *GroupUpdateOne) AddDefaultValidityDays(v int) *GroupUpdateOne {
return _u
}
// SetImagePrice1k sets the "image_price_1k" field.
func (_u *GroupUpdateOne) SetImagePrice1k(v float64) *GroupUpdateOne {
_u.mutation.ResetImagePrice1k()
_u.mutation.SetImagePrice1k(v)
return _u
}
// SetNillableImagePrice1k sets the "image_price_1k" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableImagePrice1k(v *float64) *GroupUpdateOne {
if v != nil {
_u.SetImagePrice1k(*v)
}
return _u
}
// AddImagePrice1k adds value to the "image_price_1k" field.
func (_u *GroupUpdateOne) AddImagePrice1k(v float64) *GroupUpdateOne {
_u.mutation.AddImagePrice1k(v)
return _u
}
// ClearImagePrice1k clears the value of the "image_price_1k" field.
func (_u *GroupUpdateOne) ClearImagePrice1k() *GroupUpdateOne {
_u.mutation.ClearImagePrice1k()
return _u
}
// SetImagePrice2k sets the "image_price_2k" field.
func (_u *GroupUpdateOne) SetImagePrice2k(v float64) *GroupUpdateOne {
_u.mutation.ResetImagePrice2k()
_u.mutation.SetImagePrice2k(v)
return _u
}
// SetNillableImagePrice2k sets the "image_price_2k" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableImagePrice2k(v *float64) *GroupUpdateOne {
if v != nil {
_u.SetImagePrice2k(*v)
}
return _u
}
// AddImagePrice2k adds value to the "image_price_2k" field.
func (_u *GroupUpdateOne) AddImagePrice2k(v float64) *GroupUpdateOne {
_u.mutation.AddImagePrice2k(v)
return _u
}
// ClearImagePrice2k clears the value of the "image_price_2k" field.
func (_u *GroupUpdateOne) ClearImagePrice2k() *GroupUpdateOne {
_u.mutation.ClearImagePrice2k()
return _u
}
// SetImagePrice4k sets the "image_price_4k" field.
func (_u *GroupUpdateOne) SetImagePrice4k(v float64) *GroupUpdateOne {
_u.mutation.ResetImagePrice4k()
_u.mutation.SetImagePrice4k(v)
return _u
}
// SetNillableImagePrice4k sets the "image_price_4k" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableImagePrice4k(v *float64) *GroupUpdateOne {
if v != nil {
_u.SetImagePrice4k(*v)
}
return _u
}
// AddImagePrice4k adds value to the "image_price_4k" field.
func (_u *GroupUpdateOne) AddImagePrice4k(v float64) *GroupUpdateOne {
_u.mutation.AddImagePrice4k(v)
return _u
}
// ClearImagePrice4k clears the value of the "image_price_4k" field.
func (_u *GroupUpdateOne) ClearImagePrice4k() *GroupUpdateOne {
_u.mutation.ClearImagePrice4k()
return _u
}
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
func (_u *GroupUpdateOne) AddAPIKeyIDs(ids ...int64) *GroupUpdateOne {
_u.mutation.AddAPIKeyIDs(ids...)
@@ -1594,6 +1783,33 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
if value, ok := _u.mutation.AddedDefaultValidityDays(); ok {
_spec.AddField(group.FieldDefaultValidityDays, field.TypeInt, value)
}
if value, ok := _u.mutation.ImagePrice1k(); ok {
_spec.SetField(group.FieldImagePrice1k, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedImagePrice1k(); ok {
_spec.AddField(group.FieldImagePrice1k, field.TypeFloat64, value)
}
if _u.mutation.ImagePrice1kCleared() {
_spec.ClearField(group.FieldImagePrice1k, field.TypeFloat64)
}
if value, ok := _u.mutation.ImagePrice2k(); ok {
_spec.SetField(group.FieldImagePrice2k, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedImagePrice2k(); ok {
_spec.AddField(group.FieldImagePrice2k, field.TypeFloat64, value)
}
if _u.mutation.ImagePrice2kCleared() {
_spec.ClearField(group.FieldImagePrice2k, field.TypeFloat64)
}
if value, ok := _u.mutation.ImagePrice4k(); ok {
_spec.SetField(group.FieldImagePrice4k, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedImagePrice4k(); ok {
_spec.AddField(group.FieldImagePrice4k, field.TypeFloat64, value)
}
if _u.mutation.ImagePrice4kCleared() {
_spec.ClearField(group.FieldImagePrice4k, field.TypeFloat64)
}
if _u.mutation.APIKeysCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,

View File

@@ -216,6 +216,9 @@ var (
{Name: "weekly_limit_usd", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "monthly_limit_usd", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "default_validity_days", Type: field.TypeInt, Default: 30},
{Name: "image_price_1k", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "image_price_2k", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "image_price_4k", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
}
// GroupsTable holds the schema information for the "groups" table.
GroupsTable = &schema.Table{
@@ -368,6 +371,8 @@ var (
{Name: "stream", Type: field.TypeBool, Default: false},
{Name: "duration_ms", Type: field.TypeInt, Nullable: true},
{Name: "first_token_ms", Type: field.TypeInt, Nullable: true},
{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"}},
{Name: "api_key_id", Type: field.TypeInt64},
{Name: "account_id", Type: field.TypeInt64},
@@ -383,31 +388,31 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "usage_logs_api_keys_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[21]},
Columns: []*schema.Column{UsageLogsColumns[23]},
RefColumns: []*schema.Column{APIKeysColumns[0]},
OnDelete: schema.NoAction,
},
{
Symbol: "usage_logs_accounts_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[22]},
Columns: []*schema.Column{UsageLogsColumns[24]},
RefColumns: []*schema.Column{AccountsColumns[0]},
OnDelete: schema.NoAction,
},
{
Symbol: "usage_logs_groups_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[23]},
Columns: []*schema.Column{UsageLogsColumns[25]},
RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.SetNull,
},
{
Symbol: "usage_logs_users_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[24]},
Columns: []*schema.Column{UsageLogsColumns[26]},
RefColumns: []*schema.Column{UsersColumns[0]},
OnDelete: schema.NoAction,
},
{
Symbol: "usage_logs_user_subscriptions_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[25]},
Columns: []*schema.Column{UsageLogsColumns[27]},
RefColumns: []*schema.Column{UserSubscriptionsColumns[0]},
OnDelete: schema.SetNull,
},
@@ -416,32 +421,32 @@ var (
{
Name: "usagelog_user_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[24]},
Columns: []*schema.Column{UsageLogsColumns[26]},
},
{
Name: "usagelog_api_key_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[21]},
Columns: []*schema.Column{UsageLogsColumns[23]},
},
{
Name: "usagelog_account_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[22]},
Columns: []*schema.Column{UsageLogsColumns[24]},
},
{
Name: "usagelog_group_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[23]},
Columns: []*schema.Column{UsageLogsColumns[25]},
},
{
Name: "usagelog_subscription_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[25]},
Columns: []*schema.Column{UsageLogsColumns[27]},
},
{
Name: "usagelog_created_at",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[20]},
Columns: []*schema.Column{UsageLogsColumns[22]},
},
{
Name: "usagelog_model",
@@ -456,12 +461,12 @@ var (
{
Name: "usagelog_user_id_created_at",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[24], UsageLogsColumns[20]},
Columns: []*schema.Column{UsageLogsColumns[26], UsageLogsColumns[22]},
},
{
Name: "usagelog_api_key_id_created_at",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[21], UsageLogsColumns[20]},
Columns: []*schema.Column{UsageLogsColumns[23], UsageLogsColumns[22]},
},
},
}

View File

@@ -3457,6 +3457,12 @@ type GroupMutation struct {
addmonthly_limit_usd *float64
default_validity_days *int
adddefault_validity_days *int
image_price_1k *float64
addimage_price_1k *float64
image_price_2k *float64
addimage_price_2k *float64
image_price_4k *float64
addimage_price_4k *float64
clearedFields map[string]struct{}
api_keys map[int64]struct{}
removedapi_keys map[int64]struct{}
@@ -4251,6 +4257,216 @@ func (m *GroupMutation) ResetDefaultValidityDays() {
m.adddefault_validity_days = nil
}
// SetImagePrice1k sets the "image_price_1k" field.
func (m *GroupMutation) SetImagePrice1k(f float64) {
m.image_price_1k = &f
m.addimage_price_1k = nil
}
// ImagePrice1k returns the value of the "image_price_1k" field in the mutation.
func (m *GroupMutation) ImagePrice1k() (r float64, exists bool) {
v := m.image_price_1k
if v == nil {
return
}
return *v, true
}
// OldImagePrice1k returns the old "image_price_1k" field's value of the Group entity.
// If the Group 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 *GroupMutation) OldImagePrice1k(ctx context.Context) (v *float64, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldImagePrice1k is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldImagePrice1k requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldImagePrice1k: %w", err)
}
return oldValue.ImagePrice1k, nil
}
// AddImagePrice1k adds f to the "image_price_1k" field.
func (m *GroupMutation) AddImagePrice1k(f float64) {
if m.addimage_price_1k != nil {
*m.addimage_price_1k += f
} else {
m.addimage_price_1k = &f
}
}
// AddedImagePrice1k returns the value that was added to the "image_price_1k" field in this mutation.
func (m *GroupMutation) AddedImagePrice1k() (r float64, exists bool) {
v := m.addimage_price_1k
if v == nil {
return
}
return *v, true
}
// ClearImagePrice1k clears the value of the "image_price_1k" field.
func (m *GroupMutation) ClearImagePrice1k() {
m.image_price_1k = nil
m.addimage_price_1k = nil
m.clearedFields[group.FieldImagePrice1k] = struct{}{}
}
// ImagePrice1kCleared returns if the "image_price_1k" field was cleared in this mutation.
func (m *GroupMutation) ImagePrice1kCleared() bool {
_, ok := m.clearedFields[group.FieldImagePrice1k]
return ok
}
// ResetImagePrice1k resets all changes to the "image_price_1k" field.
func (m *GroupMutation) ResetImagePrice1k() {
m.image_price_1k = nil
m.addimage_price_1k = nil
delete(m.clearedFields, group.FieldImagePrice1k)
}
// SetImagePrice2k sets the "image_price_2k" field.
func (m *GroupMutation) SetImagePrice2k(f float64) {
m.image_price_2k = &f
m.addimage_price_2k = nil
}
// ImagePrice2k returns the value of the "image_price_2k" field in the mutation.
func (m *GroupMutation) ImagePrice2k() (r float64, exists bool) {
v := m.image_price_2k
if v == nil {
return
}
return *v, true
}
// OldImagePrice2k returns the old "image_price_2k" field's value of the Group entity.
// If the Group 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 *GroupMutation) OldImagePrice2k(ctx context.Context) (v *float64, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldImagePrice2k is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldImagePrice2k requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldImagePrice2k: %w", err)
}
return oldValue.ImagePrice2k, nil
}
// AddImagePrice2k adds f to the "image_price_2k" field.
func (m *GroupMutation) AddImagePrice2k(f float64) {
if m.addimage_price_2k != nil {
*m.addimage_price_2k += f
} else {
m.addimage_price_2k = &f
}
}
// AddedImagePrice2k returns the value that was added to the "image_price_2k" field in this mutation.
func (m *GroupMutation) AddedImagePrice2k() (r float64, exists bool) {
v := m.addimage_price_2k
if v == nil {
return
}
return *v, true
}
// ClearImagePrice2k clears the value of the "image_price_2k" field.
func (m *GroupMutation) ClearImagePrice2k() {
m.image_price_2k = nil
m.addimage_price_2k = nil
m.clearedFields[group.FieldImagePrice2k] = struct{}{}
}
// ImagePrice2kCleared returns if the "image_price_2k" field was cleared in this mutation.
func (m *GroupMutation) ImagePrice2kCleared() bool {
_, ok := m.clearedFields[group.FieldImagePrice2k]
return ok
}
// ResetImagePrice2k resets all changes to the "image_price_2k" field.
func (m *GroupMutation) ResetImagePrice2k() {
m.image_price_2k = nil
m.addimage_price_2k = nil
delete(m.clearedFields, group.FieldImagePrice2k)
}
// SetImagePrice4k sets the "image_price_4k" field.
func (m *GroupMutation) SetImagePrice4k(f float64) {
m.image_price_4k = &f
m.addimage_price_4k = nil
}
// ImagePrice4k returns the value of the "image_price_4k" field in the mutation.
func (m *GroupMutation) ImagePrice4k() (r float64, exists bool) {
v := m.image_price_4k
if v == nil {
return
}
return *v, true
}
// OldImagePrice4k returns the old "image_price_4k" field's value of the Group entity.
// If the Group 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 *GroupMutation) OldImagePrice4k(ctx context.Context) (v *float64, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldImagePrice4k is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldImagePrice4k requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldImagePrice4k: %w", err)
}
return oldValue.ImagePrice4k, nil
}
// AddImagePrice4k adds f to the "image_price_4k" field.
func (m *GroupMutation) AddImagePrice4k(f float64) {
if m.addimage_price_4k != nil {
*m.addimage_price_4k += f
} else {
m.addimage_price_4k = &f
}
}
// AddedImagePrice4k returns the value that was added to the "image_price_4k" field in this mutation.
func (m *GroupMutation) AddedImagePrice4k() (r float64, exists bool) {
v := m.addimage_price_4k
if v == nil {
return
}
return *v, true
}
// ClearImagePrice4k clears the value of the "image_price_4k" field.
func (m *GroupMutation) ClearImagePrice4k() {
m.image_price_4k = nil
m.addimage_price_4k = nil
m.clearedFields[group.FieldImagePrice4k] = struct{}{}
}
// ImagePrice4kCleared returns if the "image_price_4k" field was cleared in this mutation.
func (m *GroupMutation) ImagePrice4kCleared() bool {
_, ok := m.clearedFields[group.FieldImagePrice4k]
return ok
}
// ResetImagePrice4k resets all changes to the "image_price_4k" field.
func (m *GroupMutation) ResetImagePrice4k() {
m.image_price_4k = nil
m.addimage_price_4k = nil
delete(m.clearedFields, group.FieldImagePrice4k)
}
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by ids.
func (m *GroupMutation) AddAPIKeyIDs(ids ...int64) {
if m.api_keys == nil {
@@ -4609,7 +4825,7 @@ func (m *GroupMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *GroupMutation) Fields() []string {
fields := make([]string, 0, 14)
fields := make([]string, 0, 17)
if m.created_at != nil {
fields = append(fields, group.FieldCreatedAt)
}
@@ -4652,6 +4868,15 @@ func (m *GroupMutation) Fields() []string {
if m.default_validity_days != nil {
fields = append(fields, group.FieldDefaultValidityDays)
}
if m.image_price_1k != nil {
fields = append(fields, group.FieldImagePrice1k)
}
if m.image_price_2k != nil {
fields = append(fields, group.FieldImagePrice2k)
}
if m.image_price_4k != nil {
fields = append(fields, group.FieldImagePrice4k)
}
return fields
}
@@ -4688,6 +4913,12 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
return m.MonthlyLimitUsd()
case group.FieldDefaultValidityDays:
return m.DefaultValidityDays()
case group.FieldImagePrice1k:
return m.ImagePrice1k()
case group.FieldImagePrice2k:
return m.ImagePrice2k()
case group.FieldImagePrice4k:
return m.ImagePrice4k()
}
return nil, false
}
@@ -4725,6 +4956,12 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
return m.OldMonthlyLimitUsd(ctx)
case group.FieldDefaultValidityDays:
return m.OldDefaultValidityDays(ctx)
case group.FieldImagePrice1k:
return m.OldImagePrice1k(ctx)
case group.FieldImagePrice2k:
return m.OldImagePrice2k(ctx)
case group.FieldImagePrice4k:
return m.OldImagePrice4k(ctx)
}
return nil, fmt.Errorf("unknown Group field %s", name)
}
@@ -4832,6 +5069,27 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
}
m.SetDefaultValidityDays(v)
return nil
case group.FieldImagePrice1k:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetImagePrice1k(v)
return nil
case group.FieldImagePrice2k:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetImagePrice2k(v)
return nil
case group.FieldImagePrice4k:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetImagePrice4k(v)
return nil
}
return fmt.Errorf("unknown Group field %s", name)
}
@@ -4855,6 +5113,15 @@ func (m *GroupMutation) AddedFields() []string {
if m.adddefault_validity_days != nil {
fields = append(fields, group.FieldDefaultValidityDays)
}
if m.addimage_price_1k != nil {
fields = append(fields, group.FieldImagePrice1k)
}
if m.addimage_price_2k != nil {
fields = append(fields, group.FieldImagePrice2k)
}
if m.addimage_price_4k != nil {
fields = append(fields, group.FieldImagePrice4k)
}
return fields
}
@@ -4873,6 +5140,12 @@ func (m *GroupMutation) AddedField(name string) (ent.Value, bool) {
return m.AddedMonthlyLimitUsd()
case group.FieldDefaultValidityDays:
return m.AddedDefaultValidityDays()
case group.FieldImagePrice1k:
return m.AddedImagePrice1k()
case group.FieldImagePrice2k:
return m.AddedImagePrice2k()
case group.FieldImagePrice4k:
return m.AddedImagePrice4k()
}
return nil, false
}
@@ -4917,6 +5190,27 @@ func (m *GroupMutation) AddField(name string, value ent.Value) error {
}
m.AddDefaultValidityDays(v)
return nil
case group.FieldImagePrice1k:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.AddImagePrice1k(v)
return nil
case group.FieldImagePrice2k:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.AddImagePrice2k(v)
return nil
case group.FieldImagePrice4k:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.AddImagePrice4k(v)
return nil
}
return fmt.Errorf("unknown Group numeric field %s", name)
}
@@ -4940,6 +5234,15 @@ func (m *GroupMutation) ClearedFields() []string {
if m.FieldCleared(group.FieldMonthlyLimitUsd) {
fields = append(fields, group.FieldMonthlyLimitUsd)
}
if m.FieldCleared(group.FieldImagePrice1k) {
fields = append(fields, group.FieldImagePrice1k)
}
if m.FieldCleared(group.FieldImagePrice2k) {
fields = append(fields, group.FieldImagePrice2k)
}
if m.FieldCleared(group.FieldImagePrice4k) {
fields = append(fields, group.FieldImagePrice4k)
}
return fields
}
@@ -4969,6 +5272,15 @@ func (m *GroupMutation) ClearField(name string) error {
case group.FieldMonthlyLimitUsd:
m.ClearMonthlyLimitUsd()
return nil
case group.FieldImagePrice1k:
m.ClearImagePrice1k()
return nil
case group.FieldImagePrice2k:
m.ClearImagePrice2k()
return nil
case group.FieldImagePrice4k:
m.ClearImagePrice4k()
return nil
}
return fmt.Errorf("unknown Group nullable field %s", name)
}
@@ -5019,6 +5331,15 @@ func (m *GroupMutation) ResetField(name string) error {
case group.FieldDefaultValidityDays:
m.ResetDefaultValidityDays()
return nil
case group.FieldImagePrice1k:
m.ResetImagePrice1k()
return nil
case group.FieldImagePrice2k:
m.ResetImagePrice2k()
return nil
case group.FieldImagePrice4k:
m.ResetImagePrice4k()
return nil
}
return fmt.Errorf("unknown Group field %s", name)
}
@@ -7786,6 +8107,9 @@ type UsageLogMutation struct {
addduration_ms *int
first_token_ms *int
addfirst_token_ms *int
image_count *int
addimage_count *int
image_size *string
created_at *time.Time
clearedFields map[string]struct{}
user *int64
@@ -9139,6 +9463,111 @@ func (m *UsageLogMutation) ResetFirstTokenMs() {
delete(m.clearedFields, usagelog.FieldFirstTokenMs)
}
// SetImageCount sets the "image_count" field.
func (m *UsageLogMutation) SetImageCount(i int) {
m.image_count = &i
m.addimage_count = nil
}
// ImageCount returns the value of the "image_count" field in the mutation.
func (m *UsageLogMutation) ImageCount() (r int, exists bool) {
v := m.image_count
if v == nil {
return
}
return *v, true
}
// OldImageCount returns the old "image_count" 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) OldImageCount(ctx context.Context) (v int, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldImageCount is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldImageCount requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldImageCount: %w", err)
}
return oldValue.ImageCount, nil
}
// AddImageCount adds i to the "image_count" field.
func (m *UsageLogMutation) AddImageCount(i int) {
if m.addimage_count != nil {
*m.addimage_count += i
} else {
m.addimage_count = &i
}
}
// AddedImageCount returns the value that was added to the "image_count" field in this mutation.
func (m *UsageLogMutation) AddedImageCount() (r int, exists bool) {
v := m.addimage_count
if v == nil {
return
}
return *v, true
}
// ResetImageCount resets all changes to the "image_count" field.
func (m *UsageLogMutation) ResetImageCount() {
m.image_count = nil
m.addimage_count = nil
}
// SetImageSize sets the "image_size" field.
func (m *UsageLogMutation) SetImageSize(s string) {
m.image_size = &s
}
// ImageSize returns the value of the "image_size" field in the mutation.
func (m *UsageLogMutation) ImageSize() (r string, exists bool) {
v := m.image_size
if v == nil {
return
}
return *v, true
}
// OldImageSize returns the old "image_size" 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) OldImageSize(ctx context.Context) (v *string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldImageSize is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldImageSize requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldImageSize: %w", err)
}
return oldValue.ImageSize, nil
}
// ClearImageSize clears the value of the "image_size" field.
func (m *UsageLogMutation) ClearImageSize() {
m.image_size = nil
m.clearedFields[usagelog.FieldImageSize] = struct{}{}
}
// ImageSizeCleared returns if the "image_size" field was cleared in this mutation.
func (m *UsageLogMutation) ImageSizeCleared() bool {
_, ok := m.clearedFields[usagelog.FieldImageSize]
return ok
}
// ResetImageSize resets all changes to the "image_size" field.
func (m *UsageLogMutation) ResetImageSize() {
m.image_size = nil
delete(m.clearedFields, usagelog.FieldImageSize)
}
// SetCreatedAt sets the "created_at" field.
func (m *UsageLogMutation) SetCreatedAt(t time.Time) {
m.created_at = &t
@@ -9344,7 +9773,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, 25)
fields := make([]string, 0, 27)
if m.user != nil {
fields = append(fields, usagelog.FieldUserID)
}
@@ -9417,6 +9846,12 @@ func (m *UsageLogMutation) Fields() []string {
if m.first_token_ms != nil {
fields = append(fields, usagelog.FieldFirstTokenMs)
}
if m.image_count != nil {
fields = append(fields, usagelog.FieldImageCount)
}
if m.image_size != nil {
fields = append(fields, usagelog.FieldImageSize)
}
if m.created_at != nil {
fields = append(fields, usagelog.FieldCreatedAt)
}
@@ -9476,6 +9911,10 @@ func (m *UsageLogMutation) Field(name string) (ent.Value, bool) {
return m.DurationMs()
case usagelog.FieldFirstTokenMs:
return m.FirstTokenMs()
case usagelog.FieldImageCount:
return m.ImageCount()
case usagelog.FieldImageSize:
return m.ImageSize()
case usagelog.FieldCreatedAt:
return m.CreatedAt()
}
@@ -9535,6 +9974,10 @@ func (m *UsageLogMutation) OldField(ctx context.Context, name string) (ent.Value
return m.OldDurationMs(ctx)
case usagelog.FieldFirstTokenMs:
return m.OldFirstTokenMs(ctx)
case usagelog.FieldImageCount:
return m.OldImageCount(ctx)
case usagelog.FieldImageSize:
return m.OldImageSize(ctx)
case usagelog.FieldCreatedAt:
return m.OldCreatedAt(ctx)
}
@@ -9714,6 +10157,20 @@ func (m *UsageLogMutation) SetField(name string, value ent.Value) error {
}
m.SetFirstTokenMs(v)
return nil
case usagelog.FieldImageCount:
v, ok := value.(int)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetImageCount(v)
return nil
case usagelog.FieldImageSize:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetImageSize(v)
return nil
case usagelog.FieldCreatedAt:
v, ok := value.(time.Time)
if !ok {
@@ -9777,6 +10234,9 @@ func (m *UsageLogMutation) AddedFields() []string {
if m.addfirst_token_ms != nil {
fields = append(fields, usagelog.FieldFirstTokenMs)
}
if m.addimage_count != nil {
fields = append(fields, usagelog.FieldImageCount)
}
return fields
}
@@ -9817,6 +10277,8 @@ func (m *UsageLogMutation) AddedField(name string) (ent.Value, bool) {
return m.AddedDurationMs()
case usagelog.FieldFirstTokenMs:
return m.AddedFirstTokenMs()
case usagelog.FieldImageCount:
return m.AddedImageCount()
}
return nil, false
}
@@ -9938,6 +10400,13 @@ func (m *UsageLogMutation) AddField(name string, value ent.Value) error {
}
m.AddFirstTokenMs(v)
return nil
case usagelog.FieldImageCount:
v, ok := value.(int)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.AddImageCount(v)
return nil
}
return fmt.Errorf("unknown UsageLog numeric field %s", name)
}
@@ -9958,6 +10427,9 @@ func (m *UsageLogMutation) ClearedFields() []string {
if m.FieldCleared(usagelog.FieldFirstTokenMs) {
fields = append(fields, usagelog.FieldFirstTokenMs)
}
if m.FieldCleared(usagelog.FieldImageSize) {
fields = append(fields, usagelog.FieldImageSize)
}
return fields
}
@@ -9984,6 +10456,9 @@ func (m *UsageLogMutation) ClearField(name string) error {
case usagelog.FieldFirstTokenMs:
m.ClearFirstTokenMs()
return nil
case usagelog.FieldImageSize:
m.ClearImageSize()
return nil
}
return fmt.Errorf("unknown UsageLog nullable field %s", name)
}
@@ -10064,6 +10539,12 @@ func (m *UsageLogMutation) ResetField(name string) error {
case usagelog.FieldFirstTokenMs:
m.ResetFirstTokenMs()
return nil
case usagelog.FieldImageCount:
m.ResetImageCount()
return nil
case usagelog.FieldImageSize:
m.ResetImageSize()
return nil
case usagelog.FieldCreatedAt:
m.ResetCreatedAt()
return nil

View File

@@ -521,8 +521,16 @@ func init() {
usagelogDescStream := usagelogFields[21].Descriptor()
// usagelog.DefaultStream holds the default value on creation for the stream field.
usagelog.DefaultStream = usagelogDescStream.Default.(bool)
// usagelogDescImageCount is the schema descriptor for image_count field.
usagelogDescImageCount := usagelogFields[24].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[25].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[24].Descriptor()
usagelogDescCreatedAt := usagelogFields[26].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()

View File

@@ -72,6 +72,20 @@ func (Group) Fields() []ent.Field {
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}),
field.Int("default_validity_days").
Default(30),
// 图片生成计费配置antigravity 和 gemini 平台使用)
field.Float("image_price_1k").
Optional().
Nillable().
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}),
field.Float("image_price_2k").
Optional().
Nillable().
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}),
field.Float("image_price_4k").
Optional().
Nillable().
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}),
}
}

View File

@@ -97,6 +97,14 @@ func (UsageLog) Fields() []ent.Field {
Optional().
Nillable(),
// 图片生成字段(仅 gemini-3-pro-image 等图片模型使用)
field.Int("image_count").
Default(0),
field.String("image_size").
MaxLen(10).
Optional().
Nillable(),
// 时间戳(只有 created_at日志不可修改
field.Time("created_at").
Default(time.Now).

View File

@@ -70,6 +70,10 @@ type UsageLog struct {
DurationMs *int `json:"duration_ms,omitempty"`
// FirstTokenMs holds the value of the "first_token_ms" field.
FirstTokenMs *int `json:"first_token_ms,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.
ImageSize *string `json:"image_size,omitempty"`
// CreatedAt holds the value of the "created_at" field.
CreatedAt time.Time `json:"created_at,omitempty"`
// Edges holds the relations/edges for other nodes in the graph.
@@ -159,9 +163,9 @@ func (*UsageLog) scanValues(columns []string) ([]any, error) {
values[i] = new(sql.NullBool)
case usagelog.FieldInputCost, usagelog.FieldOutputCost, usagelog.FieldCacheCreationCost, usagelog.FieldCacheReadCost, usagelog.FieldTotalCost, usagelog.FieldActualCost, usagelog.FieldRateMultiplier:
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:
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:
case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldImageSize:
values[i] = new(sql.NullString)
case usagelog.FieldCreatedAt:
values[i] = new(sql.NullTime)
@@ -334,6 +338,19 @@ func (_m *UsageLog) assignValues(columns []string, values []any) error {
_m.FirstTokenMs = new(int)
*_m.FirstTokenMs = int(value.Int64)
}
case usagelog.FieldImageCount:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field image_count", values[i])
} else if value.Valid {
_m.ImageCount = int(value.Int64)
}
case usagelog.FieldImageSize:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field image_size", values[i])
} else if value.Valid {
_m.ImageSize = new(string)
*_m.ImageSize = value.String
}
case usagelog.FieldCreatedAt:
if value, ok := values[i].(*sql.NullTime); !ok {
return fmt.Errorf("unexpected type %T for field created_at", values[i])
@@ -481,6 +498,14 @@ func (_m *UsageLog) String() string {
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
builder.WriteString("image_count=")
builder.WriteString(fmt.Sprintf("%v", _m.ImageCount))
builder.WriteString(", ")
if v := _m.ImageSize; v != nil {
builder.WriteString("image_size=")
builder.WriteString(*v)
}
builder.WriteString(", ")
builder.WriteString("created_at=")
builder.WriteString(_m.CreatedAt.Format(time.ANSIC))
builder.WriteByte(')')

View File

@@ -62,6 +62,10 @@ const (
FieldDurationMs = "duration_ms"
// FieldFirstTokenMs holds the string denoting the first_token_ms field in the database.
FieldFirstTokenMs = "first_token_ms"
// 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.
FieldImageSize = "image_size"
// FieldCreatedAt holds the string denoting the created_at field in the database.
FieldCreatedAt = "created_at"
// EdgeUser holds the string denoting the user edge name in mutations.
@@ -140,6 +144,8 @@ var Columns = []string{
FieldStream,
FieldDurationMs,
FieldFirstTokenMs,
FieldImageCount,
FieldImageSize,
FieldCreatedAt,
}
@@ -188,6 +194,10 @@ var (
DefaultBillingType int8
// DefaultStream holds the default value on creation for the "stream" field.
DefaultStream bool
// 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.
ImageSizeValidator func(string) error
// DefaultCreatedAt holds the default value on creation for the "created_at" field.
DefaultCreatedAt func() time.Time
)
@@ -320,6 +330,16 @@ func ByFirstTokenMs(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldFirstTokenMs, opts...).ToFunc()
}
// ByImageCount orders the results by the image_count field.
func ByImageCount(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldImageCount, opts...).ToFunc()
}
// ByImageSize orders the results by the image_size field.
func ByImageSize(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldImageSize, opts...).ToFunc()
}
// ByCreatedAt orders the results by the created_at field.
func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldCreatedAt, opts...).ToFunc()

View File

@@ -175,6 +175,16 @@ func FirstTokenMs(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldFirstTokenMs, 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))
}
// ImageSize applies equality check predicate on the "image_size" field. It's identical to ImageSizeEQ.
func ImageSize(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldImageSize, v))
}
// CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ.
func CreatedAt(v time.Time) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldCreatedAt, v))
@@ -1100,6 +1110,121 @@ func FirstTokenMsNotNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotNull(FieldFirstTokenMs))
}
// ImageCountEQ applies the EQ predicate on the "image_count" field.
func ImageCountEQ(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldImageCount, v))
}
// ImageCountNEQ applies the NEQ predicate on the "image_count" field.
func ImageCountNEQ(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNEQ(FieldImageCount, v))
}
// ImageCountIn applies the In predicate on the "image_count" field.
func ImageCountIn(vs ...int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldIn(FieldImageCount, vs...))
}
// ImageCountNotIn applies the NotIn predicate on the "image_count" field.
func ImageCountNotIn(vs ...int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotIn(FieldImageCount, vs...))
}
// ImageCountGT applies the GT predicate on the "image_count" field.
func ImageCountGT(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldGT(FieldImageCount, v))
}
// ImageCountGTE applies the GTE predicate on the "image_count" field.
func ImageCountGTE(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldGTE(FieldImageCount, v))
}
// ImageCountLT applies the LT predicate on the "image_count" field.
func ImageCountLT(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldLT(FieldImageCount, v))
}
// ImageCountLTE applies the LTE predicate on the "image_count" field.
func ImageCountLTE(v int) predicate.UsageLog {
return predicate.UsageLog(sql.FieldLTE(FieldImageCount, v))
}
// ImageSizeEQ applies the EQ predicate on the "image_size" field.
func ImageSizeEQ(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldImageSize, v))
}
// ImageSizeNEQ applies the NEQ predicate on the "image_size" field.
func ImageSizeNEQ(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNEQ(FieldImageSize, v))
}
// ImageSizeIn applies the In predicate on the "image_size" field.
func ImageSizeIn(vs ...string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldIn(FieldImageSize, vs...))
}
// ImageSizeNotIn applies the NotIn predicate on the "image_size" field.
func ImageSizeNotIn(vs ...string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotIn(FieldImageSize, vs...))
}
// ImageSizeGT applies the GT predicate on the "image_size" field.
func ImageSizeGT(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldGT(FieldImageSize, v))
}
// ImageSizeGTE applies the GTE predicate on the "image_size" field.
func ImageSizeGTE(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldGTE(FieldImageSize, v))
}
// ImageSizeLT applies the LT predicate on the "image_size" field.
func ImageSizeLT(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldLT(FieldImageSize, v))
}
// ImageSizeLTE applies the LTE predicate on the "image_size" field.
func ImageSizeLTE(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldLTE(FieldImageSize, v))
}
// ImageSizeContains applies the Contains predicate on the "image_size" field.
func ImageSizeContains(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldContains(FieldImageSize, v))
}
// ImageSizeHasPrefix applies the HasPrefix predicate on the "image_size" field.
func ImageSizeHasPrefix(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldHasPrefix(FieldImageSize, v))
}
// ImageSizeHasSuffix applies the HasSuffix predicate on the "image_size" field.
func ImageSizeHasSuffix(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldHasSuffix(FieldImageSize, v))
}
// ImageSizeIsNil applies the IsNil predicate on the "image_size" field.
func ImageSizeIsNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldIsNull(FieldImageSize))
}
// ImageSizeNotNil applies the NotNil predicate on the "image_size" field.
func ImageSizeNotNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotNull(FieldImageSize))
}
// ImageSizeEqualFold applies the EqualFold predicate on the "image_size" field.
func ImageSizeEqualFold(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEqualFold(FieldImageSize, v))
}
// ImageSizeContainsFold applies the ContainsFold predicate on the "image_size" field.
func ImageSizeContainsFold(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldContainsFold(FieldImageSize, v))
}
// CreatedAtEQ applies the EQ predicate on the "created_at" field.
func CreatedAtEQ(v time.Time) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldCreatedAt, v))

View File

@@ -323,6 +323,34 @@ func (_c *UsageLogCreate) SetNillableFirstTokenMs(v *int) *UsageLogCreate {
return _c
}
// SetImageCount sets the "image_count" field.
func (_c *UsageLogCreate) SetImageCount(v int) *UsageLogCreate {
_c.mutation.SetImageCount(v)
return _c
}
// SetNillableImageCount sets the "image_count" field if the given value is not nil.
func (_c *UsageLogCreate) SetNillableImageCount(v *int) *UsageLogCreate {
if v != nil {
_c.SetImageCount(*v)
}
return _c
}
// SetImageSize sets the "image_size" field.
func (_c *UsageLogCreate) SetImageSize(v string) *UsageLogCreate {
_c.mutation.SetImageSize(v)
return _c
}
// SetNillableImageSize sets the "image_size" field if the given value is not nil.
func (_c *UsageLogCreate) SetNillableImageSize(v *string) *UsageLogCreate {
if v != nil {
_c.SetImageSize(*v)
}
return _c
}
// SetCreatedAt sets the "created_at" field.
func (_c *UsageLogCreate) SetCreatedAt(v time.Time) *UsageLogCreate {
_c.mutation.SetCreatedAt(v)
@@ -457,6 +485,10 @@ func (_c *UsageLogCreate) defaults() {
v := usagelog.DefaultStream
_c.mutation.SetStream(v)
}
if _, ok := _c.mutation.ImageCount(); !ok {
v := usagelog.DefaultImageCount
_c.mutation.SetImageCount(v)
}
if _, ok := _c.mutation.CreatedAt(); !ok {
v := usagelog.DefaultCreatedAt()
_c.mutation.SetCreatedAt(v)
@@ -535,6 +567,14 @@ func (_c *UsageLogCreate) check() error {
if _, ok := _c.mutation.Stream(); !ok {
return &ValidationError{Name: "stream", err: errors.New(`ent: missing required field "UsageLog.stream"`)}
}
if _, ok := _c.mutation.ImageCount(); !ok {
return &ValidationError{Name: "image_count", err: errors.New(`ent: missing required field "UsageLog.image_count"`)}
}
if v, ok := _c.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)}
}
}
if _, ok := _c.mutation.CreatedAt(); !ok {
return &ValidationError{Name: "created_at", err: errors.New(`ent: missing required field "UsageLog.created_at"`)}
}
@@ -650,6 +690,14 @@ func (_c *UsageLogCreate) createSpec() (*UsageLog, *sqlgraph.CreateSpec) {
_spec.SetField(usagelog.FieldFirstTokenMs, field.TypeInt, value)
_node.FirstTokenMs = &value
}
if value, ok := _c.mutation.ImageCount(); ok {
_spec.SetField(usagelog.FieldImageCount, field.TypeInt, value)
_node.ImageCount = value
}
if value, ok := _c.mutation.ImageSize(); ok {
_spec.SetField(usagelog.FieldImageSize, field.TypeString, value)
_node.ImageSize = &value
}
if value, ok := _c.mutation.CreatedAt(); ok {
_spec.SetField(usagelog.FieldCreatedAt, field.TypeTime, value)
_node.CreatedAt = value
@@ -1199,6 +1247,42 @@ func (u *UsageLogUpsert) ClearFirstTokenMs() *UsageLogUpsert {
return u
}
// SetImageCount sets the "image_count" field.
func (u *UsageLogUpsert) SetImageCount(v int) *UsageLogUpsert {
u.Set(usagelog.FieldImageCount, v)
return u
}
// UpdateImageCount sets the "image_count" field to the value that was provided on create.
func (u *UsageLogUpsert) UpdateImageCount() *UsageLogUpsert {
u.SetExcluded(usagelog.FieldImageCount)
return u
}
// AddImageCount adds v to the "image_count" field.
func (u *UsageLogUpsert) AddImageCount(v int) *UsageLogUpsert {
u.Add(usagelog.FieldImageCount, v)
return u
}
// SetImageSize sets the "image_size" field.
func (u *UsageLogUpsert) SetImageSize(v string) *UsageLogUpsert {
u.Set(usagelog.FieldImageSize, v)
return u
}
// UpdateImageSize sets the "image_size" field to the value that was provided on create.
func (u *UsageLogUpsert) UpdateImageSize() *UsageLogUpsert {
u.SetExcluded(usagelog.FieldImageSize)
return u
}
// ClearImageSize clears the value of the "image_size" field.
func (u *UsageLogUpsert) ClearImageSize() *UsageLogUpsert {
u.SetNull(usagelog.FieldImageSize)
return u
}
// UpdateNewValues updates the mutable fields using the new values that were set on create.
// Using this option is equivalent to using:
//
@@ -1720,6 +1804,48 @@ func (u *UsageLogUpsertOne) ClearFirstTokenMs() *UsageLogUpsertOne {
})
}
// SetImageCount sets the "image_count" field.
func (u *UsageLogUpsertOne) SetImageCount(v int) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.SetImageCount(v)
})
}
// AddImageCount adds v to the "image_count" field.
func (u *UsageLogUpsertOne) AddImageCount(v int) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.AddImageCount(v)
})
}
// UpdateImageCount sets the "image_count" field to the value that was provided on create.
func (u *UsageLogUpsertOne) UpdateImageCount() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateImageCount()
})
}
// SetImageSize sets the "image_size" field.
func (u *UsageLogUpsertOne) SetImageSize(v string) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.SetImageSize(v)
})
}
// UpdateImageSize sets the "image_size" field to the value that was provided on create.
func (u *UsageLogUpsertOne) UpdateImageSize() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateImageSize()
})
}
// ClearImageSize clears the value of the "image_size" field.
func (u *UsageLogUpsertOne) ClearImageSize() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.ClearImageSize()
})
}
// Exec executes the query.
func (u *UsageLogUpsertOne) Exec(ctx context.Context) error {
if len(u.create.conflict) == 0 {
@@ -2407,6 +2533,48 @@ func (u *UsageLogUpsertBulk) ClearFirstTokenMs() *UsageLogUpsertBulk {
})
}
// SetImageCount sets the "image_count" field.
func (u *UsageLogUpsertBulk) SetImageCount(v int) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.SetImageCount(v)
})
}
// AddImageCount adds v to the "image_count" field.
func (u *UsageLogUpsertBulk) AddImageCount(v int) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.AddImageCount(v)
})
}
// UpdateImageCount sets the "image_count" field to the value that was provided on create.
func (u *UsageLogUpsertBulk) UpdateImageCount() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateImageCount()
})
}
// SetImageSize sets the "image_size" field.
func (u *UsageLogUpsertBulk) SetImageSize(v string) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.SetImageSize(v)
})
}
// UpdateImageSize sets the "image_size" field to the value that was provided on create.
func (u *UsageLogUpsertBulk) UpdateImageSize() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateImageSize()
})
}
// ClearImageSize clears the value of the "image_size" field.
func (u *UsageLogUpsertBulk) ClearImageSize() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.ClearImageSize()
})
}
// Exec executes the query.
func (u *UsageLogUpsertBulk) Exec(ctx context.Context) error {
if u.create.err != nil {

View File

@@ -504,6 +504,47 @@ func (_u *UsageLogUpdate) ClearFirstTokenMs() *UsageLogUpdate {
return _u
}
// SetImageCount sets the "image_count" field.
func (_u *UsageLogUpdate) SetImageCount(v int) *UsageLogUpdate {
_u.mutation.ResetImageCount()
_u.mutation.SetImageCount(v)
return _u
}
// SetNillableImageCount sets the "image_count" field if the given value is not nil.
func (_u *UsageLogUpdate) SetNillableImageCount(v *int) *UsageLogUpdate {
if v != nil {
_u.SetImageCount(*v)
}
return _u
}
// AddImageCount adds value to the "image_count" field.
func (_u *UsageLogUpdate) AddImageCount(v int) *UsageLogUpdate {
_u.mutation.AddImageCount(v)
return _u
}
// SetImageSize sets the "image_size" field.
func (_u *UsageLogUpdate) SetImageSize(v string) *UsageLogUpdate {
_u.mutation.SetImageSize(v)
return _u
}
// SetNillableImageSize sets the "image_size" field if the given value is not nil.
func (_u *UsageLogUpdate) SetNillableImageSize(v *string) *UsageLogUpdate {
if v != nil {
_u.SetImageSize(*v)
}
return _u
}
// ClearImageSize clears the value of the "image_size" field.
func (_u *UsageLogUpdate) ClearImageSize() *UsageLogUpdate {
_u.mutation.ClearImageSize()
return _u
}
// SetUser sets the "user" edge to the User entity.
func (_u *UsageLogUpdate) SetUser(v *User) *UsageLogUpdate {
return _u.SetUserID(v.ID)
@@ -603,6 +644,11 @@ func (_u *UsageLogUpdate) check() error {
return &ValidationError{Name: "model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model": %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)}
}
}
if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 {
return errors.New(`ent: clearing a required unique edge "UsageLog.user"`)
}
@@ -738,6 +784,18 @@ func (_u *UsageLogUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.FirstTokenMsCleared() {
_spec.ClearField(usagelog.FieldFirstTokenMs, field.TypeInt)
}
if value, ok := _u.mutation.ImageCount(); ok {
_spec.SetField(usagelog.FieldImageCount, field.TypeInt, value)
}
if value, ok := _u.mutation.AddedImageCount(); ok {
_spec.AddField(usagelog.FieldImageCount, field.TypeInt, value)
}
if value, ok := _u.mutation.ImageSize(); ok {
_spec.SetField(usagelog.FieldImageSize, field.TypeString, value)
}
if _u.mutation.ImageSizeCleared() {
_spec.ClearField(usagelog.FieldImageSize, field.TypeString)
}
if _u.mutation.UserCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
@@ -1375,6 +1433,47 @@ func (_u *UsageLogUpdateOne) ClearFirstTokenMs() *UsageLogUpdateOne {
return _u
}
// SetImageCount sets the "image_count" field.
func (_u *UsageLogUpdateOne) SetImageCount(v int) *UsageLogUpdateOne {
_u.mutation.ResetImageCount()
_u.mutation.SetImageCount(v)
return _u
}
// SetNillableImageCount sets the "image_count" field if the given value is not nil.
func (_u *UsageLogUpdateOne) SetNillableImageCount(v *int) *UsageLogUpdateOne {
if v != nil {
_u.SetImageCount(*v)
}
return _u
}
// AddImageCount adds value to the "image_count" field.
func (_u *UsageLogUpdateOne) AddImageCount(v int) *UsageLogUpdateOne {
_u.mutation.AddImageCount(v)
return _u
}
// SetImageSize sets the "image_size" field.
func (_u *UsageLogUpdateOne) SetImageSize(v string) *UsageLogUpdateOne {
_u.mutation.SetImageSize(v)
return _u
}
// SetNillableImageSize sets the "image_size" field if the given value is not nil.
func (_u *UsageLogUpdateOne) SetNillableImageSize(v *string) *UsageLogUpdateOne {
if v != nil {
_u.SetImageSize(*v)
}
return _u
}
// ClearImageSize clears the value of the "image_size" field.
func (_u *UsageLogUpdateOne) ClearImageSize() *UsageLogUpdateOne {
_u.mutation.ClearImageSize()
return _u
}
// SetUser sets the "user" edge to the User entity.
func (_u *UsageLogUpdateOne) SetUser(v *User) *UsageLogUpdateOne {
return _u.SetUserID(v.ID)
@@ -1487,6 +1586,11 @@ func (_u *UsageLogUpdateOne) check() error {
return &ValidationError{Name: "model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model": %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)}
}
}
if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 {
return errors.New(`ent: clearing a required unique edge "UsageLog.user"`)
}
@@ -1639,6 +1743,18 @@ func (_u *UsageLogUpdateOne) sqlSave(ctx context.Context) (_node *UsageLog, err
if _u.mutation.FirstTokenMsCleared() {
_spec.ClearField(usagelog.FieldFirstTokenMs, field.TypeInt)
}
if value, ok := _u.mutation.ImageCount(); ok {
_spec.SetField(usagelog.FieldImageCount, field.TypeInt, value)
}
if value, ok := _u.mutation.AddedImageCount(); ok {
_spec.AddField(usagelog.FieldImageCount, field.TypeInt, value)
}
if value, ok := _u.mutation.ImageSize(); ok {
_spec.SetField(usagelog.FieldImageSize, field.TypeString, value)
}
if _u.mutation.ImageSizeCleared() {
_spec.ClearField(usagelog.FieldImageSize, field.TypeString)
}
if _u.mutation.UserCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,

View File

@@ -1,16 +1,14 @@
module github.com/Wei-Shaw/sub2api
go 1.24.0
toolchain go1.24.11
go 1.25.5
require (
entgo.io/ent v0.14.5
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/google/wire v0.7.0
github.com/imroc/req/v3 v3.56.0
github.com/imroc/req/v3 v3.57.0
github.com/lib/pq v1.10.9
github.com/redis/go-redis/v9 v9.17.2
github.com/spf13/viper v1.18.2
@@ -20,16 +18,16 @@ require (
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
github.com/zeromicro/go-zero v1.9.4
golang.org/x/crypto v0.44.0
golang.org/x/net v0.47.0
golang.org/x/term v0.37.0
golang.org/x/crypto v0.46.0
golang.org/x/net v0.48.0
golang.org/x/sync v0.19.0
golang.org/x/term v0.38.0
gopkg.in/yaml.v3 v3.0.1
)
require (
ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 // indirect
dario.cat/mergo v1.0.2 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/agext/levenshtein v1.2.3 // indirect
@@ -64,7 +62,6 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/go-sql-driver/mysql v1.9.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
@@ -74,10 +71,8 @@ require (
github.com/hashicorp/hcl/v2 v2.18.1 // indirect
github.com/icholy/digest v1.1.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
@@ -105,8 +100,8 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.56.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.1 // indirect
github.com/refraction-networking/utls v1.8.1 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
@@ -141,16 +136,12 @@ require (
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/tools v0.38.0 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.39.0 // indirect
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect
google.golang.org/grpc v1.75.1 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gorm.io/datatypes v1.2.7 // indirect
gorm.io/driver/mysql v1.5.6 // indirect
gorm.io/gorm v1.30.0 // indirect
)

View File

@@ -4,8 +4,6 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
entgo.io/ent v0.14.5 h1:Rj2WOYJtCkWyFo6a+5wB3EfBRP0rnx1fMk6gGA0UUe4=
entgo.io/ent v0.14.5/go.mod h1:zTzLmWtPvGpmSwtkaayM2cm5m819NdM7z7tYPq3vN0U=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
@@ -96,15 +94,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo=
github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw=
github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=
github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -126,8 +121,8 @@ github.com/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZY
github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE=
github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
github.com/imroc/req/v3 v3.56.0 h1:t6YdqqerYBXhZ9+VjqsQs5wlKxdUNEvsgBhxWc1AEEo=
github.com/imroc/req/v3 v3.56.0/go.mod h1:cUZSooE8hhzFNOrAbdxuemXDQxFXLQTnu3066jr7ZGk=
github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI=
github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -138,14 +133,10 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
@@ -219,10 +210,10 @@ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.56.0 h1:q/TW+OLismmXAehgFLczhCDTYB3bFmua4D9lsNBWxvY=
github.com/quic-go/quic-go v0.56.0/go.mod h1:9gx5KsFQtw2oZ6GZTyh+7YEvOxWCL9WZAepnHxgAo6c=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10=
github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo=
@@ -335,16 +326,16 @@ go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTV
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -354,16 +345,16 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/tools/go/expect v0.1.0-deprecated h1:jY2C5HGYR5lqex3gEniOQL0r7Dq5+VGVgY1nudX5lXY=
golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
@@ -386,13 +377,6 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk=
gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY=
gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8=
gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -6,6 +6,7 @@ import (
"encoding/hex"
"fmt"
"log"
"os"
"strings"
"time"
@@ -17,7 +18,7 @@ const (
RunModeSimple = "simple"
)
const DefaultCSPPolicy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
const DefaultCSPPolicy = "default-src 'self'; script-src 'self' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
// 连接池隔离策略常量
// 用于控制上游 HTTP 连接池的隔离粒度,影响连接复用和资源消耗
@@ -147,7 +148,7 @@ type CSPConfig struct {
}
type ProxyProbeConfig struct {
InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"`
InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` // 已禁用:禁止跳过 TLS 证书验证
}
type BillingConfig struct {
@@ -338,8 +339,19 @@ func NormalizeRunMode(value string) string {
func Load() (*Config, error) {
viper.SetConfigName("config")
viper.SetConfigType("yaml")
// Add config paths in priority order
// 1. DATA_DIR environment variable (highest priority)
if dataDir := os.Getenv("DATA_DIR"); dataDir != "" {
viper.AddConfigPath(dataDir)
}
// 2. Docker data directory
viper.AddConfigPath("/app/data")
// 3. Current directory
viper.AddConfigPath(".")
// 4. Config subdirectory
viper.AddConfigPath("./config")
// 5. System config directory
viper.AddConfigPath("/etc/sub2api")
// 环境变量支持
@@ -372,13 +384,13 @@ func Load() (*Config, error) {
cfg.Security.ResponseHeaders.ForceRemove = normalizeStringSlice(cfg.Security.ResponseHeaders.ForceRemove)
cfg.Security.CSP.Policy = strings.TrimSpace(cfg.Security.CSP.Policy)
if cfg.Server.Mode != "release" && cfg.JWT.Secret == "" {
if cfg.JWT.Secret == "" {
secret, err := generateJWTSecret(64)
if err != nil {
return nil, fmt.Errorf("generate jwt secret error: %w", err)
}
cfg.JWT.Secret = secret
log.Println("Warning: JWT secret auto-generated for non-release mode. Do not use in production.")
log.Println("Warning: JWT secret auto-generated. Consider setting a fixed secret for production.")
}
if err := cfg.Validate(); err != nil {
@@ -392,7 +404,7 @@ func Load() (*Config, error) {
log.Println("Warning: security.response_headers.enabled=false; configurable header filtering disabled (default allowlist only).")
}
if cfg.Server.Mode != "release" && cfg.JWT.Secret != "" && isWeakJWTSecret(cfg.JWT.Secret) {
if cfg.JWT.Secret != "" && isWeakJWTSecret(cfg.JWT.Secret) {
log.Println("Warning: JWT secret appears weak; use a 32+ character random secret in production.")
}
if len(cfg.Security.ResponseHeaders.AdditionalAllowed) > 0 || len(cfg.Security.ResponseHeaders.ForceRemove) > 0 {
@@ -436,8 +448,8 @@ func setDefaults() {
"raw.githubusercontent.com",
})
viper.SetDefault("security.url_allowlist.crs_hosts", []string{})
viper.SetDefault("security.url_allowlist.allow_private_hosts", false)
viper.SetDefault("security.url_allowlist.allow_insecure_http", false)
viper.SetDefault("security.url_allowlist.allow_private_hosts", true)
viper.SetDefault("security.url_allowlist.allow_insecure_http", true)
viper.SetDefault("security.response_headers.enabled", false)
viper.SetDefault("security.response_headers.additional_allowed", []string{})
viper.SetDefault("security.response_headers.force_remove", []string{})
@@ -549,17 +561,6 @@ func setDefaults() {
}
func (c *Config) Validate() error {
if c.Server.Mode == "release" {
if c.JWT.Secret == "" {
return fmt.Errorf("jwt.secret is required in release mode")
}
if len(c.JWT.Secret) < 32 {
return fmt.Errorf("jwt.secret must be at least 32 characters")
}
if isWeakJWTSecret(c.JWT.Secret) {
return fmt.Errorf("jwt.secret is too weak")
}
}
if c.JWT.ExpireHour <= 0 {
return fmt.Errorf("jwt.expire_hour must be positive")
}

View File

@@ -80,8 +80,11 @@ func TestLoadDefaultSecurityToggles(t *testing.T) {
if cfg.Security.URLAllowlist.Enabled {
t.Fatalf("URLAllowlist.Enabled = true, want false")
}
if cfg.Security.URLAllowlist.AllowInsecureHTTP {
t.Fatalf("URLAllowlist.AllowInsecureHTTP = true, want false")
if !cfg.Security.URLAllowlist.AllowInsecureHTTP {
t.Fatalf("URLAllowlist.AllowInsecureHTTP = false, want true")
}
if !cfg.Security.URLAllowlist.AllowPrivateHosts {
t.Fatalf("URLAllowlist.AllowPrivateHosts = false, want true")
}
if cfg.Security.ResponseHeaders.Enabled {
t.Fatalf("ResponseHeaders.Enabled = true, want false")

View File

@@ -34,15 +34,16 @@ func NewOAuthHandler(oauthService *service.OAuthService) *OAuthHandler {
// AccountHandler handles admin account management
type AccountHandler struct {
adminService service.AdminService
oauthService *service.OAuthService
openaiOAuthService *service.OpenAIOAuthService
geminiOAuthService *service.GeminiOAuthService
rateLimitService *service.RateLimitService
accountUsageService *service.AccountUsageService
accountTestService *service.AccountTestService
concurrencyService *service.ConcurrencyService
crsSyncService *service.CRSSyncService
adminService service.AdminService
oauthService *service.OAuthService
openaiOAuthService *service.OpenAIOAuthService
geminiOAuthService *service.GeminiOAuthService
antigravityOAuthService *service.AntigravityOAuthService
rateLimitService *service.RateLimitService
accountUsageService *service.AccountUsageService
accountTestService *service.AccountTestService
concurrencyService *service.ConcurrencyService
crsSyncService *service.CRSSyncService
}
// NewAccountHandler creates a new admin account handler
@@ -51,6 +52,7 @@ func NewAccountHandler(
oauthService *service.OAuthService,
openaiOAuthService *service.OpenAIOAuthService,
geminiOAuthService *service.GeminiOAuthService,
antigravityOAuthService *service.AntigravityOAuthService,
rateLimitService *service.RateLimitService,
accountUsageService *service.AccountUsageService,
accountTestService *service.AccountTestService,
@@ -58,15 +60,16 @@ func NewAccountHandler(
crsSyncService *service.CRSSyncService,
) *AccountHandler {
return &AccountHandler{
adminService: adminService,
oauthService: oauthService,
openaiOAuthService: openaiOAuthService,
geminiOAuthService: geminiOAuthService,
rateLimitService: rateLimitService,
accountUsageService: accountUsageService,
accountTestService: accountTestService,
concurrencyService: concurrencyService,
crsSyncService: crsSyncService,
adminService: adminService,
oauthService: oauthService,
openaiOAuthService: openaiOAuthService,
geminiOAuthService: geminiOAuthService,
antigravityOAuthService: antigravityOAuthService,
rateLimitService: rateLimitService,
accountUsageService: accountUsageService,
accountTestService: accountTestService,
concurrencyService: concurrencyService,
crsSyncService: crsSyncService,
}
}
@@ -420,6 +423,19 @@ func (h *AccountHandler) Refresh(c *gin.Context) {
newCredentials[k] = v
}
}
} else if account.Platform == service.PlatformAntigravity {
tokenInfo, err := h.antigravityOAuthService.RefreshAccountToken(c.Request.Context(), account)
if err != nil {
response.ErrorFrom(c, err)
return
}
newCredentials = h.antigravityOAuthService.BuildAccountCredentials(tokenInfo)
for k, v := range account.Credentials {
if _, exists := newCredentials[k]; !exists {
newCredentials[k] = v
}
}
} else {
// Use Anthropic/Claude OAuth service to refresh token
tokenInfo, err := h.oauthService.RefreshAccountToken(c.Request.Context(), account)

View File

@@ -26,31 +26,33 @@ func NewDashboardHandler(dashboardService *service.DashboardService) *DashboardH
}
// parseTimeRange parses start_date, end_date query parameters
// Uses user's timezone if provided, otherwise falls back to server timezone
func parseTimeRange(c *gin.Context) (time.Time, time.Time) {
now := timezone.Now()
userTZ := c.Query("timezone") // Get user's timezone from request
now := timezone.NowInUserLocation(userTZ)
startDate := c.Query("start_date")
endDate := c.Query("end_date")
var startTime, endTime time.Time
if startDate != "" {
if t, err := timezone.ParseInLocation("2006-01-02", startDate); err == nil {
if t, err := timezone.ParseInUserLocation("2006-01-02", startDate, userTZ); err == nil {
startTime = t
} else {
startTime = timezone.StartOfDay(now.AddDate(0, 0, -7))
startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ)
}
} else {
startTime = timezone.StartOfDay(now.AddDate(0, 0, -7))
startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ)
}
if endDate != "" {
if t, err := timezone.ParseInLocation("2006-01-02", endDate); err == nil {
if t, err := timezone.ParseInUserLocation("2006-01-02", endDate, userTZ); err == nil {
endTime = t.Add(24 * time.Hour) // Include the end date
} else {
endTime = timezone.StartOfDay(now.AddDate(0, 0, 1))
endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ)
}
} else {
endTime = timezone.StartOfDay(now.AddDate(0, 0, 1))
endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ)
}
return startTime, endTime

View File

@@ -33,6 +33,10 @@ type CreateGroupRequest struct {
DailyLimitUSD *float64 `json:"daily_limit_usd"`
WeeklyLimitUSD *float64 `json:"weekly_limit_usd"`
MonthlyLimitUSD *float64 `json:"monthly_limit_usd"`
// 图片生成计费配置antigravity 和 gemini 平台使用,负数表示清除配置)
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
}
// UpdateGroupRequest represents update group request
@@ -47,6 +51,10 @@ type UpdateGroupRequest struct {
DailyLimitUSD *float64 `json:"daily_limit_usd"`
WeeklyLimitUSD *float64 `json:"weekly_limit_usd"`
MonthlyLimitUSD *float64 `json:"monthly_limit_usd"`
// 图片生成计费配置antigravity 和 gemini 平台使用,负数表示清除配置)
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
}
// List handles listing all groups with pagination
@@ -139,6 +147,9 @@ func (h *GroupHandler) Create(c *gin.Context) {
DailyLimitUSD: req.DailyLimitUSD,
WeeklyLimitUSD: req.WeeklyLimitUSD,
MonthlyLimitUSD: req.MonthlyLimitUSD,
ImagePrice1K: req.ImagePrice1K,
ImagePrice2K: req.ImagePrice2K,
ImagePrice4K: req.ImagePrice4K,
})
if err != nil {
response.ErrorFrom(c, err)
@@ -174,6 +185,9 @@ func (h *GroupHandler) Update(c *gin.Context) {
DailyLimitUSD: req.DailyLimitUSD,
WeeklyLimitUSD: req.WeeklyLimitUSD,
MonthlyLimitUSD: req.MonthlyLimitUSD,
ImagePrice1K: req.ImagePrice1K,
ImagePrice2K: req.ImagePrice2K,
ImagePrice4K: req.ImagePrice4K,
})
if err != nil {
response.ErrorFrom(c, err)

View File

@@ -102,8 +102,9 @@ func (h *UsageHandler) List(c *gin.Context) {
// Parse date range
var startTime, endTime *time.Time
userTZ := c.Query("timezone") // Get user's timezone from request
if startDateStr := c.Query("start_date"); startDateStr != "" {
t, err := timezone.ParseInLocation("2006-01-02", startDateStr)
t, err := timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ)
if err != nil {
response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
return
@@ -112,7 +113,7 @@ func (h *UsageHandler) List(c *gin.Context) {
}
if endDateStr := c.Query("end_date"); endDateStr != "" {
t, err := timezone.ParseInLocation("2006-01-02", endDateStr)
t, err := timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ)
if err != nil {
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
return
@@ -151,8 +152,8 @@ func (h *UsageHandler) List(c *gin.Context) {
// Stats handles getting usage statistics with filters
// GET /api/v1/admin/usage/stats
func (h *UsageHandler) Stats(c *gin.Context) {
// Parse filters
var userID, apiKeyID int64
// Parse filters - same as List endpoint
var userID, apiKeyID, accountID, groupID int64
if userIDStr := c.Query("user_id"); userIDStr != "" {
id, err := strconv.ParseInt(userIDStr, 10, 64)
if err != nil {
@@ -171,8 +172,50 @@ func (h *UsageHandler) Stats(c *gin.Context) {
apiKeyID = id
}
if accountIDStr := c.Query("account_id"); accountIDStr != "" {
id, err := strconv.ParseInt(accountIDStr, 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account_id")
return
}
accountID = id
}
if groupIDStr := c.Query("group_id"); groupIDStr != "" {
id, err := strconv.ParseInt(groupIDStr, 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group_id")
return
}
groupID = id
}
model := c.Query("model")
var stream *bool
if streamStr := c.Query("stream"); streamStr != "" {
val, err := strconv.ParseBool(streamStr)
if err != nil {
response.BadRequest(c, "Invalid stream value, use true or false")
return
}
stream = &val
}
var billingType *int8
if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
val, err := strconv.ParseInt(billingTypeStr, 10, 8)
if err != nil {
response.BadRequest(c, "Invalid billing_type")
return
}
bt := int8(val)
billingType = &bt
}
// Parse date range
now := timezone.Now()
userTZ := c.Query("timezone")
now := timezone.NowInUserLocation(userTZ)
var startTime, endTime time.Time
startDateStr := c.Query("start_date")
@@ -180,12 +223,12 @@ func (h *UsageHandler) Stats(c *gin.Context) {
if startDateStr != "" && endDateStr != "" {
var err error
startTime, err = timezone.ParseInLocation("2006-01-02", startDateStr)
startTime, err = timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ)
if err != nil {
response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
return
}
endTime, err = timezone.ParseInLocation("2006-01-02", endDateStr)
endTime, err = timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ)
if err != nil {
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
return
@@ -195,39 +238,31 @@ func (h *UsageHandler) Stats(c *gin.Context) {
period := c.DefaultQuery("period", "today")
switch period {
case "today":
startTime = timezone.StartOfDay(now)
startTime = timezone.StartOfDayInUserLocation(now, userTZ)
case "week":
startTime = now.AddDate(0, 0, -7)
case "month":
startTime = now.AddDate(0, -1, 0)
default:
startTime = timezone.StartOfDay(now)
startTime = timezone.StartOfDayInUserLocation(now, userTZ)
}
endTime = now
}
if apiKeyID > 0 {
stats, err := h.usageService.GetStatsByAPIKey(c.Request.Context(), apiKeyID, startTime, endTime)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, stats)
return
// Build filters and call GetStatsWithFilters
filters := usagestats.UsageLogFilters{
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
Model: model,
Stream: stream,
BillingType: billingType,
StartTime: &startTime,
EndTime: &endTime,
}
if userID > 0 {
stats, err := h.usageService.GetStatsByUser(c.Request.Context(), userID, startTime, endTime)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, stats)
return
}
// Get global stats
stats, err := h.usageService.GetGlobalStats(c.Request.Context(), startTime, endTime)
stats, err := h.usageService.GetStatsWithFilters(c.Request.Context(), filters)
if err != nil {
response.ErrorFrom(c, err)
return

View File

@@ -78,6 +78,9 @@ func GroupFromServiceShallow(g *service.Group) *Group {
DailyLimitUSD: g.DailyLimitUSD,
WeeklyLimitUSD: g.WeeklyLimitUSD,
MonthlyLimitUSD: g.MonthlyLimitUSD,
ImagePrice1K: g.ImagePrice1K,
ImagePrice2K: g.ImagePrice2K,
ImagePrice4K: g.ImagePrice4K,
CreatedAt: g.CreatedAt,
UpdatedAt: g.UpdatedAt,
AccountCount: g.AccountCount,
@@ -247,6 +250,8 @@ func UsageLogFromService(l *service.UsageLog) *UsageLog {
Stream: l.Stream,
DurationMs: l.DurationMs,
FirstTokenMs: l.FirstTokenMs,
ImageCount: l.ImageCount,
ImageSize: l.ImageSize,
CreatedAt: l.CreatedAt,
User: UserFromServiceShallow(l.User),
APIKey: APIKeyFromService(l.APIKey),

View File

@@ -47,6 +47,11 @@ type Group struct {
WeeklyLimitUSD *float64 `json:"weekly_limit_usd"`
MonthlyLimitUSD *float64 `json:"monthly_limit_usd"`
// 图片生成计费配置(仅 antigravity 平台使用)
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -169,6 +174,10 @@ type UsageLog struct {
DurationMs *int `json:"duration_ms"`
FirstTokenMs *int `json:"first_token_ms"`
// 图片生成字段
ImageCount int `json:"image_count"`
ImageSize *string `json:"image_size"`
CreatedAt time.Time `json:"created_at"`
User *User `json:"user,omitempty"`

View File

@@ -83,19 +83,33 @@ func NewConcurrencyHelper(concurrencyService *service.ConcurrencyService, pingFo
// wrapReleaseOnDone ensures release runs at most once and still triggers on context cancellation.
// 用于避免客户端断开或上游超时导致的并发槽位泄漏。
// 修复:添加 quit channel 确保 goroutine 及时退出,避免泄露
func wrapReleaseOnDone(ctx context.Context, releaseFunc func()) func() {
if releaseFunc == nil {
return nil
}
var once sync.Once
wrapped := func() {
once.Do(releaseFunc)
quit := make(chan struct{})
release := func() {
once.Do(func() {
releaseFunc()
close(quit) // 通知监听 goroutine 退出
})
}
go func() {
<-ctx.Done()
wrapped()
select {
case <-ctx.Done():
// Context 取消时释放资源
release()
case <-quit:
// 正常释放已完成goroutine 退出
return
}
}()
return wrapped
return release
}
// IncrementWaitCount increments the wait count for a user

View File

@@ -0,0 +1,141 @@
package handler
import (
"context"
"runtime"
"sync/atomic"
"testing"
"time"
)
// TestWrapReleaseOnDone_NoGoroutineLeak 验证 wrapReleaseOnDone 修复后不会泄露 goroutine
func TestWrapReleaseOnDone_NoGoroutineLeak(t *testing.T) {
// 记录测试开始时的 goroutine 数量
runtime.GC()
time.Sleep(100 * time.Millisecond)
initialGoroutines := runtime.NumGoroutine()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var releaseCount int32
release := wrapReleaseOnDone(ctx, func() {
atomic.AddInt32(&releaseCount, 1)
})
// 正常释放
release()
// 等待足够时间确保 goroutine 退出
time.Sleep(200 * time.Millisecond)
// 验证只释放一次
if count := atomic.LoadInt32(&releaseCount); count != 1 {
t.Errorf("expected release count to be 1, got %d", count)
}
// 强制 GC清理已退出的 goroutine
runtime.GC()
time.Sleep(100 * time.Millisecond)
// 验证 goroutine 数量没有增加允许±2的误差考虑到测试框架本身可能创建的 goroutine
finalGoroutines := runtime.NumGoroutine()
if finalGoroutines > initialGoroutines+2 {
t.Errorf("goroutine leak detected: initial=%d, final=%d, leaked=%d",
initialGoroutines, finalGoroutines, finalGoroutines-initialGoroutines)
}
}
// TestWrapReleaseOnDone_ContextCancellation 验证 context 取消时也能正确释放
func TestWrapReleaseOnDone_ContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
var releaseCount int32
_ = wrapReleaseOnDone(ctx, func() {
atomic.AddInt32(&releaseCount, 1)
})
// 取消 context应该触发释放
cancel()
// 等待释放完成
time.Sleep(100 * time.Millisecond)
// 验证释放被调用
if count := atomic.LoadInt32(&releaseCount); count != 1 {
t.Errorf("expected release count to be 1, got %d", count)
}
}
// TestWrapReleaseOnDone_MultipleCallsOnlyReleaseOnce 验证多次调用 release 只释放一次
func TestWrapReleaseOnDone_MultipleCallsOnlyReleaseOnce(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var releaseCount int32
release := wrapReleaseOnDone(ctx, func() {
atomic.AddInt32(&releaseCount, 1)
})
// 调用多次
release()
release()
release()
// 等待执行完成
time.Sleep(100 * time.Millisecond)
// 验证只释放一次
if count := atomic.LoadInt32(&releaseCount); count != 1 {
t.Errorf("expected release count to be 1, got %d", count)
}
}
// TestWrapReleaseOnDone_NilReleaseFunc 验证 nil releaseFunc 不会 panic
func TestWrapReleaseOnDone_NilReleaseFunc(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
release := wrapReleaseOnDone(ctx, nil)
if release != nil {
t.Error("expected nil release function when releaseFunc is nil")
}
}
// TestWrapReleaseOnDone_ConcurrentCalls 验证并发调用的安全性
func TestWrapReleaseOnDone_ConcurrentCalls(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var releaseCount int32
release := wrapReleaseOnDone(ctx, func() {
atomic.AddInt32(&releaseCount, 1)
})
// 并发调用 release
const numGoroutines = 10
for i := 0; i < numGoroutines; i++ {
go release()
}
// 等待所有 goroutine 完成
time.Sleep(200 * time.Millisecond)
// 验证只释放一次
if count := atomic.LoadInt32(&releaseCount); count != 1 {
t.Errorf("expected release count to be 1, got %d", count)
}
}
// BenchmarkWrapReleaseOnDone 性能基准测试
func BenchmarkWrapReleaseOnDone(b *testing.B) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
b.ResetTimer()
for i := 0; i < b.N; i++ {
release := wrapReleaseOnDone(ctx, func() {})
release()
}
}

View File

@@ -88,8 +88,9 @@ func (h *UsageHandler) List(c *gin.Context) {
// Parse date range
var startTime, endTime *time.Time
userTZ := c.Query("timezone") // Get user's timezone from request
if startDateStr := c.Query("start_date"); startDateStr != "" {
t, err := timezone.ParseInLocation("2006-01-02", startDateStr)
t, err := timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ)
if err != nil {
response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
return
@@ -98,7 +99,7 @@ func (h *UsageHandler) List(c *gin.Context) {
}
if endDateStr := c.Query("end_date"); endDateStr != "" {
t, err := timezone.ParseInLocation("2006-01-02", endDateStr)
t, err := timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ)
if err != nil {
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
return
@@ -194,7 +195,8 @@ func (h *UsageHandler) Stats(c *gin.Context) {
}
// 获取时间范围参数
now := timezone.Now()
userTZ := c.Query("timezone") // Get user's timezone from request
now := timezone.NowInUserLocation(userTZ)
var startTime, endTime time.Time
// 优先使用 start_date 和 end_date 参数
@@ -204,12 +206,12 @@ func (h *UsageHandler) Stats(c *gin.Context) {
if startDateStr != "" && endDateStr != "" {
// 使用自定义日期范围
var err error
startTime, err = timezone.ParseInLocation("2006-01-02", startDateStr)
startTime, err = timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ)
if err != nil {
response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
return
}
endTime, err = timezone.ParseInLocation("2006-01-02", endDateStr)
endTime, err = timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ)
if err != nil {
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
return
@@ -221,13 +223,13 @@ func (h *UsageHandler) Stats(c *gin.Context) {
period := c.DefaultQuery("period", "today")
switch period {
case "today":
startTime = timezone.StartOfDay(now)
startTime = timezone.StartOfDayInUserLocation(now, userTZ)
case "week":
startTime = now.AddDate(0, 0, -7)
case "month":
startTime = now.AddDate(0, -1, 0)
default:
startTime = timezone.StartOfDay(now)
startTime = timezone.StartOfDayInUserLocation(now, userTZ)
}
endTime = now
}
@@ -248,31 +250,33 @@ func (h *UsageHandler) Stats(c *gin.Context) {
}
// parseUserTimeRange parses start_date, end_date query parameters for user dashboard
// Uses user's timezone if provided, otherwise falls back to server timezone
func parseUserTimeRange(c *gin.Context) (time.Time, time.Time) {
now := timezone.Now()
userTZ := c.Query("timezone") // Get user's timezone from request
now := timezone.NowInUserLocation(userTZ)
startDate := c.Query("start_date")
endDate := c.Query("end_date")
var startTime, endTime time.Time
if startDate != "" {
if t, err := timezone.ParseInLocation("2006-01-02", startDate); err == nil {
if t, err := timezone.ParseInUserLocation("2006-01-02", startDate, userTZ); err == nil {
startTime = t
} else {
startTime = timezone.StartOfDay(now.AddDate(0, 0, -7))
startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ)
}
} else {
startTime = timezone.StartOfDay(now.AddDate(0, 0, -7))
startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ)
}
if endDate != "" {
if t, err := timezone.ParseInLocation("2006-01-02", endDate); err == nil {
if t, err := timezone.ParseInUserLocation("2006-01-02", endDate, userTZ); err == nil {
endTime = t.Add(24 * time.Hour) // Include the end date
} else {
endTime = timezone.StartOfDay(now.AddDate(0, 0, 1))
endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ)
}
} else {
endTime = timezone.StartOfDay(now.AddDate(0, 0, 1))
endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ)
}
return startTime, endTime

View File

@@ -67,6 +67,13 @@ type GeminiGenerationConfig struct {
TopK *int `json:"topK,omitempty"`
ThinkingConfig *GeminiThinkingConfig `json:"thinkingConfig,omitempty"`
StopSequences []string `json:"stopSequences,omitempty"`
ImageConfig *GeminiImageConfig `json:"imageConfig,omitempty"`
}
// GeminiImageConfig Gemini 图片生成配置(仅 gemini-3-pro-image 支持)
type GeminiImageConfig struct {
AspectRatio string `json:"aspectRatio,omitempty"` // "1:1", "16:9", "9:16", "4:3", "3:4"
ImageSize string `json:"imageSize,omitempty"` // "1K", "2K", "4K"
}
// GeminiThinkingConfig Gemini thinking 配置

View File

@@ -16,7 +16,6 @@
package httpclient
import (
"crypto/tls"
"fmt"
"net/http"
"net/url"
@@ -40,7 +39,7 @@ type Options struct {
ProxyURL string // 代理 URL支持 http/https/socks5/socks5h
Timeout time.Duration // 请求总超时时间
ResponseHeaderTimeout time.Duration // 等待响应头超时时间
InsecureSkipVerify bool // 是否跳过 TLS 证书验证
InsecureSkipVerify bool // 是否跳过 TLS 证书验证(已禁用,不允许设置为 true
ProxyStrict bool // 严格代理模式:代理失败时返回错误而非回退
ValidateResolvedIP bool // 是否校验解析后的 IP防止 DNS Rebinding
AllowPrivateHosts bool // 允许私有地址解析(与 ValidateResolvedIP 一起使用)
@@ -113,7 +112,8 @@ func buildTransport(opts Options) (*http.Transport, error) {
}
if opts.InsecureSkipVerify {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
// 安全要求:禁止跳过证书验证,避免中间人攻击。
return nil, fmt.Errorf("insecure_skip_verify is not allowed; install a trusted certificate instead")
}
proxyURL := strings.TrimSpace(opts.ProxyURL)

View File

@@ -122,3 +122,40 @@ func StartOfMonth(t time.Time) time.Time {
func ParseInLocation(layout, value string) (time.Time, error) {
return time.ParseInLocation(layout, value, Location())
}
// ParseInUserLocation parses a time string in the user's timezone.
// If userTZ is empty or invalid, falls back to the configured server timezone.
func ParseInUserLocation(layout, value, userTZ string) (time.Time, error) {
loc := Location() // default to server timezone
if userTZ != "" {
if userLoc, err := time.LoadLocation(userTZ); err == nil {
loc = userLoc
}
}
return time.ParseInLocation(layout, value, loc)
}
// NowInUserLocation returns the current time in the user's timezone.
// If userTZ is empty or invalid, falls back to the configured server timezone.
func NowInUserLocation(userTZ string) time.Time {
if userTZ == "" {
return Now()
}
if userLoc, err := time.LoadLocation(userTZ); err == nil {
return time.Now().In(userLoc)
}
return Now()
}
// StartOfDayInUserLocation returns the start of the given day in the user's timezone.
// If userTZ is empty or invalid, falls back to the configured server timezone.
func StartOfDayInUserLocation(t time.Time, userTZ string) time.Time {
loc := Location()
if userTZ != "" {
if userLoc, err := time.LoadLocation(userTZ); err == nil {
loc = userLoc
}
}
t = t.In(loc)
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
}

View File

@@ -321,6 +321,9 @@ func groupEntityToService(g *dbent.Group) *service.Group {
DailyLimitUSD: g.DailyLimitUsd,
WeeklyLimitUSD: g.WeeklyLimitUsd,
MonthlyLimitUSD: g.MonthlyLimitUsd,
ImagePrice1K: g.ImagePrice1k,
ImagePrice2K: g.ImagePrice2k,
ImagePrice4K: g.ImagePrice4k,
DefaultValidityDays: g.DefaultValidityDays,
CreatedAt: g.CreatedAt,
UpdatedAt: g.UpdatedAt,

View File

@@ -56,7 +56,7 @@ func InitEnt(cfg *config.Config) (*ent.Client, *sql.DB, error) {
// 确保数据库 schema 已准备就绪。
// SQL 迁移文件是 schema 的权威来源source of truth
// 这种方式比 Ent 的自动迁移更可控,支持复杂的迁移场景。
migrationCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
migrationCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
if err := applyMigrationsFS(migrationCtx, drv.DB(), migrations.FS); err != nil {
_ = drv.Close() // 迁移失败时关闭驱动,避免资源泄露

View File

@@ -43,6 +43,9 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er
SetNillableDailyLimitUsd(groupIn.DailyLimitUSD).
SetNillableWeeklyLimitUsd(groupIn.WeeklyLimitUSD).
SetNillableMonthlyLimitUsd(groupIn.MonthlyLimitUSD).
SetNillableImagePrice1k(groupIn.ImagePrice1K).
SetNillableImagePrice2k(groupIn.ImagePrice2K).
SetNillableImagePrice4k(groupIn.ImagePrice4K).
SetDefaultValidityDays(groupIn.DefaultValidityDays)
created, err := builder.Save(ctx)
@@ -80,6 +83,9 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
SetNillableDailyLimitUsd(groupIn.DailyLimitUSD).
SetNillableWeeklyLimitUsd(groupIn.WeeklyLimitUSD).
SetNillableMonthlyLimitUsd(groupIn.MonthlyLimitUSD).
SetNillableImagePrice1k(groupIn.ImagePrice1K).
SetNillableImagePrice2k(groupIn.ImagePrice2K).
SetNillableImagePrice4k(groupIn.ImagePrice4K).
SetDefaultValidityDays(groupIn.DefaultValidityDays).
Save(ctx)
if err != nil {

View File

@@ -24,7 +24,7 @@ func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber {
validateResolvedIP = cfg.Security.URLAllowlist.Enabled
}
if insecure {
log.Printf("[ProxyProbe] Warning: TLS verification is disabled for proxy probing.")
log.Printf("[ProxyProbe] Warning: insecure_skip_verify is not allowed and will cause probe failure.")
}
return &proxyProbeService{
ipInfoURL: defaultIPInfoURL,

View File

@@ -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, 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, image_count, image_size, created_at"
type usageLogRepository struct {
client *dbent.Client
@@ -109,6 +109,8 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
stream,
duration_ms,
first_token_ms,
image_count,
image_size,
created_at
) VALUES (
$1, $2, $3, $4, $5,
@@ -116,7 +118,8 @@ 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
$20, $21, $22, $23, $24,
$25, $26, $27
)
ON CONFLICT (request_id, api_key_id) DO NOTHING
RETURNING id, created_at
@@ -126,6 +129,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
subscriptionID := nullInt64(log.SubscriptionID)
duration := nullInt(log.DurationMs)
firstToken := nullInt(log.FirstTokenMs)
imageSize := nullString(log.ImageSize)
var requestIDArg any
if requestID != "" {
@@ -157,6 +161,8 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
log.Stream,
duration,
firstToken,
log.ImageCount,
imageSize,
createdAt,
}
if err := scanSingleRow(ctx, sqlq, query, args, &log.ID, &log.CreatedAt); err != nil {
@@ -1382,6 +1388,81 @@ func (r *usageLogRepository) GetGlobalStats(ctx context.Context, startTime, endT
return stats, nil
}
// GetStatsWithFilters gets usage statistics with optional filters
func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters UsageLogFilters) (*UsageStats, error) {
conditions := make([]string, 0, 9)
args := make([]any, 0, 9)
if filters.UserID > 0 {
conditions = append(conditions, fmt.Sprintf("user_id = $%d", len(args)+1))
args = append(args, filters.UserID)
}
if filters.APIKeyID > 0 {
conditions = append(conditions, fmt.Sprintf("api_key_id = $%d", len(args)+1))
args = append(args, filters.APIKeyID)
}
if filters.AccountID > 0 {
conditions = append(conditions, fmt.Sprintf("account_id = $%d", len(args)+1))
args = append(args, filters.AccountID)
}
if filters.GroupID > 0 {
conditions = append(conditions, fmt.Sprintf("group_id = $%d", len(args)+1))
args = append(args, filters.GroupID)
}
if filters.Model != "" {
conditions = append(conditions, fmt.Sprintf("model = $%d", len(args)+1))
args = append(args, filters.Model)
}
if filters.Stream != nil {
conditions = append(conditions, fmt.Sprintf("stream = $%d", len(args)+1))
args = append(args, *filters.Stream)
}
if filters.BillingType != nil {
conditions = append(conditions, fmt.Sprintf("billing_type = $%d", len(args)+1))
args = append(args, int16(*filters.BillingType))
}
if filters.StartTime != nil {
conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1))
args = append(args, *filters.StartTime)
}
if filters.EndTime != nil {
conditions = append(conditions, fmt.Sprintf("created_at <= $%d", len(args)+1))
args = append(args, *filters.EndTime)
}
query := fmt.Sprintf(`
SELECT
COUNT(*) as total_requests,
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens,
COALESCE(SUM(total_cost), 0) as total_cost,
COALESCE(SUM(actual_cost), 0) as total_actual_cost,
COALESCE(AVG(duration_ms), 0) as avg_duration_ms
FROM usage_logs
%s
`, buildWhere(conditions))
stats := &UsageStats{}
if err := scanSingleRow(
ctx,
r.sql,
query,
args,
&stats.TotalRequests,
&stats.TotalInputTokens,
&stats.TotalOutputTokens,
&stats.TotalCacheTokens,
&stats.TotalCost,
&stats.TotalActualCost,
&stats.AverageDurationMs,
); err != nil {
return nil, err
}
stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens
return stats, nil
}
// AccountUsageHistory represents daily usage history for an account
type AccountUsageHistory = usagestats.AccountUsageHistory
@@ -1789,6 +1870,8 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
stream bool
durationMs sql.NullInt64
firstTokenMs sql.NullInt64
imageCount int
imageSize sql.NullString
createdAt time.Time
)
@@ -1818,6 +1901,8 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
&stream,
&durationMs,
&firstTokenMs,
&imageCount,
&imageSize,
&createdAt,
); err != nil {
return nil, err
@@ -1844,6 +1929,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
RateMultiplier: rateMultiplier,
BillingType: int8(billingType),
Stream: stream,
ImageCount: imageCount,
CreatedAt: createdAt,
}
@@ -1866,6 +1952,9 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
value := int(firstTokenMs.Int64)
log.FirstTokenMs = &value
}
if imageSize.Valid {
log.ImageSize = &imageSize.String
}
return log, nil
}
@@ -1938,6 +2027,13 @@ func nullInt(v *int) sql.NullInt64 {
return sql.NullInt64{Int64: int64(*v), Valid: true}
}
func nullString(v *string) sql.NullString {
if v == nil || *v == "" {
return sql.NullString{}
}
return sql.NullString{String: *v, Valid: true}
}
func setToSlice(set map[int64]struct{}) []int64 {
out := make([]int64, 0, len(set))
for id := range set {

View File

@@ -241,6 +241,8 @@ func TestAPIContracts(t *testing.T) {
"stream": true,
"duration_ms": 100,
"first_token_ms": 50,
"image_count": 0,
"image_size": null,
"created_at": "2025-01-02T03:04:05Z"
}
],
@@ -1063,6 +1065,10 @@ func (r *stubUsageLogRepo) GetAccountUsageStats(ctx context.Context, accountID i
return nil, errors.New("not implemented")
}
func (r *stubUsageLogRepo) GetStatsWithFilters(ctx context.Context, filters usagestats.UsageLogFilters) (*usagestats.UsageStats, error) {
return nil, errors.New("not implemented")
}
type stubSettingRepo struct {
all map[string]string
}

View File

@@ -47,6 +47,7 @@ type UsageLogRepository interface {
// Admin usage listing/stats
ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]UsageLog, *pagination.PaginationResult, error)
GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*usagestats.UsageStats, error)
GetStatsWithFilters(ctx context.Context, filters usagestats.UsageLogFilters) (*usagestats.UsageStats, error)
// Account stats
GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.AccountUsageStatsResponse, error)

View File

@@ -98,6 +98,10 @@ type CreateGroupInput struct {
DailyLimitUSD *float64 // 日限额 (USD)
WeeklyLimitUSD *float64 // 周限额 (USD)
MonthlyLimitUSD *float64 // 月限额 (USD)
// 图片生成计费配置(仅 antigravity 平台使用)
ImagePrice1K *float64
ImagePrice2K *float64
ImagePrice4K *float64
}
type UpdateGroupInput struct {
@@ -111,6 +115,10 @@ type UpdateGroupInput struct {
DailyLimitUSD *float64 // 日限额 (USD)
WeeklyLimitUSD *float64 // 周限额 (USD)
MonthlyLimitUSD *float64 // 月限额 (USD)
// 图片生成计费配置(仅 antigravity 平台使用)
ImagePrice1K *float64
ImagePrice2K *float64
ImagePrice4K *float64
}
type CreateAccountInput struct {
@@ -498,6 +506,11 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
weeklyLimit := normalizeLimit(input.WeeklyLimitUSD)
monthlyLimit := normalizeLimit(input.MonthlyLimitUSD)
// 图片价格负数表示清除使用默认价格0 保留(表示免费)
imagePrice1K := normalizePrice(input.ImagePrice1K)
imagePrice2K := normalizePrice(input.ImagePrice2K)
imagePrice4K := normalizePrice(input.ImagePrice4K)
group := &Group{
Name: input.Name,
Description: input.Description,
@@ -509,6 +522,9 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
DailyLimitUSD: dailyLimit,
WeeklyLimitUSD: weeklyLimit,
MonthlyLimitUSD: monthlyLimit,
ImagePrice1K: imagePrice1K,
ImagePrice2K: imagePrice2K,
ImagePrice4K: imagePrice4K,
}
if err := s.groupRepo.Create(ctx, group); err != nil {
return nil, err
@@ -524,6 +540,14 @@ func normalizeLimit(limit *float64) *float64 {
return limit
}
// normalizePrice 将负数转换为 nil表示使用默认价格0 保留(表示免费)
func normalizePrice(price *float64) *float64 {
if price == nil || *price < 0 {
return nil
}
return price
}
func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *UpdateGroupInput) (*Group, error) {
group, err := s.groupRepo.GetByID(ctx, id)
if err != nil {
@@ -563,6 +587,16 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
if input.MonthlyLimitUSD != nil {
group.MonthlyLimitUSD = normalizeLimit(input.MonthlyLimitUSD)
}
// 图片生成计费配置:负数表示清除(使用默认价格)
if input.ImagePrice1K != nil {
group.ImagePrice1K = normalizePrice(input.ImagePrice1K)
}
if input.ImagePrice2K != nil {
group.ImagePrice2K = normalizePrice(input.ImagePrice2K)
}
if input.ImagePrice4K != nil {
group.ImagePrice4K = normalizePrice(input.ImagePrice4K)
}
if err := s.groupRepo.Update(ctx, group); err != nil {
return nil, err

View File

@@ -0,0 +1,197 @@
//go:build unit
package service
import (
"context"
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/stretchr/testify/require"
)
// groupRepoStubForAdmin 用于测试 AdminService 的 GroupRepository Stub
type groupRepoStubForAdmin struct {
created *Group // 记录 Create 调用的参数
updated *Group // 记录 Update 调用的参数
getByID *Group // GetByID 返回值
getErr error // GetByID 返回的错误
}
func (s *groupRepoStubForAdmin) Create(_ context.Context, g *Group) error {
s.created = g
return nil
}
func (s *groupRepoStubForAdmin) Update(_ context.Context, g *Group) error {
s.updated = g
return nil
}
func (s *groupRepoStubForAdmin) GetByID(_ 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")
}
func (s *groupRepoStubForAdmin) DeleteCascade(_ context.Context, _ int64) ([]int64, error) {
panic("unexpected DeleteCascade call")
}
func (s *groupRepoStubForAdmin) List(_ context.Context, _ pagination.PaginationParams) ([]Group, *pagination.PaginationResult, error) {
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) ListActive(_ context.Context) ([]Group, error) {
panic("unexpected ListActive call")
}
func (s *groupRepoStubForAdmin) ListActiveByPlatform(_ context.Context, _ string) ([]Group, error) {
panic("unexpected ListActiveByPlatform call")
}
func (s *groupRepoStubForAdmin) ExistsByName(_ context.Context, _ string) (bool, error) {
panic("unexpected ExistsByName call")
}
func (s *groupRepoStubForAdmin) GetAccountCount(_ context.Context, _ int64) (int64, error) {
panic("unexpected GetAccountCount call")
}
func (s *groupRepoStubForAdmin) DeleteAccountGroupsByGroupID(_ context.Context, _ int64) (int64, error) {
panic("unexpected DeleteAccountGroupsByGroupID call")
}
// TestAdminService_CreateGroup_WithImagePricing 测试创建分组时 ImagePrice 字段正确传递
func TestAdminService_CreateGroup_WithImagePricing(t *testing.T) {
repo := &groupRepoStubForAdmin{}
svc := &adminServiceImpl{groupRepo: repo}
price1K := 0.10
price2K := 0.15
price4K := 0.30
input := &CreateGroupInput{
Name: "test-group",
Description: "Test group",
Platform: PlatformAntigravity,
RateMultiplier: 1.0,
ImagePrice1K: &price1K,
ImagePrice2K: &price2K,
ImagePrice4K: &price4K,
}
group, err := svc.CreateGroup(context.Background(), input)
require.NoError(t, err)
require.NotNil(t, group)
// 验证 repo 收到了正确的字段
require.NotNil(t, repo.created)
require.NotNil(t, repo.created.ImagePrice1K)
require.NotNil(t, repo.created.ImagePrice2K)
require.NotNil(t, repo.created.ImagePrice4K)
require.InDelta(t, 0.10, *repo.created.ImagePrice1K, 0.0001)
require.InDelta(t, 0.15, *repo.created.ImagePrice2K, 0.0001)
require.InDelta(t, 0.30, *repo.created.ImagePrice4K, 0.0001)
}
// TestAdminService_CreateGroup_NilImagePricing 测试 ImagePrice 为 nil 时正常创建
func TestAdminService_CreateGroup_NilImagePricing(t *testing.T) {
repo := &groupRepoStubForAdmin{}
svc := &adminServiceImpl{groupRepo: repo}
input := &CreateGroupInput{
Name: "test-group",
Description: "Test group",
Platform: PlatformAntigravity,
RateMultiplier: 1.0,
// ImagePrice 字段全部为 nil
}
group, err := svc.CreateGroup(context.Background(), input)
require.NoError(t, err)
require.NotNil(t, group)
// 验证 ImagePrice 字段为 nil
require.NotNil(t, repo.created)
require.Nil(t, repo.created.ImagePrice1K)
require.Nil(t, repo.created.ImagePrice2K)
require.Nil(t, repo.created.ImagePrice4K)
}
// TestAdminService_UpdateGroup_WithImagePricing 测试更新分组时 ImagePrice 字段正确更新
func TestAdminService_UpdateGroup_WithImagePricing(t *testing.T) {
existingGroup := &Group{
ID: 1,
Name: "existing-group",
Platform: PlatformAntigravity,
Status: StatusActive,
}
repo := &groupRepoStubForAdmin{getByID: existingGroup}
svc := &adminServiceImpl{groupRepo: repo}
price1K := 0.12
price2K := 0.18
price4K := 0.36
input := &UpdateGroupInput{
ImagePrice1K: &price1K,
ImagePrice2K: &price2K,
ImagePrice4K: &price4K,
}
group, err := svc.UpdateGroup(context.Background(), 1, input)
require.NoError(t, err)
require.NotNil(t, group)
// 验证 repo 收到了更新后的字段
require.NotNil(t, repo.updated)
require.NotNil(t, repo.updated.ImagePrice1K)
require.NotNil(t, repo.updated.ImagePrice2K)
require.NotNil(t, repo.updated.ImagePrice4K)
require.InDelta(t, 0.12, *repo.updated.ImagePrice1K, 0.0001)
require.InDelta(t, 0.18, *repo.updated.ImagePrice2K, 0.0001)
require.InDelta(t, 0.36, *repo.updated.ImagePrice4K, 0.0001)
}
// TestAdminService_UpdateGroup_PartialImagePricing 测试仅更新部分 ImagePrice 字段
func TestAdminService_UpdateGroup_PartialImagePricing(t *testing.T) {
oldPrice2K := 0.15
existingGroup := &Group{
ID: 1,
Name: "existing-group",
Platform: PlatformAntigravity,
Status: StatusActive,
ImagePrice2K: &oldPrice2K, // 已有 2K 价格
}
repo := &groupRepoStubForAdmin{getByID: existingGroup}
svc := &adminServiceImpl{groupRepo: repo}
// 只更新 1K 价格
price1K := 0.10
input := &UpdateGroupInput{
ImagePrice1K: &price1K,
// ImagePrice2K 和 ImagePrice4K 为 nil不更新
}
group, err := svc.UpdateGroup(context.Background(), 1, input)
require.NoError(t, err)
require.NotNil(t, group)
// 验证1K 被更新2K 保持原值4K 仍为 nil
require.NotNil(t, repo.updated)
require.NotNil(t, repo.updated.ImagePrice1K)
require.InDelta(t, 0.10, *repo.updated.ImagePrice1K, 0.0001)
require.NotNil(t, repo.updated.ImagePrice2K)
require.InDelta(t, 0.15, *repo.updated.ImagePrice2K, 0.0001) // 原值保持
require.Nil(t, repo.updated.ImagePrice4K)
}

View File

@@ -860,6 +860,9 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
return nil, s.writeGoogleError(c, http.StatusBadRequest, "Request body is empty")
}
// 解析请求以获取 image_size用于图片计费
imageSize := s.extractImageSize(body)
switch action {
case "generateContent", "streamGenerateContent":
// ok
@@ -1059,6 +1062,13 @@ handleSuccess:
usage = &ClaudeUsage{}
}
// 判断是否为图片生成模型
imageCount := 0
if isImageGenerationModel(mappedModel) {
// Gemini 图片生成 API 每次请求只生成一张图片API 限制)
imageCount = 1
}
return &ForwardResult{
RequestID: requestID,
Usage: *usage,
@@ -1066,6 +1076,8 @@ handleSuccess:
Stream: stream,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ImageCount: imageCount,
ImageSize: imageSize,
}, nil
}
@@ -1572,3 +1584,36 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context
}
}
// extractImageSize 从 Gemini 请求中提取 image_size 参数
func (s *AntigravityGatewayService) extractImageSize(body []byte) string {
var req antigravity.GeminiRequest
if err := json.Unmarshal(body, &req); err != nil {
return "2K" // 默认 2K
}
if req.GenerationConfig != nil && req.GenerationConfig.ImageConfig != nil {
size := strings.ToUpper(strings.TrimSpace(req.GenerationConfig.ImageConfig.ImageSize))
if size == "1K" || size == "2K" || size == "4K" {
return size
}
}
return "2K" // 默认 2K
}
// isImageGenerationModel 判断模型是否为图片生成模型
// 支持的模型gemini-3-pro-image, gemini-3-pro-image-preview, gemini-2.5-flash-image 等
func isImageGenerationModel(model string) bool {
modelLower := strings.ToLower(model)
// 移除 models/ 前缀
modelLower = strings.TrimPrefix(modelLower, "models/")
// 精确匹配或前缀匹配
return modelLower == "gemini-3-pro-image" ||
modelLower == "gemini-3-pro-image-preview" ||
strings.HasPrefix(modelLower, "gemini-3-pro-image-") ||
modelLower == "gemini-2.5-flash-image" ||
modelLower == "gemini-2.5-flash-image-preview" ||
strings.HasPrefix(modelLower, "gemini-2.5-flash-image-")
}

View File

@@ -0,0 +1,123 @@
//go:build unit
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
// TestIsImageGenerationModel_GeminiProImage 测试 gemini-3-pro-image 识别
func TestIsImageGenerationModel_GeminiProImage(t *testing.T) {
require.True(t, isImageGenerationModel("gemini-3-pro-image"))
require.True(t, isImageGenerationModel("gemini-3-pro-image-preview"))
require.True(t, isImageGenerationModel("models/gemini-3-pro-image"))
}
// TestIsImageGenerationModel_GeminiFlashImage 测试 gemini-2.5-flash-image 识别
func TestIsImageGenerationModel_GeminiFlashImage(t *testing.T) {
require.True(t, isImageGenerationModel("gemini-2.5-flash-image"))
require.True(t, isImageGenerationModel("gemini-2.5-flash-image-preview"))
}
// TestIsImageGenerationModel_RegularModel 测试普通模型不被识别为图片模型
func TestIsImageGenerationModel_RegularModel(t *testing.T) {
require.False(t, isImageGenerationModel("claude-3-opus"))
require.False(t, isImageGenerationModel("claude-sonnet-4-20250514"))
require.False(t, isImageGenerationModel("gpt-4o"))
require.False(t, isImageGenerationModel("gemini-2.5-pro")) // 非图片模型
require.False(t, isImageGenerationModel("gemini-2.5-flash"))
// 验证不会误匹配包含关键词的自定义模型名
require.False(t, isImageGenerationModel("my-gemini-3-pro-image-test"))
require.False(t, isImageGenerationModel("custom-gemini-2.5-flash-image-wrapper"))
}
// TestIsImageGenerationModel_CaseInsensitive 测试大小写不敏感
func TestIsImageGenerationModel_CaseInsensitive(t *testing.T) {
require.True(t, isImageGenerationModel("GEMINI-3-PRO-IMAGE"))
require.True(t, isImageGenerationModel("Gemini-3-Pro-Image"))
require.True(t, isImageGenerationModel("GEMINI-2.5-FLASH-IMAGE"))
}
// TestExtractImageSize_ValidSizes 测试有效尺寸解析
func TestExtractImageSize_ValidSizes(t *testing.T) {
svc := &AntigravityGatewayService{}
// 1K
body := []byte(`{"generationConfig":{"imageConfig":{"imageSize":"1K"}}}`)
require.Equal(t, "1K", svc.extractImageSize(body))
// 2K
body = []byte(`{"generationConfig":{"imageConfig":{"imageSize":"2K"}}}`)
require.Equal(t, "2K", svc.extractImageSize(body))
// 4K
body = []byte(`{"generationConfig":{"imageConfig":{"imageSize":"4K"}}}`)
require.Equal(t, "4K", svc.extractImageSize(body))
}
// TestExtractImageSize_CaseInsensitive 测试大小写不敏感
func TestExtractImageSize_CaseInsensitive(t *testing.T) {
svc := &AntigravityGatewayService{}
body := []byte(`{"generationConfig":{"imageConfig":{"imageSize":"1k"}}}`)
require.Equal(t, "1K", svc.extractImageSize(body))
body = []byte(`{"generationConfig":{"imageConfig":{"imageSize":"4k"}}}`)
require.Equal(t, "4K", svc.extractImageSize(body))
}
// TestExtractImageSize_Default 测试无 imageConfig 返回默认 2K
func TestExtractImageSize_Default(t *testing.T) {
svc := &AntigravityGatewayService{}
// 无 generationConfig
body := []byte(`{"contents":[]}`)
require.Equal(t, "2K", svc.extractImageSize(body))
// 有 generationConfig 但无 imageConfig
body = []byte(`{"generationConfig":{"temperature":0.7}}`)
require.Equal(t, "2K", svc.extractImageSize(body))
// 有 imageConfig 但无 imageSize
body = []byte(`{"generationConfig":{"imageConfig":{}}}`)
require.Equal(t, "2K", svc.extractImageSize(body))
}
// TestExtractImageSize_InvalidJSON 测试非法 JSON 返回默认 2K
func TestExtractImageSize_InvalidJSON(t *testing.T) {
svc := &AntigravityGatewayService{}
body := []byte(`not valid json`)
require.Equal(t, "2K", svc.extractImageSize(body))
body = []byte(`{"broken":`)
require.Equal(t, "2K", svc.extractImageSize(body))
}
// TestExtractImageSize_EmptySize 测试空 imageSize 返回默认 2K
func TestExtractImageSize_EmptySize(t *testing.T) {
svc := &AntigravityGatewayService{}
body := []byte(`{"generationConfig":{"imageConfig":{"imageSize":""}}}`)
require.Equal(t, "2K", svc.extractImageSize(body))
// 空格
body = []byte(`{"generationConfig":{"imageConfig":{"imageSize":" "}}}`)
require.Equal(t, "2K", svc.extractImageSize(body))
}
// TestExtractImageSize_InvalidSize 测试无效尺寸返回默认 2K
func TestExtractImageSize_InvalidSize(t *testing.T) {
svc := &AntigravityGatewayService{}
body := []byte(`{"generationConfig":{"imageConfig":{"imageSize":"3K"}}}`)
require.Equal(t, "2K", svc.extractImageSize(body))
body = []byte(`{"generationConfig":{"imageConfig":{"imageSize":"8K"}}}`)
require.Equal(t, "2K", svc.extractImageSize(body))
body = []byte(`{"generationConfig":{"imageConfig":{"imageSize":"invalid"}}}`)
require.Equal(t, "2K", svc.extractImageSize(body))
}

View File

@@ -20,12 +20,16 @@ var (
ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists")
ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token")
ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired")
ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large")
ErrTokenRevoked = infraerrors.Unauthorized("TOKEN_REVOKED", "token has been revoked")
ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required")
ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled")
ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable")
)
// maxTokenLength 限制 token 大小,避免超长 header 触发解析时的异常内存分配。
const maxTokenLength = 8192
// JWTClaims JWT载荷数据
type JWTClaims struct {
UserID int64 `json:"user_id"`
@@ -309,7 +313,20 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (string
// ValidateToken 验证JWT token并返回用户声明
func (s *AuthService) ValidateToken(tokenString string) (*JWTClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (any, error) {
// 先做长度校验,尽早拒绝异常超长 token降低 DoS 风险。
if len(tokenString) > maxTokenLength {
return nil, ErrTokenTooLarge
}
// 使用解析器并限制可接受的签名算法,防止算法混淆。
parser := jwt.NewParser(jwt.WithValidMethods([]string{
jwt.SigningMethodHS256.Name,
jwt.SigningMethodHS384.Name,
jwt.SigningMethodHS512.Name,
}))
// 保留默认 claims 校验exp/nbf避免放行过期或未生效的 token。
token, err := parser.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (any, error) {
// 验证签名方法
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])

View File

@@ -295,3 +295,88 @@ func (s *BillingService) ForceUpdatePricing() error {
}
return fmt.Errorf("pricing service not initialized")
}
// ImagePriceConfig 图片计费配置
type ImagePriceConfig struct {
Price1K *float64 // 1K 尺寸价格nil 表示使用默认值)
Price2K *float64 // 2K 尺寸价格nil 表示使用默认值)
Price4K *float64 // 4K 尺寸价格nil 表示使用默认值)
}
// CalculateImageCost 计算图片生成费用
// model: 请求的模型名称(用于获取 LiteLLM 默认价格)
// imageSize: 图片尺寸 "1K", "2K", "4K"
// imageCount: 生成的图片数量
// groupConfig: 分组配置的价格(可能为 nil表示使用默认值
// rateMultiplier: 费率倍数
func (s *BillingService) CalculateImageCost(model string, imageSize string, imageCount int, groupConfig *ImagePriceConfig, rateMultiplier float64) *CostBreakdown {
if imageCount <= 0 {
return &CostBreakdown{}
}
// 获取单价
unitPrice := s.getImageUnitPrice(model, imageSize, groupConfig)
// 计算总费用
totalCost := unitPrice * float64(imageCount)
// 应用倍率
if rateMultiplier <= 0 {
rateMultiplier = 1.0
}
actualCost := totalCost * rateMultiplier
return &CostBreakdown{
TotalCost: totalCost,
ActualCost: actualCost,
}
}
// getImageUnitPrice 获取图片单价
func (s *BillingService) getImageUnitPrice(model string, imageSize string, groupConfig *ImagePriceConfig) float64 {
// 优先使用分组配置的价格
if groupConfig != nil {
switch imageSize {
case "1K":
if groupConfig.Price1K != nil {
return *groupConfig.Price1K
}
case "2K":
if groupConfig.Price2K != nil {
return *groupConfig.Price2K
}
case "4K":
if groupConfig.Price4K != nil {
return *groupConfig.Price4K
}
}
}
// 回退到 LiteLLM 默认价格
return s.getDefaultImagePrice(model, imageSize)
}
// getDefaultImagePrice 获取 LiteLLM 默认图片价格
func (s *BillingService) getDefaultImagePrice(model string, imageSize string) float64 {
basePrice := 0.0
// 从 PricingService 获取 output_cost_per_image
if s.pricingService != nil {
pricing := s.pricingService.GetModelPricing(model)
if pricing != nil && pricing.OutputCostPerImage > 0 {
basePrice = pricing.OutputCostPerImage
}
}
// 如果没有找到价格,使用硬编码默认值($0.134,来自 gemini-3-pro-image-preview
if basePrice <= 0 {
basePrice = 0.134
}
// 4K 尺寸翻倍
if imageSize == "4K" {
return basePrice * 2
}
return basePrice
}

View File

@@ -0,0 +1,149 @@
//go:build unit
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
// TestCalculateImageCost_DefaultPricing 测试无分组配置时使用默认价格
func TestCalculateImageCost_DefaultPricing(t *testing.T) {
svc := &BillingService{} // pricingService 为 nil使用硬编码默认值
// 2K 尺寸,默认价格 $0.134
cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, nil, 1.0)
require.InDelta(t, 0.134, cost.TotalCost, 0.0001)
require.InDelta(t, 0.134, cost.ActualCost, 0.0001)
// 多张图片
cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 3, nil, 1.0)
require.InDelta(t, 0.402, cost.TotalCost, 0.0001)
}
// TestCalculateImageCost_GroupCustomPricing 测试分组自定义价格
func TestCalculateImageCost_GroupCustomPricing(t *testing.T) {
svc := &BillingService{}
price1K := 0.10
price2K := 0.15
price4K := 0.30
groupConfig := &ImagePriceConfig{
Price1K: &price1K,
Price2K: &price2K,
Price4K: &price4K,
}
// 1K 使用分组价格
cost := svc.CalculateImageCost("gemini-3-pro-image", "1K", 2, groupConfig, 1.0)
require.InDelta(t, 0.20, cost.TotalCost, 0.0001)
// 2K 使用分组价格
cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, groupConfig, 1.0)
require.InDelta(t, 0.15, cost.TotalCost, 0.0001)
// 4K 使用分组价格
cost = svc.CalculateImageCost("gemini-3-pro-image", "4K", 1, groupConfig, 1.0)
require.InDelta(t, 0.30, cost.TotalCost, 0.0001)
}
// TestCalculateImageCost_4KDoublePrice 测试 4K 默认价格翻倍
func TestCalculateImageCost_4KDoublePrice(t *testing.T) {
svc := &BillingService{}
// 4K 尺寸,默认价格翻倍 $0.134 * 2 = $0.268
cost := svc.CalculateImageCost("gemini-3-pro-image", "4K", 1, nil, 1.0)
require.InDelta(t, 0.268, cost.TotalCost, 0.0001)
}
// TestCalculateImageCost_RateMultiplier 测试费率倍数
func TestCalculateImageCost_RateMultiplier(t *testing.T) {
svc := &BillingService{}
// 费率倍数 1.5x
cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, nil, 1.5)
require.InDelta(t, 0.134, cost.TotalCost, 0.0001) // TotalCost 不变
require.InDelta(t, 0.201, cost.ActualCost, 0.0001) // ActualCost = 0.134 * 1.5
// 费率倍数 2.0x
cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 2, nil, 2.0)
require.InDelta(t, 0.268, cost.TotalCost, 0.0001)
require.InDelta(t, 0.536, cost.ActualCost, 0.0001)
}
// TestCalculateImageCost_ZeroCount 测试 imageCount=0
func TestCalculateImageCost_ZeroCount(t *testing.T) {
svc := &BillingService{}
cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", 0, nil, 1.0)
require.Equal(t, 0.0, cost.TotalCost)
require.Equal(t, 0.0, cost.ActualCost)
}
// TestCalculateImageCost_NegativeCount 测试 imageCount=-1
func TestCalculateImageCost_NegativeCount(t *testing.T) {
svc := &BillingService{}
cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", -1, nil, 1.0)
require.Equal(t, 0.0, cost.TotalCost)
require.Equal(t, 0.0, cost.ActualCost)
}
// TestCalculateImageCost_ZeroRateMultiplier 测试费率倍数为 0 时默认使用 1.0
func TestCalculateImageCost_ZeroRateMultiplier(t *testing.T) {
svc := &BillingService{}
cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, nil, 0)
require.InDelta(t, 0.134, cost.TotalCost, 0.0001)
require.InDelta(t, 0.134, cost.ActualCost, 0.0001) // 0 倍率当作 1.0 处理
}
// TestGetImageUnitPrice_GroupPriorityOverDefault 测试分组价格优先于默认价格
func TestGetImageUnitPrice_GroupPriorityOverDefault(t *testing.T) {
svc := &BillingService{}
price2K := 0.20
groupConfig := &ImagePriceConfig{
Price2K: &price2K,
}
// 分组配置了 2K 价格,应该使用分组价格而不是默认的 $0.134
cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, groupConfig, 1.0)
require.InDelta(t, 0.20, cost.TotalCost, 0.0001)
}
// TestGetImageUnitPrice_PartialGroupConfig 测试分组部分配置时回退默认
func TestGetImageUnitPrice_PartialGroupConfig(t *testing.T) {
svc := &BillingService{}
// 只配置 1K 价格
price1K := 0.10
groupConfig := &ImagePriceConfig{
Price1K: &price1K,
}
// 1K 使用分组价格
cost := svc.CalculateImageCost("gemini-3-pro-image", "1K", 1, groupConfig, 1.0)
require.InDelta(t, 0.10, cost.TotalCost, 0.0001)
// 2K 回退默认价格 $0.134
cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, groupConfig, 1.0)
require.InDelta(t, 0.134, cost.TotalCost, 0.0001)
// 4K 回退默认价格 $0.268 (翻倍)
cost = svc.CalculateImageCost("gemini-3-pro-image", "4K", 1, groupConfig, 1.0)
require.InDelta(t, 0.268, cost.TotalCost, 0.0001)
}
// TestGetDefaultImagePrice_FallbackHardcoded 测试 PricingService 无数据时使用硬编码默认值
func TestGetDefaultImagePrice_FallbackHardcoded(t *testing.T) {
svc := &BillingService{} // pricingService 为 nil
// 1K 和 2K 使用相同的默认价格 $0.134
cost := svc.CalculateImageCost("gemini-3-pro-image", "1K", 1, nil, 1.0)
require.InDelta(t, 0.134, cost.TotalCost, 0.0001)
cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, nil, 1.0)
require.InDelta(t, 0.134, cost.TotalCost, 0.0001)
}

View File

@@ -140,6 +140,8 @@ func (s *EmailService) SendEmailWithConfig(config *SMTPConfig, to, subject, body
func (s *EmailService) sendMailTLS(addr string, auth smtp.Auth, from, to string, msg []byte, host string) error {
tlsConfig := &tls.Config{
ServerName: host,
// 强制 TLS 1.2+,避免协议降级导致的弱加密风险。
MinVersion: tls.VersionTLS12,
}
conn, err := tls.Dial("tcp", addr, tlsConfig)
@@ -311,7 +313,11 @@ func (s *EmailService) TestSMTPConnectionWithConfig(config *SMTPConfig) error {
addr := fmt.Sprintf("%s:%d", config.Host, config.Port)
if config.UseTLS {
tlsConfig := &tls.Config{ServerName: config.Host}
tlsConfig := &tls.Config{
ServerName: config.Host,
// 与发送逻辑一致,显式要求 TLS 1.2+。
MinVersion: tls.VersionTLS12,
}
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("tls connection failed: %w", err)

View File

@@ -104,6 +104,10 @@ type ForwardResult struct {
Stream bool
Duration time.Duration
FirstTokenMs *int // 首字时间(流式请求)
// 图片生成计费字段(仅 gemini-3-pro-image 使用)
ImageCount int // 生成的图片数量
ImageSize string // 图片尺寸 "1K", "2K", "4K"
}
// UpstreamFailoverError indicates an upstream error that should trigger account failover.
@@ -2009,25 +2013,40 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
account := input.Account
subscription := input.Subscription
// 计算费用
tokens := UsageTokens{
InputTokens: result.Usage.InputTokens,
OutputTokens: result.Usage.OutputTokens,
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
CacheReadTokens: result.Usage.CacheReadInputTokens,
}
// 获取费率倍数
multiplier := s.cfg.Default.RateMultiplier
if apiKey.GroupID != nil && apiKey.Group != nil {
multiplier = apiKey.Group.RateMultiplier
}
cost, err := s.billingService.CalculateCost(result.Model, tokens, multiplier)
if err != nil {
log.Printf("Calculate cost failed: %v", err)
// 使用默认费用继续
cost = &CostBreakdown{ActualCost: 0}
var cost *CostBreakdown
// 根据请求类型选择计费方式
if result.ImageCount > 0 {
// 图片生成计费
var groupConfig *ImagePriceConfig
if apiKey.Group != nil {
groupConfig = &ImagePriceConfig{
Price1K: apiKey.Group.ImagePrice1K,
Price2K: apiKey.Group.ImagePrice2K,
Price4K: apiKey.Group.ImagePrice4K,
}
}
cost = s.billingService.CalculateImageCost(result.Model, result.ImageSize, result.ImageCount, groupConfig, multiplier)
} else {
// Token 计费
tokens := UsageTokens{
InputTokens: result.Usage.InputTokens,
OutputTokens: result.Usage.OutputTokens,
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
CacheReadTokens: result.Usage.CacheReadInputTokens,
}
var err error
cost, err = s.billingService.CalculateCost(result.Model, tokens, multiplier)
if err != nil {
log.Printf("Calculate cost failed: %v", err)
cost = &CostBreakdown{ActualCost: 0}
}
}
// 判断计费方式:订阅模式 vs 余额模式
@@ -2039,6 +2058,10 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
// 创建使用日志
durationMs := int(result.Duration.Milliseconds())
var imageSize *string
if result.ImageSize != "" {
imageSize = &result.ImageSize
}
usageLog := &UsageLog{
UserID: user.ID,
APIKeyID: apiKey.ID,
@@ -2060,6 +2083,8 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
Stream: result.Stream,
DurationMs: &durationMs,
FirstTokenMs: result.FirstTokenMs,
ImageCount: result.ImageCount,
ImageSize: imageSize,
CreatedAt: time.Now(),
}

View File

@@ -17,6 +17,11 @@ type Group struct {
MonthlyLimitUSD *float64
DefaultValidityDays int
// 图片生成计费配置antigravity 和 gemini 平台使用)
ImagePrice1K *float64
ImagePrice2K *float64
ImagePrice4K *float64
CreatedAt time.Time
UpdatedAt time.Time
@@ -47,3 +52,19 @@ func (g *Group) HasWeeklyLimit() bool {
func (g *Group) HasMonthlyLimit() bool {
return g.MonthlyLimitUSD != nil && *g.MonthlyLimitUSD > 0
}
// GetImagePrice 根据 image_size 返回对应的图片生成价格
// 如果分组未配置价格,返回 nil调用方应使用默认值
func (g *Group) GetImagePrice(imageSize string) *float64 {
switch imageSize {
case "1K":
return g.ImagePrice1K
case "2K":
return g.ImagePrice2K
case "4K":
return g.ImagePrice4K
default:
// 未知尺寸默认按 2K 计费
return g.ImagePrice2K
}
}

View File

@@ -0,0 +1,92 @@
//go:build unit
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
// TestGroup_GetImagePrice_1K 测试 1K 尺寸返回正确价格
func TestGroup_GetImagePrice_1K(t *testing.T) {
price := 0.10
group := &Group{
ImagePrice1K: &price,
}
result := group.GetImagePrice("1K")
require.NotNil(t, result)
require.InDelta(t, 0.10, *result, 0.0001)
}
// TestGroup_GetImagePrice_2K 测试 2K 尺寸返回正确价格
func TestGroup_GetImagePrice_2K(t *testing.T) {
price := 0.15
group := &Group{
ImagePrice2K: &price,
}
result := group.GetImagePrice("2K")
require.NotNil(t, result)
require.InDelta(t, 0.15, *result, 0.0001)
}
// TestGroup_GetImagePrice_4K 测试 4K 尺寸返回正确价格
func TestGroup_GetImagePrice_4K(t *testing.T) {
price := 0.30
group := &Group{
ImagePrice4K: &price,
}
result := group.GetImagePrice("4K")
require.NotNil(t, result)
require.InDelta(t, 0.30, *result, 0.0001)
}
// TestGroup_GetImagePrice_UnknownSize 测试未知尺寸回退 2K
func TestGroup_GetImagePrice_UnknownSize(t *testing.T) {
price2K := 0.15
group := &Group{
ImagePrice2K: &price2K,
}
// 未知尺寸 "3K" 应该回退到 2K
result := group.GetImagePrice("3K")
require.NotNil(t, result)
require.InDelta(t, 0.15, *result, 0.0001)
// 空字符串也回退到 2K
result = group.GetImagePrice("")
require.NotNil(t, result)
require.InDelta(t, 0.15, *result, 0.0001)
}
// TestGroup_GetImagePrice_NilValues 测试未配置时返回 nil
func TestGroup_GetImagePrice_NilValues(t *testing.T) {
group := &Group{
// 所有 ImagePrice 字段都是 nil
}
require.Nil(t, group.GetImagePrice("1K"))
require.Nil(t, group.GetImagePrice("2K"))
require.Nil(t, group.GetImagePrice("4K"))
require.Nil(t, group.GetImagePrice("unknown"))
}
// TestGroup_GetImagePrice_PartialConfig 测试部分配置
func TestGroup_GetImagePrice_PartialConfig(t *testing.T) {
price1K := 0.10
group := &Group{
ImagePrice1K: &price1K,
// ImagePrice2K 和 ImagePrice4K 未配置
}
result := group.GetImagePrice("1K")
require.NotNil(t, result)
require.InDelta(t, 0.10, *result, 0.0001)
// 2K 和 4K 返回 nil
require.Nil(t, group.GetImagePrice("2K"))
require.Nil(t, group.GetImagePrice("4K"))
}

View File

@@ -34,6 +34,7 @@ type LiteLLMModelPricing struct {
LiteLLMProvider string `json:"litellm_provider"`
Mode string `json:"mode"`
SupportsPromptCaching bool `json:"supports_prompt_caching"`
OutputCostPerImage float64 `json:"output_cost_per_image"` // 图片生成模型每张图片价格
}
// PricingRemoteClient 远程价格数据获取接口
@@ -51,6 +52,7 @@ type LiteLLMRawEntry struct {
LiteLLMProvider string `json:"litellm_provider"`
Mode string `json:"mode"`
SupportsPromptCaching bool `json:"supports_prompt_caching"`
OutputCostPerImage *float64 `json:"output_cost_per_image"`
}
// PricingService 动态价格服务
@@ -319,6 +321,9 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel
if entry.CacheReadInputTokenCost != nil {
pricing.CacheReadInputTokenCost = *entry.CacheReadInputTokenCost
}
if entry.OutputCostPerImage != nil {
pricing.OutputCostPerImage = *entry.OutputCostPerImage
}
result[modelName] = pricing
}

View File

@@ -39,6 +39,10 @@ type UsageLog struct {
DurationMs *int
FirstTokenMs *int
// 图片生成字段
ImageCount int
ImageSize *string
CreatedAt time.Time
User *User

View File

@@ -319,3 +319,12 @@ func (s *UsageService) GetGlobalStats(ctx context.Context, startTime, endTime ti
}
return stats, nil
}
// GetStatsWithFilters returns usage stats with optional filters.
func (s *UsageService) GetStatsWithFilters(ctx context.Context, filters usagestats.UsageLogFilters) (*usagestats.UsageStats, error) {
stats, err := s.usageRepo.GetStatsWithFilters(ctx, filters)
if err != nil {
return nil, fmt.Errorf("get usage stats with filters: %w", err)
}
return stats, nil
}

View File

@@ -9,7 +9,6 @@ import (
"log"
"os"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/repository"
@@ -22,10 +21,44 @@ import (
// Config paths
const (
ConfigFile = "config.yaml"
EnvFile = ".env"
ConfigFileName = "config.yaml"
InstallLockFile = ".installed"
)
// GetDataDir returns the data directory for storing config and lock files.
// Priority: DATA_DIR env > /app/data (if exists and writable) > current directory
func GetDataDir() string {
// Check DATA_DIR environment variable first
if dir := os.Getenv("DATA_DIR"); dir != "" {
return dir
}
// Check if /app/data exists and is writable (Docker environment)
dockerDataDir := "/app/data"
if info, err := os.Stat(dockerDataDir); err == nil && info.IsDir() {
// Try to check if writable by creating a temp file
testFile := dockerDataDir + "/.write_test"
if f, err := os.Create(testFile); err == nil {
_ = f.Close()
_ = os.Remove(testFile)
return dockerDataDir
}
}
// Default to current directory
return "."
}
// GetConfigFilePath returns the full path to config.yaml
func GetConfigFilePath() string {
return GetDataDir() + "/" + ConfigFileName
}
// GetInstallLockPath returns the full path to .installed lock file
func GetInstallLockPath() string {
return GetDataDir() + "/" + InstallLockFile
}
// SetupConfig holds the setup configuration
type SetupConfig struct {
Database DatabaseConfig `json:"database" yaml:"database"`
@@ -72,13 +105,12 @@ type JWTConfig struct {
// Uses multiple checks to prevent attackers from forcing re-setup by deleting config
func NeedsSetup() bool {
// Check 1: Config file must not exist
if _, err := os.Stat(ConfigFile); !os.IsNotExist(err) {
if _, err := os.Stat(GetConfigFilePath()); !os.IsNotExist(err) {
return false // Config exists, no setup needed
}
// Check 2: Installation lock file (harder to bypass)
lockFile := ".installed"
if _, err := os.Stat(lockFile); !os.IsNotExist(err) {
if _, err := os.Stat(GetInstallLockPath()); !os.IsNotExist(err) {
return false // Lock file exists, already installed
}
@@ -197,17 +229,12 @@ func Install(cfg *SetupConfig) error {
// Generate JWT secret if not provided
if cfg.JWT.Secret == "" {
if strings.EqualFold(cfg.Server.Mode, "release") {
return fmt.Errorf("jwt secret is required in release mode")
}
secret, err := generateSecret(32)
if err != nil {
return fmt.Errorf("failed to generate jwt secret: %w", err)
}
cfg.JWT.Secret = secret
log.Println("Warning: JWT secret auto-generated for non-release mode. Do not use in production.")
} else if strings.EqualFold(cfg.Server.Mode, "release") && len(cfg.JWT.Secret) < 32 {
return fmt.Errorf("jwt secret must be at least 32 characters in release mode")
log.Println("Warning: JWT secret auto-generated. Consider setting a fixed secret for production.")
}
// Test connections
@@ -244,9 +271,8 @@ func Install(cfg *SetupConfig) error {
// createInstallLock creates a lock file to prevent re-installation attacks
func createInstallLock() error {
lockFile := ".installed"
content := fmt.Sprintf("installed_at=%s\n", time.Now().UTC().Format(time.RFC3339))
return os.WriteFile(lockFile, []byte(content), 0400) // Read-only for owner
return os.WriteFile(GetInstallLockPath(), []byte(content), 0400) // Read-only for owner
}
func initializeDatabase(cfg *SetupConfig) error {
@@ -397,7 +423,7 @@ func writeConfigFile(cfg *SetupConfig) error {
return err
}
return os.WriteFile(ConfigFile, data, 0600)
return os.WriteFile(GetConfigFilePath(), data, 0600)
}
func generateSecret(length int) (string, error) {
@@ -440,6 +466,7 @@ func getEnvIntOrDefault(key string, defaultValue int) int {
// This is designed for Docker deployment where all config is passed via env vars
func AutoSetupFromEnv() error {
log.Println("Auto setup enabled, configuring from environment variables...")
log.Printf("Data directory: %s", GetDataDir())
// Get timezone from TZ or TIMEZONE env var (TZ is standard for Docker)
tz := getEnvOrDefault("TZ", "")
@@ -481,17 +508,12 @@ func AutoSetupFromEnv() error {
// Generate JWT secret if not provided
if cfg.JWT.Secret == "" {
if strings.EqualFold(cfg.Server.Mode, "release") {
return fmt.Errorf("jwt secret is required in release mode")
}
secret, err := generateSecret(32)
if err != nil {
return fmt.Errorf("failed to generate jwt secret: %w", err)
}
cfg.JWT.Secret = secret
log.Println("Warning: JWT secret auto-generated for non-release mode. Do not use in production.")
} else if strings.EqualFold(cfg.Server.Mode, "release") && len(cfg.JWT.Secret) < 32 {
return fmt.Errorf("jwt secret must be at least 32 characters in release mode")
log.Println("Warning: JWT secret auto-generated. Consider setting a fixed secret for production.")
}
// Generate admin password if not provided

View File

@@ -0,0 +1,10 @@
-- 为 Antigravity 分组添加图片生成计费配置
-- 支持 gemini-3-pro-image 模型的 1K/2K/4K 分辨率按次计费
ALTER TABLE groups ADD COLUMN IF NOT EXISTS image_price_1k DECIMAL(20,8);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS image_price_2k DECIMAL(20,8);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS image_price_4k DECIMAL(20,8);
COMMENT ON COLUMN groups.image_price_1k IS '1K 分辨率图片生成单价 (USD),仅 antigravity 平台使用';
COMMENT ON COLUMN groups.image_price_2k IS '2K 分辨率图片生成单价 (USD),仅 antigravity 平台使用';
COMMENT ON COLUMN groups.image_price_4k IS '4K 分辨率图片生成单价 (USD),仅 antigravity 平台使用';

View File

@@ -0,0 +1,5 @@
-- 为使用日志添加图片生成统计字段
-- 用于记录 gemini-3-pro-image 等图片生成模型的使用情况
ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS image_count INT DEFAULT 0;
ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS image_size VARCHAR(10);

390
config.yaml Normal file
View File

@@ -0,0 +1,390 @@
# Sub2API Configuration File
# Sub2API 配置文件
#
# Copy this file to /etc/sub2api/config.yaml and modify as needed
# 复制此文件到 /etc/sub2api/config.yaml 并根据需要修改
#
# Documentation / 文档: https://github.com/Wei-Shaw/sub2api
# =============================================================================
# Server Configuration
# 服务器配置
# =============================================================================
server:
# Bind address (0.0.0.0 for all interfaces)
# 绑定地址0.0.0.0 表示监听所有网络接口)
host: "0.0.0.0"
# Port to listen on
# 监听端口
port: 8080
# Mode: "debug" for development, "release" for production
# 运行模式:"debug" 用于开发,"release" 用于生产环境
mode: "release"
# Trusted proxies for X-Forwarded-For parsing (CIDR/IP). Empty disables trusted proxies.
# 信任的代理地址CIDR/IP 格式),用于解析 X-Forwarded-For 头。留空则禁用代理信任。
trusted_proxies: []
# =============================================================================
# Run Mode Configuration
# 运行模式配置
# =============================================================================
# Run mode: "standard" (default) or "simple" (for internal use)
# 运行模式:"standard"(默认)或 "simple"(内部使用)
# - standard: Full SaaS features with billing/balance checks
# - standard: 完整 SaaS 功能,包含计费和余额校验
# - simple: Hides SaaS features and skips billing/balance checks
# - simple: 隐藏 SaaS 功能,跳过计费和余额校验
run_mode: "standard"
# =============================================================================
# CORS Configuration
# 跨域资源共享 (CORS) 配置
# =============================================================================
cors:
# Allowed origins list. Leave empty to disable cross-origin requests.
# 允许的来源列表。留空则禁用跨域请求。
allowed_origins: []
# Allow credentials (cookies/authorization headers). Cannot be used with "*".
# 允许携带凭证cookies/授权头)。不能与 "*" 通配符同时使用。
allow_credentials: true
# =============================================================================
# Security Configuration
# 安全配置
# =============================================================================
security:
url_allowlist:
# Enable URL allowlist validation (disable to skip all URL checks)
# 启用 URL 白名单验证(禁用则跳过所有 URL 检查)
enabled: false
# Allowed upstream hosts for API proxying
# 允许代理的上游 API 主机列表
upstream_hosts:
- "api.openai.com"
- "api.anthropic.com"
- "api.kimi.com"
- "open.bigmodel.cn"
- "api.minimaxi.com"
- "generativelanguage.googleapis.com"
- "cloudcode-pa.googleapis.com"
- "*.openai.azure.com"
# Allowed hosts for pricing data download
# 允许下载定价数据的主机列表
pricing_hosts:
- "raw.githubusercontent.com"
# Allowed hosts for CRS sync (required when using CRS sync)
# 允许 CRS 同步的主机列表(使用 CRS 同步功能时必须配置)
crs_hosts: []
# Allow localhost/private IPs for upstream/pricing/CRS (use only in trusted networks)
# 允许本地/私有 IP 地址用于上游/定价/CRS仅在可信网络中使用
allow_private_hosts: true
# Allow http:// URLs when allowlist is disabled (default: false, require https)
# 白名单禁用时是否允许 http:// URL默认: false要求 https
allow_insecure_http: true
response_headers:
# Enable configurable response header filtering (disable to use default allowlist)
# 启用可配置的响应头过滤(禁用则使用默认白名单)
enabled: false
# Extra allowed response headers from upstream
# 额外允许的上游响应头
additional_allowed: []
# Force-remove response headers from upstream
# 强制移除的上游响应头
force_remove: []
csp:
# Enable Content-Security-Policy header
# 启用内容安全策略 (CSP) 响应头
enabled: true
# Default CSP policy (override if you host assets on other domains)
# 默认 CSP 策略(如果静态资源托管在其他域名,请自行覆盖)
policy: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
proxy_probe:
# Allow skipping TLS verification for proxy probe (debug only)
# 允许代理探测时跳过 TLS 证书验证(仅用于调试)
insecure_skip_verify: false
# =============================================================================
# Gateway Configuration
# 网关配置
# =============================================================================
gateway:
# Timeout for waiting upstream response headers (seconds)
# 等待上游响应头超时时间(秒)
response_header_timeout: 600
# Max request body size in bytes (default: 100MB)
# 请求体最大字节数(默认 100MB
max_body_size: 104857600
# Connection pool isolation strategy:
# 连接池隔离策略:
# - proxy: Isolate by proxy, same proxy shares connection pool (suitable for few proxies, many accounts)
# - proxy: 按代理隔离,同一代理共享连接池(适合代理少、账户多)
# - account: Isolate by account, same account shares connection pool (suitable for few accounts, strict isolation)
# - account: 按账户隔离,同一账户共享连接池(适合账户少、需严格隔离)
# - account_proxy: Isolate by account+proxy combination (default, finest granularity)
# - account_proxy: 按账户+代理组合隔离(默认,最细粒度)
connection_pool_isolation: "account_proxy"
# HTTP upstream connection pool settings (HTTP/2 + multi-proxy scenario defaults)
# HTTP 上游连接池配置HTTP/2 + 多代理场景默认值)
# Max idle connections across all hosts
# 所有主机的最大空闲连接数
max_idle_conns: 240
# Max idle connections per host
# 每个主机的最大空闲连接数
max_idle_conns_per_host: 120
# Max connections per host
# 每个主机的最大连接数
max_conns_per_host: 240
# Idle connection timeout (seconds)
# 空闲连接超时时间(秒)
idle_conn_timeout_seconds: 90
# Upstream client cache settings
# 上游连接池客户端缓存配置
# max_upstream_clients: Max cached clients, evicts least recently used when exceeded
# max_upstream_clients: 最大缓存客户端数量,超出后淘汰最久未使用的
max_upstream_clients: 5000
# client_idle_ttl_seconds: Client idle reclaim threshold (seconds), reclaimed when idle and no active requests
# client_idle_ttl_seconds: 客户端空闲回收阈值(秒),超时且无活跃请求时回收
client_idle_ttl_seconds: 900
# Concurrency slot expiration time (minutes)
# 并发槽位过期时间(分钟)
concurrency_slot_ttl_minutes: 30
# Stream data interval timeout (seconds), 0=disable
# 流数据间隔超时0=禁用
stream_data_interval_timeout: 180
# 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
# Log upstream error response body summary (safe/truncated; does not log request content)
# 记录上游错误响应体摘要(安全/截断;不记录请求内容)
log_upstream_error_body: false
# Max bytes to log from upstream error body
# 记录上游错误响应体的最大字节数
log_upstream_error_body_max_bytes: 2048
# Auto inject anthropic-beta header for API-key accounts when needed (default: off)
# 需要时自动为 API-key 账户注入 anthropic-beta 头(默认:关闭)
inject_beta_for_apikey: false
# Allow failover on selected 400 errors (default: off)
# 允许在特定 400 错误时进行故障转移(默认:关闭)
failover_on_400: false
# =============================================================================
# Concurrency Wait Configuration
# 并发等待配置
# =============================================================================
concurrency:
# SSE ping interval during concurrency wait (seconds)
# 并发等待期间的 SSE ping 间隔(秒)
ping_interval: 10
# =============================================================================
# Database Configuration (PostgreSQL)
# 数据库配置 (PostgreSQL)
# =============================================================================
database:
# Database host address
# 数据库主机地址
host: "localhost"
# Database port
# 数据库端口
port: 5432
# Database username
# 数据库用户名
user: "postgres"
# Database password
# 数据库密码
password: "your_secure_password_here"
# Database name
# 数据库名称
dbname: "sub2api"
# SSL mode: disable, require, verify-ca, verify-full
# SSL 模式disable禁用, require要求, verify-ca验证CA, verify-full完全验证
sslmode: "disable"
# =============================================================================
# Redis Configuration
# Redis 配置
# =============================================================================
redis:
# Redis host address
# Redis 主机地址
host: "localhost"
# Redis port
# Redis 端口
port: 6379
# Redis password (leave empty if no password is set)
# Redis 密码(如果未设置密码则留空)
password: ""
# Database number (0-15)
# 数据库编号0-15
db: 0
# =============================================================================
# JWT Configuration
# JWT 配置
# =============================================================================
jwt:
# IMPORTANT: Change this to a random string in production!
# 重要:生产环境中请更改为随机字符串!
# Generate with / 生成命令: openssl rand -hex 32
secret: "change-this-to-a-secure-random-string"
# Token expiration time in hours (max 24)
# 令牌过期时间(小时,最大 24
expire_hour: 24
# =============================================================================
# Default Settings
# 默认设置
# =============================================================================
default:
# Initial admin account (created on first run)
# 初始管理员账户(首次运行时创建)
admin_email: "admin@example.com"
admin_password: "admin123"
# Default settings for new users
# 新用户默认设置
# Max concurrent requests per user
# 每用户最大并发请求数
user_concurrency: 5
# Initial balance for new users
# 新用户初始余额
user_balance: 0
# API key settings
# API 密钥设置
# Prefix for generated API keys
# 生成的 API 密钥前缀
api_key_prefix: "sk-"
# Rate multiplier (affects billing calculation)
# 费率倍数(影响计费计算)
rate_multiplier: 1.0
# =============================================================================
# Rate Limiting
# 速率限制
# =============================================================================
rate_limit:
# Cooldown time (in minutes) when upstream returns 529 (overloaded)
# 上游返回 529过载时的冷却时间分钟
overload_cooldown_minutes: 10
# =============================================================================
# Pricing Data Source (Optional)
# 定价数据源(可选)
# =============================================================================
pricing:
# URL to fetch model pricing data (default: LiteLLM)
# 获取模型定价数据的 URL默认LiteLLM
remote_url: "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"
# Hash verification URL (optional)
# 哈希校验 URL可选
hash_url: ""
# Local data directory for caching
# 本地数据缓存目录
data_dir: "./data"
# Fallback pricing file
# 备用定价文件
fallback_file: "./resources/model-pricing/model_prices_and_context_window.json"
# Update interval in hours
# 更新间隔(小时)
update_interval_hours: 24
# Hash check interval in minutes
# 哈希检查间隔(分钟)
hash_check_interval_minutes: 10
# =============================================================================
# Billing Configuration
# 计费配置
# =============================================================================
billing:
circuit_breaker:
# Enable circuit breaker for billing service
# 启用计费服务熔断器
enabled: true
# Number of failures before opening circuit
# 触发熔断的失败次数阈值
failure_threshold: 5
# Time to wait before attempting reset (seconds)
# 熔断后重试等待时间(秒)
reset_timeout_seconds: 30
# Number of requests to allow in half-open state
# 半开状态允许通过的请求数
half_open_requests: 3
# =============================================================================
# Turnstile Configuration
# Turnstile 人机验证配置
# =============================================================================
turnstile:
# Require Turnstile in release mode (when enabled, login/register will fail if not configured)
# 在 release 模式下要求 Turnstile 验证(启用后,若未配置则登录/注册会失败)
required: false
# =============================================================================
# Gemini OAuth (Required for Gemini accounts)
# Gemini OAuth 配置Gemini 账户必需)
# =============================================================================
# Sub2API supports TWO Gemini OAuth modes:
# Sub2API 支持两种 Gemini OAuth 模式:
#
# 1. Code Assist OAuth (requires GCP project_id)
# 1. Code Assist OAuth需要 GCP project_id
# - Uses: cloudcode-pa.googleapis.com (Code Assist API)
# - 使用cloudcode-pa.googleapis.comCode Assist API
#
# 2. AI Studio OAuth (no project_id needed)
# 2. AI Studio OAuth不需要 project_id
# - Uses: generativelanguage.googleapis.com (AI Studio API)
# - 使用generativelanguage.googleapis.comAI Studio API
#
# Default: Uses Gemini CLI's public OAuth credentials (same as Google's official CLI tool)
# 默认:使用 Gemini CLI 的公开 OAuth 凭证(与 Google 官方 CLI 工具相同)
gemini:
oauth:
# Gemini CLI public OAuth credentials (works for both Code Assist and AI Studio)
# Gemini CLI 公开 OAuth 凭证(适用于 Code Assist 和 AI Studio
client_id: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
client_secret: "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"
# Optional scopes (space-separated). Leave empty to auto-select based on oauth_type.
# 可选的权限范围(空格分隔)。留空则根据 oauth_type 自动选择。
scopes: ""
quota:
# Optional: local quota simulation for Gemini Code Assist (local billing).
# 可选Gemini Code Assist 本地配额模拟(本地计费)。
# These values are used for UI progress + precheck scheduling, not official Google quotas.
# 这些值用于 UI 进度显示和预检调度,并非 Google 官方配额。
tiers:
LEGACY:
# Pro model requests per day
# Pro 模型每日请求数
pro_rpd: 50
# Flash model requests per day
# Flash 模型每日请求数
flash_rpd: 1500
# Cooldown time (minutes) after hitting quota
# 达到配额后的冷却时间(分钟)
cooldown_minutes: 30
PRO:
# Pro model requests per day
# Pro 模型每日请求数
pro_rpd: 1500
# Flash model requests per day
# Flash 模型每日请求数
flash_rpd: 4000
# Cooldown time (minutes) after hitting quota
# 达到配额后的冷却时间(分钟)
cooldown_minutes: 5
ULTRA:
# Pro model requests per day
# Pro 模型每日请求数
pro_rpd: 2000
# Flash model requests per day (0 = unlimited)
# Flash 模型每日请求数0 = 无限制)
flash_rpd: 0
# Cooldown time (minutes) after hitting quota
# 达到配额后的冷却时间(分钟)
cooldown_minutes: 5

View File

@@ -54,7 +54,10 @@ ADMIN_PASSWORD=
# -----------------------------------------------------------------------------
# JWT Configuration
# -----------------------------------------------------------------------------
# Leave empty to auto-generate (recommended)
# IMPORTANT: Set a fixed JWT_SECRET to prevent login sessions from being
# invalidated after container restarts. If left empty, a random secret will
# be generated on each startup, causing all users to be logged out.
# Generate a secure secret: openssl rand -hex 32
JWT_SECRET=
JWT_EXPIRE_HOUR=24
@@ -66,6 +69,24 @@ JWT_EXPIRE_HOUR=24
# Leave unset to use default ./config.yaml
#CONFIG_FILE=./config.yaml
# -----------------------------------------------------------------------------
# Security Configuration
# -----------------------------------------------------------------------------
# URL Allowlist Configuration
# 启用 URL 白名单验证false 则跳过白名单检查,仅做基本格式校验)
SECURITY_URL_ALLOWLIST_ENABLED=false
# 关闭白名单时,是否允许 http:// URL默认 false只允许 https://
# ⚠️ 警告:允许 HTTP 存在安全风险(明文传输),仅建议在开发/测试环境或可信内网中使用
# Allow insecure HTTP URLs when allowlist is disabled (default: false, requires https)
# ⚠️ WARNING: Allowing HTTP has security risks (plaintext transmission)
# Only recommended for dev/test environments or trusted networks
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true
# 是否允许本地/私有 IP 地址用于上游/定价/CRS仅在可信网络中使用
# Allow localhost/private IPs for upstream/pricing/CRS (use only in trusted networks)
SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=true
# -----------------------------------------------------------------------------
# Gemini OAuth (OPTIONAL, required only for Gemini OAuth accounts)
# -----------------------------------------------------------------------------

View File

@@ -97,7 +97,7 @@ security:
enabled: true
# Default CSP policy (override if you host assets on other domains)
# 默认 CSP 策略(如果静态资源托管在其他域名,请自行覆盖)
policy: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
policy: "default-src 'self'; script-src 'self' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
proxy_probe:
# Allow skipping TLS verification for proxy probe (debug only)
# 允许代理探测时跳过 TLS 证书验证(仅用于调试)

View File

@@ -72,7 +72,10 @@ services:
# =======================================================================
# JWT Configuration
# =======================================================================
# Leave empty to auto-generate (recommended)
# IMPORTANT: Set a fixed JWT_SECRET to prevent login sessions from being
# invalidated after container restarts. If left empty, a random secret
# will be generated on each startup.
# Generate a secure secret: openssl rand -hex 32
- JWT_SECRET=${JWT_SECRET:-}
- JWT_EXPIRE_HOUR=${JWT_EXPIRE_HOUR:-24}
@@ -98,9 +101,14 @@ services:
# =======================================================================
# Security Configuration (URL Allowlist)
# =======================================================================
- SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS=${SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS:-}
# Allow private IP addresses for CRS sync (for internal deployments)
# Enable URL allowlist validation (false to skip allowlist checks)
- SECURITY_URL_ALLOWLIST_ENABLED=${SECURITY_URL_ALLOWLIST_ENABLED:-false}
# Allow insecure HTTP URLs when allowlist is disabled (default: false, requires https)
- SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=${SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP:-false}
# Allow private IP addresses for upstream/pricing/CRS (for internal deployments)
- SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=${SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS:-false}
# Upstream hosts whitelist (comma-separated, only used when enabled=true)
- SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS=${SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS:-}
depends_on:
postgres:
condition: service_healthy

118
frontend/audit.json Normal file
View File

@@ -0,0 +1,118 @@
{
"actions": [
{
"action": "review",
"module": "xlsx",
"resolves": [
{
"id": 1108110,
"path": ".>xlsx",
"dev": false,
"bundled": false,
"optional": false
},
{
"id": 1108111,
"path": ".>xlsx",
"dev": false,
"bundled": false,
"optional": false
}
]
}
],
"advisories": {
"1108110": {
"findings": [
{
"version": "0.18.5",
"paths": [
".>xlsx"
]
}
],
"found_by": null,
"deleted": null,
"references": "- https://nvd.nist.gov/vuln/detail/CVE-2023-30533\n- https://cdn.sheetjs.com/advisories/CVE-2023-30533\n- https://git.sheetjs.com/sheetjs/sheetjs/src/branch/master/CHANGELOG.md\n- https://git.sheetjs.com/sheetjs/sheetjs/issues/2667\n- https://git.sheetjs.com/sheetjs/sheetjs/issues/2986\n- https://cdn.sheetjs.com\n- https://github.com/advisories/GHSA-4r6h-8v6p-xvw6",
"created": "2023-04-24T09:30:19.000Z",
"id": 1108110,
"npm_advisory_id": null,
"overview": "All versions of SheetJS CE through 0.19.2 are vulnerable to \"Prototype Pollution\" when reading specially crafted files. Workflows that do not read arbitrary files (for example, exporting data to spreadsheet files) are unaffected.\n\nA non-vulnerable version cannot be found via npm, as the repository hosted on GitHub and the npm package `xlsx` are no longer maintained. Version 0.19.3 can be downloaded via https://cdn.sheetjs.com/.",
"reported_by": null,
"title": "Prototype Pollution in sheetJS",
"metadata": null,
"cves": [
"CVE-2023-30533"
],
"access": "public",
"severity": "high",
"module_name": "xlsx",
"vulnerable_versions": "<0.19.3",
"github_advisory_id": "GHSA-4r6h-8v6p-xvw6",
"recommendation": "None",
"patched_versions": "<0.0.0",
"updated": "2025-09-19T15:23:41.000Z",
"cvss": {
"score": 7.8,
"vectorString": "CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H"
},
"cwe": [
"CWE-1321"
],
"url": "https://github.com/advisories/GHSA-4r6h-8v6p-xvw6"
},
"1108111": {
"findings": [
{
"version": "0.18.5",
"paths": [
".>xlsx"
]
}
],
"found_by": null,
"deleted": null,
"references": "- https://nvd.nist.gov/vuln/detail/CVE-2024-22363\n- https://cdn.sheetjs.com/advisories/CVE-2024-22363\n- https://cwe.mitre.org/data/definitions/1333.html\n- https://git.sheetjs.com/sheetjs/sheetjs/src/tag/v0.20.2\n- https://cdn.sheetjs.com\n- https://github.com/advisories/GHSA-5pgg-2g8v-p4x9",
"created": "2024-04-05T06:30:46.000Z",
"id": 1108111,
"npm_advisory_id": null,
"overview": "SheetJS Community Edition before 0.20.2 is vulnerable.to Regular Expression Denial of Service (ReDoS).\n\nA non-vulnerable version cannot be found via npm, as the repository hosted on GitHub and the npm package `xlsx` are no longer maintained. Version 0.20.2 can be downloaded via https://cdn.sheetjs.com/.",
"reported_by": null,
"title": "SheetJS Regular Expression Denial of Service (ReDoS)",
"metadata": null,
"cves": [
"CVE-2024-22363"
],
"access": "public",
"severity": "high",
"module_name": "xlsx",
"vulnerable_versions": "<0.20.2",
"github_advisory_id": "GHSA-5pgg-2g8v-p4x9",
"recommendation": "None",
"patched_versions": "<0.0.0",
"updated": "2025-09-19T15:23:26.000Z",
"cvss": {
"score": 7.5,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
},
"cwe": [
"CWE-1333"
],
"url": "https://github.com/advisories/GHSA-5pgg-2g8v-p4x9"
}
},
"muted": [],
"metadata": {
"vulnerabilities": {
"info": 0,
"low": 0,
"moderate": 0,
"high": 2,
"critical": 0
},
"dependencies": 639,
"devDependencies": 0,
"optionalDependencies": 0,
"totalDependencies": 639
}
}

View File

@@ -54,15 +54,21 @@ export async function list(
/**
* Get usage statistics with optional filters (admin only)
* @param params - Query parameters (user_id, api_key_id, period/date range)
* @param params - Query parameters for filtering
* @returns Usage statistics
*/
export async function getStats(params: {
user_id?: number
api_key_id?: number
account_id?: number
group_id?: number
model?: string
stream?: boolean
billing_type?: number
period?: string
start_date?: string
end_date?: string
timezone?: string
}): Promise<AdminUsageStatsResponse> {
const { data } = await apiClient.get<AdminUsageStatsResponse>('/admin/usage/stats', {
params

View File

@@ -21,6 +21,15 @@ export const apiClient: AxiosInstance = axios.create({
// ==================== Request Interceptor ====================
// Get user's timezone
const getUserTimezone = (): string => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone
} catch {
return 'UTC'
}
}
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// Attach token from localStorage
@@ -34,6 +43,14 @@ apiClient.interceptors.request.use(
config.headers['Accept-Language'] = getLocale()
}
// Attach timezone for all GET requests (backend may use it for default date ranges)
if (config.method === 'get') {
if (!config.params) {
config.params = {}
}
config.params.timezone = getUserTimezone()
}
return config
},
(error) => {

View File

@@ -831,7 +831,8 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
let credentialsChanged = false
if (enableProxy.value) {
updates.proxy_id = proxyId.value
// 后端期望 proxy_id: 0 表示清除代理,而不是 null
updates.proxy_id = proxyId.value === null ? 0 : proxyId.value
}
if (enableConcurrency.value) {

View File

@@ -1053,6 +1053,10 @@ const handleSubmit = async () => {
submitting.value = true
try {
const updatePayload: Record<string, unknown> = { ...form }
// 后端期望 proxy_id: 0 表示清除代理,而不是 null
if (updatePayload.proxy_id === null) {
updatePayload.proxy_id = 0
}
// For apikey type, handle credentials update
if (props.account.type === 'apikey') {
@@ -1136,7 +1140,7 @@ const handleSubmit = async () => {
emit('updated')
handleClose()
} catch (error: any) {
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
appStore.showError(error.response?.data?.message || error.response?.data?.detail || t('admin.accounts.failedToUpdate'))
} finally {
submitting.value = false
}

View File

@@ -7,15 +7,18 @@
@update:model-value="$emit('update:searchQuery', $event)"
@search="$emit('change')"
/>
<Select v-model="filters.platform" class="w-40" :options="pOpts" @change="$emit('change')" />
<Select v-model="filters.type" class="w-40" :options="tOpts" @change="$emit('change')" />
<Select v-model="filters.status" class="w-40" :options="sOpts" @change="$emit('change')" />
<Select :model-value="filters.platform" class="w-40" :options="pOpts" @update:model-value="updatePlatform" @change="$emit('change')" />
<Select :model-value="filters.type" class="w-40" :options="tOpts" @update:model-value="updateType" @change="$emit('change')" />
<Select :model-value="filters.status" class="w-40" :options="sOpts" @update:model-value="updateStatus" @change="$emit('change')" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import Select from '@/components/common/Select.vue'; import SearchInput from '@/components/common/SearchInput.vue'
defineProps(['searchQuery', 'filters']); defineEmits(['update:searchQuery', 'change']); const { t } = useI18n()
const props = defineProps(['searchQuery', 'filters']); const emit = defineEmits(['update:searchQuery', 'update:filters', 'change']); const { t } = useI18n()
const updatePlatform = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, platform: value }) }
const updateType = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, type: value }) }
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) }
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }])
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }])
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }])

View File

@@ -50,7 +50,7 @@
class="input pr-8"
:placeholder="t('admin.usage.searchApiKeyPlaceholder')"
@input="debounceApiKeySearch"
@focus="showApiKeyDropdown = true"
@focus="onApiKeyFocus"
/>
<button
v-if="filters.api_key_id"
@@ -62,7 +62,7 @@
</button>
<div
v-if="showApiKeyDropdown && (apiKeyResults.length > 0 || apiKeyKeyword)"
v-if="showApiKeyDropdown && apiKeyResults.length > 0"
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
>
<button
@@ -223,14 +223,10 @@ const debounceUserSearch = () => {
const debounceApiKeySearch = () => {
if (apiKeySearchTimeout) clearTimeout(apiKeySearchTimeout)
apiKeySearchTimeout = setTimeout(async () => {
if (!apiKeyKeyword.value) {
apiKeyResults.value = []
return
}
try {
apiKeyResults.value = await adminAPI.usage.searchApiKeys(
filters.value.user_id,
apiKeyKeyword.value
apiKeyKeyword.value || ''
)
} catch {
apiKeyResults.value = []
@@ -238,11 +234,19 @@ const debounceApiKeySearch = () => {
}, 300)
}
const selectUser = (u: SimpleUser) => {
const selectUser = async (u: SimpleUser) => {
userKeyword.value = u.email
showUserDropdown.value = false
filters.value.user_id = u.id
clearApiKey()
// Auto-load API keys for this user
try {
apiKeyResults.value = await adminAPI.usage.searchApiKeys(u.id, '')
} catch {
apiKeyResults.value = []
}
emitChange()
}
@@ -274,6 +278,14 @@ const onClearApiKey = () => {
emitChange()
}
const onApiKeyFocus = () => {
showApiKeyDropdown.value = true
// Trigger search if no results yet
if (apiKeyResults.value.length === 0) {
debounceApiKeySearch()
}
}
const onDocumentClick = (e: MouseEvent) => {
const target = e.target as Node | null
if (!target) return

View File

@@ -4,17 +4,34 @@
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30 text-blue-600">
<Icon name="document" size="md" />
</div>
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalRequests') }}</p><p class="text-xl font-bold">{{ stats?.total_requests?.toLocaleString() || '0' }}</p></div>
<div>
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalRequests') }}</p>
<p class="text-xl font-bold">{{ stats?.total_requests?.toLocaleString() || '0' }}</p>
<p class="text-xs text-gray-400">{{ t('usage.inSelectedRange') }}</p>
</div>
</div>
<div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30 text-amber-600"><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" /></svg></div>
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalTokens') }}</p><p class="text-xl font-bold">{{ formatTokens(stats?.total_tokens || 0) }}</p></div>
<div>
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalTokens') }}</p>
<p class="text-xl font-bold">{{ formatTokens(stats?.total_tokens || 0) }}</p>
<p class="text-xs text-gray-500">
{{ t('usage.in') }}: {{ formatTokens(stats?.total_input_tokens || 0) }} /
{{ t('usage.out') }}: {{ formatTokens(stats?.total_output_tokens || 0) }}
</p>
</div>
</div>
<div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30 text-green-600">
<Icon name="dollar" size="md" />
</div>
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalCost') }}</p><p class="text-xl font-bold text-green-600">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</p></div>
<div class="min-w-0 flex-1">
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalCost') }}</p>
<p class="text-xl font-bold text-green-600">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</p>
<p class="text-xs text-gray-400">
{{ t('usage.standardCost') }}: <span class="line-through">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
</p>
</div>
</div>
<div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30 text-purple-600">

View File

@@ -35,32 +35,65 @@
</template>
<template #cell-tokens="{ row }">
<div class="space-y-1 text-sm">
<div class="flex items-center gap-2">
<div class="inline-flex items-center gap-1">
<Icon name="arrowDown" size="sm" class="h-3.5 w-3.5 text-emerald-500" />
<span class="font-medium text-gray-900 dark:text-white">{{ row.input_tokens?.toLocaleString() || 0 }}</span>
<!-- 图片生成请求 -->
<div v-if="row.image_count > 0" class="flex items-center gap-1.5">
<svg class="h-4 w-4 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span class="font-medium text-gray-900 dark:text-white">{{ row.image_count }}{{ t('usage.imageUnit') }}</span>
<span class="text-gray-400">({{ row.image_size || '2K' }})</span>
</div>
<!-- Token 请求 -->
<div v-else class="flex items-center gap-1.5">
<div class="space-y-1 text-sm">
<div class="flex items-center gap-2">
<div class="inline-flex items-center gap-1">
<Icon name="arrowDown" size="sm" class="h-3.5 w-3.5 text-emerald-500" />
<span class="font-medium text-gray-900 dark:text-white">{{ row.input_tokens?.toLocaleString() || 0 }}</span>
</div>
<div class="inline-flex items-center gap-1">
<Icon name="arrowUp" size="sm" class="h-3.5 w-3.5 text-violet-500" />
<span class="font-medium text-gray-900 dark:text-white">{{ row.output_tokens?.toLocaleString() || 0 }}</span>
</div>
</div>
<div class="inline-flex items-center gap-1">
<Icon name="arrowUp" size="sm" class="h-3.5 w-3.5 text-violet-500" />
<span class="font-medium text-gray-900 dark:text-white">{{ row.output_tokens?.toLocaleString() || 0 }}</span>
<div v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0" class="flex items-center gap-2">
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
<svg class="h-3.5 w-3.5 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" /></svg>
<span class="font-medium text-sky-600 dark:text-sky-400">{{ formatCacheTokens(row.cache_read_tokens) }}</span>
</div>
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
<svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
<span class="font-medium text-amber-600 dark:text-amber-400">{{ formatCacheTokens(row.cache_creation_tokens) }}</span>
</div>
</div>
</div>
<div v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0" class="flex items-center gap-2">
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
<svg class="h-3.5 w-3.5 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" /></svg>
<span class="font-medium text-sky-600 dark:text-sky-400">{{ formatCacheTokens(row.cache_read_tokens) }}</span>
</div>
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
<svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
<span class="font-medium text-amber-600 dark:text-amber-400">{{ formatCacheTokens(row.cache_creation_tokens) }}</span>
<!-- Token Detail Tooltip -->
<div
class="group relative"
@mouseenter="showTokenTooltip($event, row)"
@mouseleave="hideTokenTooltip"
>
<div class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50">
<Icon name="infoCircle" size="xs" class="text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400" />
</div>
</div>
</div>
</template>
<template #cell-cost="{ row }">
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span>
<div class="flex items-center gap-1.5 text-sm">
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span>
<!-- Cost Detail Tooltip -->
<div
class="group relative"
@mouseenter="showTooltip($event, row)"
@mouseleave="hideTooltip"
>
<div class="flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-100 transition-colors group-hover:bg-blue-100 dark:bg-gray-700 dark:group-hover:bg-blue-900/50">
<Icon name="infoCircle" size="xs" class="text-gray-400 group-hover:text-blue-500 dark:text-gray-500 dark:group-hover:text-blue-400" />
</div>
</div>
</div>
</template>
<template #cell-billing_type="{ row }">
@@ -97,6 +130,77 @@
</DataTable>
</div>
</div>
<!-- Token Tooltip Portal -->
<Teleport to="body">
<div
v-if="tokenTooltipVisible"
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
:style="{
left: tokenTooltipPosition.x + 'px',
top: tokenTooltipPosition.y + 'px'
}"
>
<div class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800">
<div class="space-y-1.5">
<div class="mb-2 border-b border-gray-700 pb-1.5">
<div class="text-xs font-semibold text-gray-300 mb-1">Token {{ t('usage.details') }}</div>
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_creation_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.cache_creation_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.cache_read_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheReadTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.cache_read_tokens.toLocaleString() }}</span>
</div>
</div>
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.totalTokens') }}</span>
<span class="font-semibold text-blue-400">{{ ((tokenTooltipData?.input_tokens || 0) + (tokenTooltipData?.output_tokens || 0) + (tokenTooltipData?.cache_creation_tokens || 0) + (tokenTooltipData?.cache_read_tokens || 0)).toLocaleString() }}</span>
</div>
</div>
<div class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"></div>
</div>
</div>
</Teleport>
<!-- Cost Tooltip Portal -->
<Teleport to="body">
<div
v-if="tooltipVisible"
class="fixed z-[9999] pointer-events-none -translate-y-1/2"
:style="{
left: tooltipPosition.x + 'px',
top: tooltipPosition.y + 'px'
}"
>
<div class="whitespace-nowrap rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-xs text-white shadow-xl dark:border-gray-600 dark:bg-gray-800">
<div class="space-y-1.5">
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.rate') }}</span>
<span class="font-semibold text-blue-400">{{ (tooltipData?.rate_multiplier || 1).toFixed(2) }}x</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.original') }}</span>
<span class="font-medium text-white">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
</div>
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.billed') }}</span>
<span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</span>
</div>
</div>
<div class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"></div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
@@ -107,12 +211,23 @@ import { useAppStore } from '@/stores/app'
import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue'
import type { UsageLog } from '@/types'
defineProps(['data', 'loading'])
const { t } = useI18n()
const appStore = useAppStore()
const copiedRequestId = ref<string | null>(null)
// Tooltip state - cost
const tooltipVisible = ref(false)
const tooltipPosition = ref({ x: 0, y: 0 })
const tooltipData = ref<UsageLog | null>(null)
// Tooltip state - token
const tokenTooltipVisible = ref(false)
const tokenTooltipPosition = ref({ x: 0, y: 0 })
const tokenTooltipData = ref<UsageLog | null>(null)
const cols = computed(() => [
{ key: 'user', label: t('admin.usage.user'), sortable: false },
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
@@ -151,4 +266,34 @@ const copyRequestId = async (requestId: string) => {
appStore.showError(t('common.copyFailed'))
}
}
// Cost tooltip functions
const showTooltip = (event: MouseEvent, row: UsageLog) => {
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
tooltipData.value = row
tooltipPosition.value.x = rect.right + 8
tooltipPosition.value.y = rect.top + rect.height / 2
tooltipVisible.value = true
}
const hideTooltip = () => {
tooltipVisible.value = false
tooltipData.value = null
}
// Token tooltip functions
const showTokenTooltip = (event: MouseEvent, row: UsageLog) => {
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
tokenTooltipData.value = row
tokenTooltipPosition.value.x = rect.right + 8
tokenTooltipPosition.value.y = rect.top + rect.height / 2
tokenTooltipVisible.value = true
}
const hideTokenTooltip = () => {
tokenTooltipVisible.value = false
tokenTooltipData.value = null
}
</script>

View File

@@ -48,12 +48,12 @@ const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const a
const groups = ref<Group[]>([]); const selectedIds = ref<number[]>([]); const loading = ref(false); const submitting = ref(false)
watch(() => props.show, (v) => { if(v && props.user) { selectedIds.value = props.user.allowed_groups || []; load() } })
const load = async () => { loading.value = true; try { const res = await adminAPI.groups.list(1, 1000); groups.value = res.items.filter(g => g.subscription_type === 'standard' && g.status === 'active') } catch {} finally { loading.value = false } }
const load = async () => { loading.value = true; try { const res = await adminAPI.groups.list(1, 1000); groups.value = res.items.filter(g => g.subscription_type === 'standard' && g.status === 'active') } catch (error) { console.error('Failed to load groups:', error) } finally { loading.value = false } }
const handleSave = async () => {
if (!props.user) return; submitting.value = true
try {
await adminAPI.users.update(props.user.id, { allowed_groups: selectedIds.value })
appStore.showSuccess(t('admin.users.allowedGroupsUpdated')); emit('success'); emit('close')
} catch {} finally { submitting.value = false }
} catch (error) { console.error('Failed to update allowed groups:', error) } finally { submitting.value = false }
}
</script>

View File

@@ -42,6 +42,6 @@ const apiKeys = ref<ApiKey[]>([]); const loading = ref(false)
watch(() => props.show, (v) => { if (v && props.user) load() })
const load = async () => {
if (!props.user) return; loading.value = true
try { const res = await adminAPI.users.getUserApiKeys(props.user.id); apiKeys.value = res.items || [] } catch {} finally { loading.value = false }
try { const res = await adminAPI.users.getUserApiKeys(props.user.id); apiKeys.value = res.items || [] } catch (error) { console.error('Failed to load API keys:', error) } finally { loading.value = false }
}
</script>

View File

@@ -51,7 +51,8 @@ const handleBalanceSubmit = async () => {
await adminAPI.users.updateBalance(props.user.id, form.amount, props.operation, form.notes)
appStore.showSuccess(t('common.success')); emit('success'); emit('close')
} catch (e: any) {
console.error('Failed to update balance:', e)
appStore.showError(e.response?.data?.detail || t('common.error'))
} finally { submitting.value = false }
}
</script>
</script>

View File

@@ -153,6 +153,10 @@ const formatBalance = (b: number) =>
const formatNumber = (n: number) => n.toLocaleString()
const formatCost = (c: number) => c.toFixed(4)
const formatTokens = (t: number) => (t >= 1000 ? `${(t / 1000).toFixed(1)}K` : t.toString())
const formatTokens = (t: number) => {
if (t >= 1_000_000) return `${(t / 1_000_000).toFixed(1)}M`
if (t >= 1000) return `${(t / 1000).toFixed(1)}K`
return t.toString()
}
const formatDuration = (ms: number) => ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms.toFixed(0)}ms`
</script>

View File

@@ -421,7 +421,8 @@ export default {
exportExcelFailed: 'Failed to export usage data',
billingType: 'Billing',
balance: 'Balance',
subscription: 'Subscription'
subscription: 'Subscription',
imageUnit: ' images'
},
// Redeem
@@ -849,6 +850,10 @@ export default {
defaultValidityDays: 'Default Validity (Days)',
validityHint: 'Number of days the subscription is valid when assigned to a user',
noLimit: 'No limit'
},
imagePricing: {
title: 'Image Generation Pricing',
description: 'Configure pricing for gemini-3-pro-image model. Leave empty to use default prices.'
}
},
@@ -1661,7 +1666,10 @@ export default {
types: {
balance: 'Balance',
concurrency: 'Concurrency',
subscription: 'Subscription'
subscription: 'Subscription',
// Admin adjustment types (created when admin modifies user balance/concurrency)
admin_balance: 'Balance (Admin)',
admin_concurrency: 'Concurrency (Admin)'
},
selectGroup: 'Select Group',
selectGroupPlaceholder: 'Choose a subscription group',

View File

@@ -418,7 +418,8 @@ export default {
exportExcelFailed: '使用数据导出失败',
billingType: '消费类型',
balance: '余额',
subscription: '订阅'
subscription: '订阅',
imageUnit: '张'
},
// Redeem
@@ -926,6 +927,10 @@ export default {
defaultValidityDays: '默认有效期(天)',
validityHint: '分配给用户时订阅的有效天数',
noLimit: '无限制'
},
imagePricing: {
title: '图片生成计费',
description: '配置 gemini-3-pro-image 模型的图片生成价格,留空则使用默认价格'
}
},
@@ -1736,7 +1741,10 @@ export default {
types: {
balance: '余额',
concurrency: '并发数',
subscription: '订阅'
subscription: '订阅',
// 管理员在用户管理页面调整余额/并发时产生的记录
admin_balance: '余额(管理员)',
admin_concurrency: '并发数(管理员)'
},
// 用于选择器和筛选器的直接键
balance: '余额',

View File

@@ -259,6 +259,10 @@ export interface Group {
daily_limit_usd: number | null
weekly_limit_usd: number | null
monthly_limit_usd: number | null
// 图片生成计费配置(仅 antigravity 平台使用)
image_price_1k: number | null
image_price_2k: number | null
image_price_4k: number | null
account_count?: number
created_at: string
updated_at: string
@@ -561,6 +565,11 @@ export interface UsageLog {
stream: boolean
duration_ms: number
first_token_ms: number | null
// 图片生成字段
image_count: number
image_size: string | null
created_at: string
user?: User

View File

@@ -174,10 +174,12 @@ export function formatCostFixed(amount: number, fractionDigits: number = 4): str
}
/**
* 格式化 token 数量(>=1000 显示为 K保留 1 位小数)
* 格式化 token 数量(>=1M 显示为 M>=1K 显示为 K保留 1 位小数)
* @param tokens token 数量
* @returns 格式化后的字符串,如 "950", "1.2K"
* @returns 格式化后的字符串,如 "950", "1.2K", "3.5M"
*/
export function formatTokensK(tokens: number): string {
return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}K` : tokens.toString()
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
return tokens.toString()
}

View File

@@ -6,6 +6,7 @@
<AccountTableFilters
v-model:searchQuery="params.search"
:filters="params"
@update:filters="(newFilters) => Object.assign(params, newFilters)"
@change="reload"
@update:searchQuery="debouncedReload"
/>
@@ -187,7 +188,7 @@ const handleEdit = (a: Account) => { edAcc.value = a; showEdit.value = true }
const openMenu = (a: Account, e: MouseEvent) => { menu.acc = a; menu.pos = { top: e.clientY, left: e.clientX - 200 }; menu.show = true }
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 {} }
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 handleBulkUpdated = () => { showBulkEdit.value = false; selIds.value = []; reload() }
const closeTestModal = () => { showTest.value = false; testingAcc.value = null }
const closeStatsModal = () => { showStats.value = false; statsAcc.value = null }
@@ -195,14 +196,14 @@ const closeReAuthModal = () => { showReAuth.value = false; reAuthAcc.value = nul
const handleTest = (a: Account) => { testingAcc.value = a; showTest.value = true }
const handleViewStats = (a: Account) => { statsAcc.value = a; showStats.value = true }
const handleReAuth = (a: Account) => { reAuthAcc.value = a; showReAuth.value = true }
const handleRefresh = async (a: Account) => { try { await adminAPI.accounts.refreshCredentials(a.id); load() } catch {} }
const handleResetStatus = async (a: Account) => { try { await adminAPI.accounts.clearError(a.id); appStore.showSuccess(t('common.success')); load() } catch {} }
const handleClearRateLimit = async (a: Account) => { try { await adminAPI.accounts.clearRateLimit(a.id); appStore.showSuccess(t('common.success')); load() } catch {} }
const handleRefresh = async (a: Account) => { try { await adminAPI.accounts.refreshCredentials(a.id); load() } catch (error) { console.error('Failed to refresh credentials:', error) } }
const handleResetStatus = async (a: Account) => { try { await adminAPI.accounts.clearError(a.id); appStore.showSuccess(t('common.success')); load() } catch (error) { console.error('Failed to reset status:', error) } }
const handleClearRateLimit = async (a: Account) => { try { await adminAPI.accounts.clearRateLimit(a.id); appStore.showSuccess(t('common.success')); load() } catch (error) { console.error('Failed to clear rate limit:', error) } }
const handleDelete = (a: Account) => { deletingAcc.value = a; showDeleteDialog.value = true }
const confirmDelete = async () => { if(!deletingAcc.value) return; try { await adminAPI.accounts.delete(deletingAcc.value.id); showDeleteDialog.value = false; deletingAcc.value = null; reload() } catch {} }
const confirmDelete = async () => { if(!deletingAcc.value) return; try { await adminAPI.accounts.delete(deletingAcc.value.id); showDeleteDialog.value = false; deletingAcc.value = null; reload() } catch (error) { console.error('Failed to delete account:', error) } }
const handleToggleSchedulable = async (a: Account) => { togglingSchedulable.value = a.id; try { await adminAPI.accounts.setSchedulable(a.id, !a.schedulable); load() } finally { togglingSchedulable.value = null } }
const handleShowTempUnsched = (a: Account) => { tempUnschedAcc.value = a; showTempUnsched.value = true }
const handleTempUnschedReset = async () => { if(!tempUnschedAcc.value) return; try { await adminAPI.accounts.clearError(tempUnschedAcc.value.id); showTempUnsched.value = false; tempUnschedAcc.value = null; load() } catch {} }
const handleTempUnschedReset = async () => { if(!tempUnschedAcc.value) return; try { await adminAPI.accounts.clearError(tempUnschedAcc.value.id); showTempUnsched.value = false; tempUnschedAcc.value = null; load() } catch (error) { console.error('Failed to reset temp unscheduled:', error) } }
onMounted(async () => { load(); try { const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()]); proxies.value = p; groups.value = g } catch {} })
onMounted(async () => { load(); try { const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()]); proxies.value = p; groups.value = g } catch (error) { console.error('Failed to load proxies/groups:', error) } })
</script>

View File

@@ -358,6 +358,51 @@
</div>
</div>
<!-- 图片生成计费配置antigravity gemini 平台 -->
<div v-if="createForm.platform === 'antigravity' || createForm.platform === 'gemini'" class="border-t pt-4">
<label class="block mb-2 font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.imagePricing.title') }}
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t('admin.groups.imagePricing.description') }}
</p>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="input-label">1K ($)</label>
<input
v-model.number="createForm.image_price_1k"
type="number"
step="0.001"
min="0"
class="input"
placeholder="0.134"
/>
</div>
<div>
<label class="input-label">2K ($)</label>
<input
v-model.number="createForm.image_price_2k"
type="number"
step="0.001"
min="0"
class="input"
placeholder="0.134"
/>
</div>
<div>
<label class="input-label">4K ($)</label>
<input
v-model.number="createForm.image_price_4k"
type="number"
step="0.001"
min="0"
class="input"
placeholder="0.268"
/>
</div>
</div>
</div>
</form>
<template #footer>
@@ -558,6 +603,51 @@
</div>
</div>
<!-- 图片生成计费配置antigravity gemini 平台 -->
<div v-if="editForm.platform === 'antigravity' || editForm.platform === 'gemini'" class="border-t pt-4">
<label class="block mb-2 font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.imagePricing.title') }}
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ t('admin.groups.imagePricing.description') }}
</p>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="input-label">1K ($)</label>
<input
v-model.number="editForm.image_price_1k"
type="number"
step="0.001"
min="0"
class="input"
placeholder="0.134"
/>
</div>
<div>
<label class="input-label">2K ($)</label>
<input
v-model.number="editForm.image_price_2k"
type="number"
step="0.001"
min="0"
class="input"
placeholder="0.134"
/>
</div>
<div>
<label class="input-label">4K ($)</label>
<input
v-model.number="editForm.image_price_4k"
type="number"
step="0.001"
min="0"
class="input"
placeholder="0.268"
/>
</div>
</div>
</div>
</form>
<template #footer>
@@ -727,7 +817,11 @@ const createForm = reactive({
subscription_type: 'standard' as SubscriptionType,
daily_limit_usd: null as number | null,
weekly_limit_usd: null as number | null,
monthly_limit_usd: null as number | null
monthly_limit_usd: null as number | null,
// 图片生成计费配置(仅 antigravity 平台使用)
image_price_1k: null as number | null,
image_price_2k: null as number | null,
image_price_4k: null as number | null
})
const editForm = reactive({
@@ -740,7 +834,11 @@ const editForm = reactive({
subscription_type: 'standard' as SubscriptionType,
daily_limit_usd: null as number | null,
weekly_limit_usd: null as number | null,
monthly_limit_usd: null as number | null
monthly_limit_usd: null as number | null,
// 图片生成计费配置(仅 antigravity 平台使用)
image_price_1k: null as number | null,
image_price_2k: null as number | null,
image_price_4k: null as number | null
})
// 根据分组类型返回不同的删除确认消息
@@ -807,6 +905,9 @@ const closeCreateModal = () => {
createForm.daily_limit_usd = null
createForm.weekly_limit_usd = null
createForm.monthly_limit_usd = null
createForm.image_price_1k = null
createForm.image_price_2k = null
createForm.image_price_4k = null
}
const handleCreateGroup = async () => {
@@ -845,6 +946,9 @@ const handleEdit = (group: Group) => {
editForm.daily_limit_usd = group.daily_limit_usd
editForm.weekly_limit_usd = group.weekly_limit_usd
editForm.monthly_limit_usd = group.monthly_limit_usd
editForm.image_price_1k = group.image_price_1k
editForm.image_price_2k = group.image_price_2k
editForm.image_price_4k = group.image_price_4k
showEditModal.value = true
}

View File

@@ -28,7 +28,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import * as XLSX from 'xlsx'; import { saveAs } from 'file-saver'
import { saveAs } from 'file-saver'
import { useAppStore } from '@/stores/app'; import { adminAPI } from '@/api/admin'; import { adminUsageAPI } from '@/api/admin/usage'
import AppLayout from '@/components/layout/AppLayout.vue'; import Pagination from '@/components/common/Pagination.vue'; import Select from '@/components/common/Select.vue'
import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'; import UsageFilters from '@/components/admin/usage/UsageFilters.vue'
@@ -55,16 +55,16 @@ const loadLogs = async () => {
try {
const res = await adminAPI.usage.list({ page: pagination.page, page_size: pagination.page_size, ...filters.value }, { signal: c.signal })
if(!c.signal.aborted) { usageLogs.value = res.items; pagination.total = res.total }
} catch {} finally { if(abortController === c) loading.value = false }
} catch (error: any) { if(error?.name !== 'AbortError') console.error('Failed to load usage logs:', error) } finally { if(abortController === c) loading.value = false }
}
const loadStats = async () => { try { const s = await adminAPI.usage.getStats(filters.value); usageStats.value = s } catch {} }
const loadStats = async () => { try { const s = await adminAPI.usage.getStats(filters.value); usageStats.value = s } catch (error) { console.error('Failed to load usage stats:', error) } }
const loadChartData = async () => {
chartsLoading.value = true
try {
const params = { start_date: filters.value.start_date || startDate.value, end_date: filters.value.end_date || endDate.value, granularity: granularity.value, user_id: filters.value.user_id }
const [trendRes, modelRes] = await Promise.all([adminAPI.dashboard.getUsageTrend(params), adminAPI.dashboard.getModelStats({ start_date: params.start_date, end_date: params.end_date, user_id: params.user_id })])
trendData.value = trendRes.trend || []; modelStats.value = modelRes.models || []
} catch {} finally { chartsLoading.value = false }
} catch (error) { console.error('Failed to load chart data:', error) } finally { chartsLoading.value = false }
}
const applyFilters = () => { pagination.page = 1; loadLogs(); loadStats(); loadChartData() }
const resetFilters = () => { startDate.value = formatLD(weekAgo); endDate.value = formatLD(now); filters.value = { start_date: startDate.value, end_date: endDate.value }; granularity.value = 'day'; applyFilters() }
@@ -85,14 +85,16 @@ const exportToExcel = async () => {
if (all.length >= total || res.items.length < 100) break; p++
}
if(!c.signal.aborted) {
// 动态加载 xlsx降低首屏包体并减少高危依赖的常驻暴露面。
const XLSX = await import('xlsx')
const ws = XLSX.utils.json_to_sheet(all); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Usage')
saveAs(new Blob([XLSX.write(wb, { bookType: 'xlsx', type: 'array' })], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), `usage_${Date.now()}.xlsx`)
appStore.showSuccess('Export Success')
}
} catch { appStore.showError('Export Failed') }
} catch (error) { console.error('Failed to export:', error); appStore.showError('Export Failed') }
finally { if(exportAbortController === c) { exportAbortController = null; exporting.value = false; exportProgress.show = false } }
}
onMounted(() => { loadLogs(); loadStats(); loadChartData() })
onUnmounted(() => { abortController?.abort(); exportAbortController?.abort() })
</script>
</script>

View File

@@ -28,9 +28,9 @@ const trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]>
const formatLD = (d: Date) => d.toISOString().split('T')[0]
const startDate = ref(formatLD(new Date(Date.now() - 6 * 86400000))); const endDate = ref(formatLD(new Date())); const granularity = ref('day')
const loadStats = async () => { loading.value = true; try { await authStore.refreshUser(); stats.value = await usageAPI.getDashboardStats() } catch {} finally { loading.value = false } }
const loadCharts = async () => { loadingCharts.value = true; try { const res = await Promise.all([usageAPI.getDashboardTrend({ start_date: startDate.value, end_date: endDate.value, granularity: granularity.value as any }), usageAPI.getDashboardModels({ start_date: startDate.value, end_date: endDate.value })]); trendData.value = res[0].trend || []; modelStats.value = res[1].models || [] } catch {} finally { loadingCharts.value = false } }
const loadRecent = async () => { loadingUsage.value = true; try { const res = await usageAPI.getByDateRange(startDate.value, endDate.value); recentUsage.value = res.items.slice(0, 5) } catch {} finally { loadingUsage.value = false } }
const loadStats = async () => { loading.value = true; try { await authStore.refreshUser(); stats.value = await usageAPI.getDashboardStats() } catch (error) { console.error('Failed to load dashboard stats:', error) } finally { loading.value = false } }
const loadCharts = async () => { loadingCharts.value = true; try { const res = await Promise.all([usageAPI.getDashboardTrend({ start_date: startDate.value, end_date: endDate.value, granularity: granularity.value as any }), usageAPI.getDashboardModels({ start_date: startDate.value, end_date: endDate.value })]); trendData.value = res[0].trend || []; modelStats.value = res[1].models || [] } catch (error) { console.error('Failed to load charts:', error) } finally { loadingCharts.value = false } }
const loadRecent = async () => { loadingUsage.value = true; try { const res = await usageAPI.getByDateRange(startDate.value, endDate.value); recentUsage.value = res.items.slice(0, 5) } catch (error) { console.error('Failed to load recent usage:', error) } finally { loadingUsage.value = false } }
onMounted(() => { loadStats(); loadCharts(); loadRecent() })
</script>

View File

@@ -36,6 +36,6 @@ const WalletIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24'
const BoltIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'm3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z' })]) }
const CalendarIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'M6.75 3v2.25M17.25 3v2.25' })]) }
onMounted(async () => { try { const s = await authAPI.getPublicSettings(); contactInfo.value = s.contact_info || '' } catch {} })
onMounted(async () => { try { const s = await authAPI.getPublicSettings(); contactInfo.value = s.contact_info || '' } catch (error) { console.error('Failed to load contact info:', error) } })
const formatCurrency = (v: number) => `$${v.toFixed(2)}`
</script>

View File

@@ -171,7 +171,26 @@
</template>
<template #cell-tokens="{ row }">
<div class="flex items-center gap-1.5">
<!-- 图片生成请求 -->
<div v-if="row.image_count > 0" class="flex items-center gap-1.5">
<svg
class="h-4 w-4 text-indigo-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<span class="font-medium text-gray-900 dark:text-white">{{ row.image_count }}{{ $t('usage.imageUnit') }}</span>
<span class="text-gray-400">({{ row.image_size || '2K' }})</span>
</div>
<!-- Token 请求 -->
<div v-else class="flex items-center gap-1.5">
<div class="space-y-1.5 text-sm">
<!-- Input / Output Tokens -->
<div class="flex items-center gap-2">

View File

@@ -0,0 +1,247 @@
#!/usr/bin/env python3
import argparse
import json
import sys
from datetime import date
HIGH_SEVERITIES = {"high", "critical"}
REQUIRED_FIELDS = {"package", "advisory", "severity", "mitigation", "expires_on"}
def split_kv(line: str) -> tuple[str, str]:
# 解析 "key: value" 形式的简单 YAML 行,并去除引号。
key, value = line.split(":", 1)
value = value.strip()
if (value.startswith('"') and value.endswith('"')) or (
value.startswith("'") and value.endswith("'")
):
value = value[1:-1]
return key.strip(), value
def parse_exceptions(path: str) -> list[dict]:
# 轻量解析异常清单,避免引入额外依赖。
exceptions = []
current = None
with open(path, "r", encoding="utf-8") as handle:
for raw in handle:
line = raw.strip()
if not line or line.startswith("#"):
continue
if line.startswith("version:") or line.startswith("exceptions:"):
continue
if line.startswith("- "):
if current:
exceptions.append(current)
current = {}
line = line[2:].strip()
if line:
key, value = split_kv(line)
current[key] = value
continue
if current is not None and ":" in line:
key, value = split_kv(line)
current[key] = value
if current:
exceptions.append(current)
return exceptions
def pick_advisory_id(advisory: dict) -> str | None:
# 优先使用可稳定匹配的标识GHSA/URL/CVE避免误匹配到其他同名漏洞。
return (
advisory.get("github_advisory_id")
or advisory.get("url")
or (advisory.get("cves") or [None])[0]
or (str(advisory.get("id")) if advisory.get("id") is not None else None)
or advisory.get("title")
or advisory.get("advisory")
or advisory.get("overview")
)
def iter_vulns(data: dict):
# 兼容 pnpm audit 的不同输出结构advisories / vulnerabilities并提取 advisory 标识。
advisories = data.get("advisories")
if isinstance(advisories, dict):
for advisory in advisories.values():
name = advisory.get("module_name") or advisory.get("name")
severity = advisory.get("severity")
advisory_id = pick_advisory_id(advisory)
title = (
advisory.get("title")
or advisory.get("advisory")
or advisory.get("overview")
or advisory.get("url")
)
yield name, severity, advisory_id, title
vulnerabilities = data.get("vulnerabilities")
if isinstance(vulnerabilities, dict):
for name, vuln in vulnerabilities.items():
severity = vuln.get("severity")
via = vuln.get("via", [])
titles = []
advisories = []
if isinstance(via, list):
for item in via:
if isinstance(item, dict):
advisories.append(
item.get("github_advisory_id")
or item.get("url")
or item.get("source")
or item.get("title")
or item.get("name")
)
titles.append(
item.get("title")
or item.get("url")
or item.get("advisory")
or item.get("source")
)
elif isinstance(item, str):
advisories.append(item)
titles.append(item)
elif isinstance(via, str):
advisories.append(via)
titles.append(via)
title = "; ".join([t for t in titles if t])
for advisory_id in [a for a in advisories if a]:
yield name, severity, advisory_id, title
def normalize_severity(severity: str) -> str:
# 统一大小写,避免比较失败。
return (severity or "").strip().lower()
def normalize_package(name: str) -> str:
# 包名只去掉首尾空白,保留原始大小写,同时兼容非字符串输入。
if name is None:
return ""
return str(name).strip()
def normalize_advisory(advisory: str) -> str:
# advisory 统一为小写匹配,避免 GHSA/URL 因大小写差异导致漏匹配。
# pnpm 的 source 字段可能是数字,这里统一转为字符串以保证可比较。
if advisory is None:
return ""
return str(advisory).strip().lower()
def parse_date(value: str) -> date | None:
# 仅接受 ISO8601 日期格式,非法值视为无效。
try:
return date.fromisoformat(value)
except ValueError:
return None
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--audit", required=True)
parser.add_argument("--exceptions", required=True)
args = parser.parse_args()
with open(args.audit, "r", encoding="utf-8") as handle:
audit = json.load(handle)
# 读取异常清单并建立索引,便于快速匹配包名 + advisory。
exceptions = parse_exceptions(args.exceptions)
exception_index = {}
errors = []
for exc in exceptions:
missing = [field for field in REQUIRED_FIELDS if not exc.get(field)]
if missing:
errors.append(
f"Exception missing required fields {missing}: {exc.get('package', '<unknown>')}"
)
continue
exc_severity = normalize_severity(exc.get("severity"))
exc_package = normalize_package(exc.get("package"))
exc_advisory = normalize_advisory(exc.get("advisory"))
exc_date = parse_date(exc.get("expires_on"))
if exc_date is None:
errors.append(
f"Exception has invalid expires_on date: {exc.get('package', '<unknown>')}"
)
continue
if not exc_package or not exc_advisory:
errors.append("Exception missing package or advisory value")
continue
key = (exc_package, exc_advisory)
if key in exception_index:
errors.append(
f"Duplicate exception for {exc_package} advisory {exc.get('advisory')}"
)
continue
exception_index[key] = {
"raw": exc,
"severity": exc_severity,
"expires_on": exc_date,
}
today = date.today()
missing_exceptions = []
expired_exceptions = []
# 去重处理:同一包名 + advisory 可能在不同字段重复出现。
seen = set()
for name, severity, advisory_id, title in iter_vulns(audit):
sev = normalize_severity(severity)
if sev not in HIGH_SEVERITIES or not name:
continue
advisory_key = normalize_advisory(advisory_id)
if not advisory_key:
errors.append(
f"High/Critical vulnerability missing advisory id: {name} ({sev})"
)
continue
key = (normalize_package(name), advisory_key)
if key in seen:
continue
seen.add(key)
exc = exception_index.get(key)
if exc is None:
missing_exceptions.append((name, sev, advisory_id, title))
continue
if exc["severity"] and exc["severity"] != sev:
errors.append(
"Exception severity mismatch: "
f"{name} ({advisory_id}) expected {sev}, got {exc['severity']}"
)
if exc["expires_on"] and exc["expires_on"] < today:
expired_exceptions.append(
(name, sev, advisory_id, exc["expires_on"].isoformat())
)
if missing_exceptions:
errors.append("High/Critical vulnerabilities missing exceptions:")
for name, sev, advisory_id, title in missing_exceptions:
label = f"{name} ({sev})"
if advisory_id:
label = f"{label} [{advisory_id}]"
if title:
label = f"{label}: {title}"
errors.append(f"- {label}")
if expired_exceptions:
errors.append("Exceptions expired:")
for name, sev, advisory_id, expires_on in expired_exceptions:
errors.append(
f"- {name} ({sev}) [{advisory_id}] expired on {expires_on}"
)
if errors:
sys.stderr.write("\n".join(errors) + "\n")
return 1
print("Audit exceptions validated.")
return 0
if __name__ == "__main__":
raise SystemExit(main())