diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index c80c1cb..5c56020 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -3,9 +3,15 @@ name: CD on: push: tags: ['v*'] + workflow_dispatch: + inputs: + version: + description: 'Image tag (e.g. dev, v0.1.0)' + required: true + default: 'dev' env: - REGISTRY: ghcr.io/layfz/maas-cloud-api + REGISTRY: ghcr.io/maas-cloud-api jobs: build-push: @@ -15,12 +21,17 @@ jobs: packages: write strategy: matrix: - service: [auth, core, billing, syncer, adapter] + service: [auth, core] steps: - uses: actions/checkout@v4 - name: Set version - run: echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV + else + echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV + fi - name: Login to GHCR uses: docker/login-action@v3 @@ -51,7 +62,12 @@ jobs: - uses: actions/checkout@v4 - name: Set version - run: echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV + else + echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV + fi - name: Login to GHCR uses: docker/login-action@v3 diff --git a/.gitignore b/.gitignore index 0d6ee0d..7b77128 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,11 @@ docker-compose.dev.yaml # Generated / temporary logo-philosophy.md +syncer +/auth +/billing +/core +web/tsconfig.tsbuildinfo +.agents/status/ +.agents/issues.md +.agents \ No newline at end of file diff --git a/Makefile b/Makefile index 1d07eb9..90fe3c5 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") BUILD_TIME ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") -REGISTRY ?= ghcr.io/layfz/maas-cloud-api +REGISTRY ?= ghcr.io/maas-cloud-api SERVICES := auth core billing syncer adapter LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildTime=$(BUILD_TIME) diff --git a/cmd/billing/main.go b/cmd/billing/main.go index 2b41deb..0edcb71 100644 --- a/cmd/billing/main.go +++ b/cmd/billing/main.go @@ -2,16 +2,22 @@ package main import ( "context" + "fmt" "log" + "net/http" "os" "os/signal" "syscall" "github.com/LayFz/maas-cloud-api/internal/billing/consumer" + "github.com/LayFz/maas-cloud-api/internal/billing/handler" + "github.com/LayFz/maas-cloud-api/internal/billing/server" "github.com/LayFz/maas-cloud-api/internal/pkg/config" "github.com/LayFz/maas-cloud-api/internal/pkg/database" + "github.com/LayFz/maas-cloud-api/internal/pkg/middleware" "github.com/LayFz/maas-cloud-api/internal/pkg/mq" appOtel "github.com/LayFz/maas-cloud-api/internal/pkg/otel" + "github.com/gin-gonic/gin" "go.uber.org/zap" ) @@ -46,7 +52,19 @@ func main() { log.Fatalf("init redis: %v", err) } - // 消费用量事件 + producer := mq.NewProducer(cfg.Kafka.Brokers) + defer producer.Close() + + rdb := database.Redis() + billingSrv := server.NewBillingServer(cfg, producer, rdb, logger) + + // 启动额度持久化协程 + go billingSrv.QuotaPersister.Run(ctx) + + // 启动 ClickHouse 批量写入 + go billingSrv.CHWriter.Run(ctx) + + // 启动 Kafka 消费者 usageConsumer := consumer.NewUsageConsumer(cfg, logger) go func() { logger.Info("billing consumer started", @@ -58,10 +76,37 @@ func main() { } }() + // 启动 HTTP 服务 + r := gin.Default() + r.Use(middleware.CORS()) + handler.RegisterRoutes(r, billingSrv) + handler.RegisterInternalRoutes(r, billingSrv) + + port := cfg.Server.HTTPPort + if port == 0 { + port = 8083 + } + + httpServer := &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: r, + } + go func() { + logger.Info("billing HTTP server started", + zap.Int("port", port), + zap.String("version", version), + ) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Fatal("serve http", zap.Error(err)) + } + }() + quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit logger.Info("shutting down billing service...") cancel() + httpServer.Shutdown(context.Background()) + billingSrv.CHWriter.Close() } diff --git a/cmd/syncer/main.go b/cmd/syncer/main.go index 5d399bf..f43205b 100644 --- a/cmd/syncer/main.go +++ b/cmd/syncer/main.go @@ -44,7 +44,7 @@ func main() { } // Config Syncer: 消费 tenant.events → 同步到 Higress - s := syncer.NewSyncer(cfg, logger) + s := syncer.NewSyncer(cfg, database.RawDB(), logger) go func() { logger.Info("config syncer started", zap.String("topic", mq.TopicTenantEvents), diff --git a/configs/config.yaml b/configs/config.yaml index 926a68e..ede1fbe 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -43,6 +43,13 @@ oauth: client_id: "" client_secret: "" +smtp: + host: "" # SMTP server, e.g. smtp.gmail.com (empty = dev mode, logs code to stdout) + port: 587 + username: "" + password: "" + from: "noreply@anyfast.com" + otel: endpoint: "" # 本地开发可不开 OTel insecure: true diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index eeab03f..e9da53b 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -2,7 +2,7 @@ # Usage: docker build --build-arg SERVICE=auth -t anyfast/auth:v1.0.0 . # ============ Build Stage ============ -FROM golang:1.22-alpine AS builder +FROM golang:1.25-alpine AS builder ARG SERVICE ARG LDFLAGS diff --git a/deploy/k8s/base/adapter/deployment.yaml b/deploy/k8s/base/adapter/deployment.yaml index 3483cc9..5b398a6 100644 --- a/deploy/k8s/base/adapter/deployment.yaml +++ b/deploy/k8s/base/adapter/deployment.yaml @@ -17,7 +17,7 @@ spec: spec: containers: - name: adapter - image: ghcr.io/layfz/maas-cloud-api/adapter:latest + image: ghcr.io/maas-cloud-api/adapter:latest ports: - name: http containerPort: 8080 diff --git a/deploy/k8s/base/auth/deployment.yaml b/deploy/k8s/base/auth/deployment.yaml index 05061ce..2331efc 100644 --- a/deploy/k8s/base/auth/deployment.yaml +++ b/deploy/k8s/base/auth/deployment.yaml @@ -17,7 +17,7 @@ spec: spec: containers: - name: auth - image: ghcr.io/layfz/maas-cloud-api/auth:latest + image: ghcr.io/maas-cloud-api/auth:latest ports: - name: http containerPort: 8080 diff --git a/deploy/k8s/base/billing/deployment.yaml b/deploy/k8s/base/billing/deployment.yaml index 6e15e7c..9a69e79 100644 --- a/deploy/k8s/base/billing/deployment.yaml +++ b/deploy/k8s/base/billing/deployment.yaml @@ -17,7 +17,7 @@ spec: spec: containers: - name: billing - image: ghcr.io/layfz/maas-cloud-api/billing:latest + image: ghcr.io/maas-cloud-api/billing:latest envFrom: - configMapRef: name: anyfast-common diff --git a/deploy/k8s/base/core/deployment.yaml b/deploy/k8s/base/core/deployment.yaml index 44996b0..557ebb2 100644 --- a/deploy/k8s/base/core/deployment.yaml +++ b/deploy/k8s/base/core/deployment.yaml @@ -17,7 +17,7 @@ spec: spec: containers: - name: core - image: ghcr.io/layfz/maas-cloud-api/core:latest + image: ghcr.io/maas-cloud-api/core:latest ports: - name: http containerPort: 8080 diff --git a/deploy/k8s/base/syncer/deployment.yaml b/deploy/k8s/base/syncer/deployment.yaml index 2199af9..d4fbe34 100644 --- a/deploy/k8s/base/syncer/deployment.yaml +++ b/deploy/k8s/base/syncer/deployment.yaml @@ -18,7 +18,7 @@ spec: serviceAccountName: config-syncer # 需要 K8s API 权限写 CRD containers: - name: syncer - image: ghcr.io/layfz/maas-cloud-api/syncer:latest + image: ghcr.io/maas-cloud-api/syncer:latest envFrom: - configMapRef: name: anyfast-common diff --git a/docs/billing-design.md b/docs/billing-design.md new file mode 100644 index 0000000..9d76d88 --- /dev/null +++ b/docs/billing-design.md @@ -0,0 +1,330 @@ +# 计费体系设计 + +## 核心原则 + +**平台自己也是代理商**,只不过是最顶层的。所有租户(平台 + 代理商)共用同一套计费逻辑,区别只是: +- 平台能配渠道(上游 API) +- 代理商不能配渠道,共享平台的渠道 + +## 一、价格层级 + +``` +上游成本(渠道级) + │ 我们实际付给 OpenAI/Claude 的钱 + │ 由渠道的 model_ratio 决定 + ▼ +平台定价(tenant_id=1) + │ 平台卖给自己用户的价格 + │ 也是代理商的"进货价"(代理商不能低于这个价) + ├──→ 平台用户:按平台定价扣费 + ▼ +代理商定价(tenant_id=N) + │ 代理商自己定,必须 >= 平台定价 + └──→ 代理商用户:按代理商定价扣费 + +利润 = 收入(卖给用户的) - 成本(上游的) +代理商利润 = 代理商收入 - 平台定价 +平台利润 = 平台收入 - 上游成本 +``` + +## 二、倍率系统(借鉴 new-api) + +不用固定的 input_price / output_price,改用**倍率系统**,灵活且好算: + +### 2.1 模型倍率(model_ratio) + +每个模型有一个基础倍率,决定了它相对于"1美元"的成本: + +``` +gpt-4o: model_ratio = 2.5 (input) + completion_ratio = 3 (output 是 input 的 3 倍) + +claude-sonnet-4-6: model_ratio = 3.0 + completion_ratio = 5 + +gpt-4o-mini: model_ratio = 0.15 + completion_ratio = 0.6 +``` + +### 2.2 分组倍率(group_ratio) + +每个租户可以设置不同用户组的价格倍率: + +``` +平台: + default 组: group_ratio = 1.0 (标准价) + vip 组: group_ratio = 0.8 (8 折) + wholesale: group_ratio = 0.5 (批发价,给代理商) + +代理商A: + default 组: group_ratio = 1.5 (在平台价基础上加 50%) + vip 组: group_ratio = 1.2 +``` + +### 2.3 计费公式 + +``` +一次请求的费用 = + (prompt_tokens × model_ratio + + completion_tokens × model_ratio × completion_ratio) + × group_ratio + × quota_per_unit // 每单位额度对应的 token 数 + +// 简化版(quota_per_unit = 500000,即 50 万 token = 1 美元额度) +费用(quota) = (prompt + completion × completion_ratio) × model_ratio × group_ratio / 500000 +``` + +## 三、额度系统 + +### 3.1 用户额度 + +```sql +-- 用户表加字段 +ALTER TABLE users ADD COLUMN quota BIGINT DEFAULT 0 COMMENT '可用额度'; +ALTER TABLE users ADD COLUMN used_quota BIGINT DEFAULT 0 COMMENT '已用额度'; +ALTER TABLE users ADD COLUMN user_group VARCHAR(32) DEFAULT 'default' COMMENT '用户组'; +``` + +### 3.2 额度操作(Redis 并发安全) + +``` +充值: quota += amount +扣费: quota -= cost, used_quota += cost +退款: quota += refund, used_quota -= refund + +所有操作先改 Redis(原子),再异步落库 +``` + +### 3.3 预扣费 + 实际结算 + +``` +用户请求 /v1/chat/completions + │ + ▼ +1. 估算 prompt token 数(根据请求内容) +2. 预扣费:quota -= 预估费用 +3. 转发到上游模型 +4. 收到响应,拿到实际 token 数 +5. 结算:比较实际 vs 预估 + - 多扣了 → 退回差额 + - 少扣了 → 补扣差额 +6. 记录日志(ClickHouse) +``` + +## 四、数据库设计 + +### 4.1 模型定价表(全局) + +```sql +CREATE TABLE model_pricing ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + model VARCHAR(64) NOT NULL UNIQUE, + model_ratio DECIMAL(10,4) NOT NULL DEFAULT 1.0, + completion_ratio DECIMAL(10,4) NOT NULL DEFAULT 1.0, + type VARCHAR(16) DEFAULT 'text' COMMENT 'text/image/audio/video', + status TINYINT DEFAULT 1, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) COMMENT '模型基础倍率(全局统一)'; +``` + +### 4.2 租户定价覆盖表 + +```sql +CREATE TABLE tenant_pricing ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + model VARCHAR(64) NOT NULL, + model_ratio DECIMAL(10,4) COMMENT '覆盖全局倍率,NULL=用全局', + completion_ratio DECIMAL(10,4) COMMENT '覆盖全局,NULL=用全局', + enabled TINYINT DEFAULT 1 COMMENT '0=禁用该模型', + UNIQUE KEY uk_tenant_model (tenant_id, model) +) COMMENT '租户自定义倍率(覆盖全局)'; +``` + +### 4.3 分组倍率表 + +```sql +CREATE TABLE group_ratios ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + group_name VARCHAR(32) NOT NULL DEFAULT 'default', + ratio DECIMAL(10,4) NOT NULL DEFAULT 1.0, + UNIQUE KEY uk_tenant_group (tenant_id, group_name) +) COMMENT '用户组倍率'; +``` + +### 4.4 请求日志表(ClickHouse) + +```sql +-- 已有 usage_logs,补充字段 +CREATE TABLE usage_logs ( + -- 已有字段... + + -- 计费相关 + model_ratio Float32, + completion_ratio Float32, + group_ratio Float32, + quota_cost Int64 COMMENT '实际扣费额度', + pre_consumed_quota Int64 COMMENT '预扣费额度', + + -- 渠道信息 + channel_id UInt64, + channel_name String, + upstream_model String COMMENT '实际调用的上游模型(可能经过映射)', + + -- 请求信息 + request_id String, + is_stream UInt8, + first_response_ms UInt32 COMMENT '首 token 响应时间', + + -- 用户信息 + user_group String, + token_name String +) +``` + +### 4.5 兑换码表 + +```sql +CREATE TABLE redemptions ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + key_code VARCHAR(32) NOT NULL UNIQUE, + name VARCHAR(64), + quota BIGINT NOT NULL COMMENT '兑换额度', + status TINYINT DEFAULT 1 COMMENT '1=可用 2=已用 3=禁用', + used_user_id BIGINT, + used_at DATETIME, + expired_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_tenant (tenant_id), + INDEX idx_key (key_code) +) COMMENT '兑换码'; +``` + +## 五、完整请求流程 + +``` +用户发起 POST /v1/chat/completions + │ + ▼ +[1] Higress 网关接收 + ├── 域名 → 识别租户 + ├── API Key → 验证身份(调 auth-svc) + └── 转发到 relay-svc(新服务) + │ + ▼ +[2] Relay 服务处理 + ├── 解析请求,提取 model、messages + ├── 查模型定价:model_pricing → tenant_pricing 覆盖 + ├── 查用户组倍率:group_ratios + ├── 估算 prompt token 数 + ├── 计算预估费用 = tokens × model_ratio × group_ratio + ├── 预扣费(Redis 原子操作) + │ └── 额度不足 → 返回 402 + │ + ├── 选渠道(优先级 + 权重 + 自动禁用) + ├── 模型映射(用户请求 gpt-4o → 渠道实际用 gpt-4o 或其他) + ├── 转发到上游 API + │ + ├── 收到响应 + │ ├── 流式:逐 chunk 转发,最后汇总 token + │ └── 非流式:直接返回 + │ + ├── 结算:实际费用 vs 预扣费,补扣或退回差额 + ├── 更新用户 quota(Redis → 异步落库) + ├── 更新 token used_quota + └── 写日志(Kafka → ClickHouse) + │ + ▼ +[3] 用户收到响应 +``` + +## 六、Relay 服务 vs Higress AI Proxy + +**问题:Higress 已经有 AI Proxy 插件了,我们还需要自己做 Relay 吗?** + +答案:**Higress 做转发,我们做计费。** + +``` +Higress 负责: + ├── 协议转换(OpenAI ↔ Claude ↔ Gemini) + ├── 故障转移(渠道挂了自动切) + ├── 负载均衡(权重分配) + └── 流式代理(SSE 转发) + +我们的 Relay/Billing 负责: + ├── 鉴权(API Key → 用户 → 租户) + ├── 额度检查 + 预扣费 + ├── Token 计数 + ├── 结算 + 日志 + └── 倍率计算 + +流程: + 用户请求 → 我们的 Relay(鉴权+预扣费)→ Higress AI Proxy(转发)→ 上游 + 上游响应 → Higress(流式转发)→ 我们的 Relay(计数+结算)→ 用户 +``` + +实现方式:用 Higress Wasm 插件注入计费逻辑,或者在 Higress 前面加一层 Relay 服务。 + +**推荐:先用独立的 Relay 服务,简单直接。后期优化成 Wasm 插件减少一跳。** + +## 七、代理商的账务 + +### 7.1 代理商怎么付费 + +两种模式: + +**模式 A:预充值(推荐,简单)** +``` +代理商先向平台充值(Stripe / 对公转账) + → 平台给代理商账户加额度 + → 代理商的用户调 API 时 + → 按代理商定价扣代理商用户的额度 + → 按平台定价扣代理商的额度(成本) + → 代理商利润 = 卖给用户的 - 扣的成本 +``` + +**模式 B:后结算** +``` +代理商不用预充值 + → 代理商的用户调 API + → 记录用量 + → 每月结算:平台按平台定价向代理商收费 +``` + +### 7.2 代理商额度扣减 + +``` +代理商用户调 API: + 1. 扣用户额度(按代理商定价)— 用户付的 + 2. 扣代理商额度(按平台定价)— 代理商的成本 + 3. 差额 = 代理商利润 + +如果代理商额度不足: + → 该代理商下所有用户的 API 调用都返回 402 +``` + +## 八、实现优先级 + +### Phase 1: 核心计费(让平台自己能跑) +- [ ] model_pricing 表 + 初始化主流模型倍率 +- [ ] 用户额度字段(quota, used_quota, user_group) +- [ ] 预扣费 + 结算逻辑 +- [ ] Relay 服务(鉴权 → 预扣 → 转发 → 结算) +- [ ] 请求日志写入 ClickHouse +- [ ] 兑换码充值 + +### Phase 2: 代理商计费 +- [ ] tenant_pricing 覆盖表 +- [ ] group_ratios 分组倍率 +- [ ] 代理商额度(租户级 quota) +- [ ] 双层扣费(扣用户 + 扣代理商) +- [ ] 代理商充值(Stripe) + +### Phase 3: 高级功能 +- [ ] 订阅计划(月付/年付 + 额度重置) +- [ ] 渠道自动测试 + 健康检查 +- [ ] Token 精确计数(tokenizer) +- [ ] 实时用量看板 +- [ ] 账单导出 diff --git a/docs/issues.md b/docs/issues.md new file mode 100644 index 0000000..6b0a1a4 --- /dev/null +++ b/docs/issues.md @@ -0,0 +1,48 @@ +# Issues — Frontend/Backend Compatibility Audit + +Audited: 2026-03-19 (branch: dev) + +## 🔴 Blocking + +### ~~ISSUE-001: handleSetPricing undefined — backend won't compile~~ + +- **Status**: RESOLVED — pricing routes and handler removed from routes.go +- **Note**: Pricing is now frontend-only (mock data). Backend endpoint will need to be re-added when pricing feature is ready. + +### ISSUE-002: POST /api/tokens response shape mismatch + +- **Backend** (`internal/core/handler/routes.go:338-346`): returns 7 fields — `id, name, full_key, key_prefix, quota, rate_limit, status("active")` +- **Frontend** (`web/modules/tokens/types/tokens.types.ts`): expects 13 fields — additionally `models, used_quota, expires_at, created_at`, and `status` as `number` (not string) +- **Impact**: Token creation dialog may display incomplete data; fields will be `undefined`. +- **Fix (backend)**: Return full Token object including `models`, `used_quota` (0), `expires_at`, `created_at`, and `status` as integer (1=active). + +## 🟡 Medium + +### ISSUE-003: Token refresh endpoint unused by frontend + +- **Backend**: `POST /api/auth/refresh` implemented (`internal/auth/handler/routes.go`) +- **Frontend** (`web/shared/lib/api-client.ts`): On 401, clears token and redirects to `/login`; never attempts refresh. +- **Impact**: Users get logged out on token expiry instead of seamless refresh. +- **Fix (frontend)**: Add refresh interceptor in api-client.ts before falling back to logout. + +### ISSUE-004: GET /api/members is a stub + +- **Backend** (`internal/core/handler/routes.go:402-407`): Returns `{ items: [], pagination: {page:1, page_size:20, total:0} }` with TODO comment. +- **Frontend**: `/customers` page calls this endpoint and will always show an empty table. +- **Fix (backend)**: Implement actual user+role query. + +### ISSUE-005: Pricing endpoint removed — frontend has page but no backend + +- **Frontend**: `/pricing` page exists with mock data (`USE_MOCK = true`) +- **Backend**: Pricing routes were removed to fix compile error; no GET/PUT /api/pricing endpoint currently +- **Fix (backend)**: Re-implement pricing endpoints when ready. Frontend mock can remain for now. + +## 🟢 Verified OK + +- **Build**: `go build ./...` ✅ | `npm run build` ✅ +- Auth login/branding paths: proxy rewrites `/api/auth/*` → `/oauth/*` correctly +- GET /api/menus response shape: exact match (`{ menus, permissions }`) +- POST /api/tenants response: backend wraps in `{ tenant, admin }`, frontend reads `.tenant` — compatible +- GET /api/tenants, GET/DELETE /api/tokens: paginated response shapes match +- Channel endpoint: backend implemented, frontend page is placeholder (no mismatch) +- Proxy config (next.config.mjs): auth→8080, core→8081 — correct diff --git a/go.mod b/go.mod index 80b0155..a146012 100644 --- a/go.mod +++ b/go.mod @@ -14,10 +14,14 @@ require ( go.opentelemetry.io/otel/sdk v1.28.0 go.opentelemetry.io/otel/trace v1.28.0 go.uber.org/zap v1.27.0 - golang.org/x/crypto v0.25.0 + golang.org/x/crypto v0.44.0 + golang.org/x/oauth2 v0.36.0 google.golang.org/grpc v1.65.0 gorm.io/driver/mysql v1.5.7 gorm.io/gorm v1.25.11 + k8s.io/api v0.35.2 + k8s.io/apimachinery v0.35.2 + k8s.io/client-go v0.35.2 ) require ( @@ -28,31 +32,41 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect 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.20.0 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/google/gnostic-models v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.7 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/magiconair/properties v1.8.7 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect @@ -60,23 +74,36 @@ require ( github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/pflag v1.0.9 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect go.opentelemetry.io/otel/metric v1.28.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect go.uber.org/multierr v1.10.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/net v0.27.0 // indirect - golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sys v0.22.0 // indirect - golang.org/x/text v0.16.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.9.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect - google.golang.org/protobuf v1.34.2 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 1870f0c..1e7d301 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -cloud.google.com/go v0.112.1 h1:uJSeirPke5UNZHIb4SxfZklVSiWWVqW4oXlETwZziwM= -cloud.google.com/go/compute v1.24.0 h1:phWcR2eWzRJaL/kOiJwfFsPs4BaKq1j6vnpZrc1YlVg= cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -18,16 +18,21 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/ github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= @@ -37,10 +42,18 @@ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -51,13 +64,19 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 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.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= @@ -68,6 +87,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD 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/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 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.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= @@ -77,14 +98,19 @@ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02 github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -92,8 +118,15 @@ github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= @@ -104,8 +137,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4= github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= @@ -118,13 +151,14 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -132,14 +166,17 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= @@ -167,31 +204,39 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= -golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= 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.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +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.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= -golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +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/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -201,13 +246,15 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +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.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -215,12 +262,16 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 h1:0+ozOGcrp+Y8Aq8TLNN2Aliibms5LEzsq99ZZmAGYm0= google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094/go.mod h1:fJ/e3If/Q67Mj99hin0hMhiNyCRmt6BQ2aWIJshUSJw= @@ -228,11 +279,15 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 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= @@ -243,5 +298,25 @@ gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkD gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +k8s.io/api v0.35.2 h1:tW7mWc2RpxW7HS4CoRXhtYHSzme1PN1UjGHJ1bdrtdw= +k8s.io/api v0.35.2/go.mod h1:7AJfqGoAZcwSFhOjcGM7WV05QxMMgUaChNfLTXDRE60= +k8s.io/apimachinery v0.35.2 h1:NqsM/mmZA7sHW02JZ9RTtk3wInRgbVxL8MPfzSANAK8= +k8s.io/apimachinery v0.35.2/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/client-go v0.35.2 h1:YUfPefdGJA4aljDdayAXkc98DnPkIetMl4PrKX97W9o= +k8s.io/client-go v0.35.2/go.mod h1:4QqEwh4oQpeK8AaefZ0jwTFJw/9kIjdQi0jpKeYvz7g= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/auth/handler/routes.go b/internal/auth/handler/routes.go index ae4fc41..2cc5021 100644 --- a/internal/auth/handler/routes.go +++ b/internal/auth/handler/routes.go @@ -1,14 +1,26 @@ package handler import ( + "context" + "crypto/rand" "encoding/json" + "errors" "fmt" + "math/big" "net/http" "net/url" + "os" + "strings" + "time" + "github.com/LayFz/maas-cloud-api/internal/auth/repo" "github.com/LayFz/maas-cloud-api/internal/auth/server" + "github.com/LayFz/maas-cloud-api/internal/auth/service" + "github.com/LayFz/maas-cloud-api/internal/pkg/authcfg" + "github.com/LayFz/maas-cloud-api/internal/pkg/middleware" "github.com/LayFz/maas-cloud-api/internal/pkg/tenant" "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" ) // getRequestHost 获取真实的请求域名 @@ -27,17 +39,61 @@ func RegisterRoutes(r *gin.Engine, srv *server.AuthServer) { c.JSON(200, gin.H{"status": "ok", "service": "auth"}) }) - oauth := r.Group("/oauth") - { - oauth.GET("/login", handleLoginPage(srv)) - oauth.POST("/login", handleLoginSubmit(srv)) - oauth.POST("/refresh", handleRefresh(srv)) - oauth.GET("/userinfo", handleUserInfo(srv)) - - // SSO OAuth providers - oauth.GET("/providers", handleProviders(srv)) // 返回已启用的 provider 列表 - oauth.GET("/:provider", handleOAuthRedirect(srv)) // 重定向到第三方授权页 - oauth.GET("/:provider/callback", handleOAuthCallback(srv)) // 第三方回调 + // 注册到两个路径组:/oauth/* 和 /api/auth/* + // 开发环境 Next.js rewrite /api/auth/* → /oauth/* + // 生产环境 Higress 直接转发 /api/auth/* 到 auth-svc,后端两个路径都能匹配 + for _, prefix := range []string{"/oauth", "/api/auth"} { + g := r.Group(prefix) + { + g.GET("/branding", handleBranding(srv)) + g.GET("/login", handleLoginPage(srv)) + g.POST("/login", handleLoginSubmit(srv)) + g.POST("/refresh", handleRefresh(srv)) + g.GET("/userinfo", handleUserInfo(srv)) + g.GET("/providers", handleProviders(srv)) + g.POST("/send-code", handleSendCode(srv)) + g.POST("/verify-code", handleVerifyCode(srv)) + g.POST("/set-password", middleware.Auth(srv.JwtService), handleSetPassword(srv)) + g.GET("/:provider", handleOAuthRedirect(srv)) + g.GET("/:provider/callback", handleOAuthCallback(srv)) + } + } + + // Account management (requires JWT auth) + for _, prefix := range []string{"/oauth/account", "/api/account"} { + acc := r.Group(prefix) + acc.Use(middleware.Auth(srv.JwtService)) + { + acc.GET("", handleGetAccount(srv)) + acc.PUT("/password", handleChangePassword(srv)) + acc.POST("/change-email", handleChangeEmailSendCode(srv)) + acc.PUT("/email", handleChangeEmailConfirm(srv)) + } + } +} + +// GET /api/auth/branding → 根据域名返回租户品牌信息 +// 前端首次加载时调用,获取当前域名对应的 Logo/名称/主题色 +func handleBranding(srv *server.AuthServer) gin.HandlerFunc { + return func(c *gin.Context) { + host := getRequestHost(c) + tenantSlug := srv.SsoService.ResolveTenantByHost(c.Request.Context(), host) + name, logo, theme, err := srv.SsoService.GetTenantBranding(c.Request.Context(), tenantSlug) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "tenant_name": "AnyFast", + "logo_url": "", + "theme_color": "#6c8cff", + "slug": "platform", + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "tenant_name": name, + "logo_url": logo, + "theme_color": theme, + "slug": tenantSlug, + }) } } @@ -78,9 +134,20 @@ func handleLoginSubmit(srv *server.AuthServer) gin.HandlerFunc { fmt.Printf("[LOGIN] Host=%s X-Forwarded-Host=%s → tenant=%s account=%s\n", c.Request.Host, c.GetHeader("X-Forwarded-Host"), tenantSlug, req.Account) + // Check if password login is enabled for this tenant + authCfg, err := srv.SsoService.GetAuthConfig(c.Request.Context(), tenantSlug) + if err == nil && !authCfg.HasMethod("password") { + c.JSON(http.StatusForbidden, gin.H{"error": "password login not enabled"}) + return + } + result, err := srv.SsoService.Login(c.Request.Context(), tenantSlug, req.Account, req.Password) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + if errors.Is(err, service.ErrAccountNotFound) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "account_not_found"}) + } else { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + } return } @@ -175,13 +242,261 @@ func handleUserInfo(srv *server.AuthServer) gin.HandlerFunc { } } +// ========== Email Code Login/Register ========== + +type sendCodeRequest struct { + Email string `json:"email" binding:"required,email"` +} + +// POST /api/auth/send-code → 发送验证码 +func handleSendCode(srv *server.AuthServer) gin.HandlerFunc { + return func(c *gin.Context) { + var req sendCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + host := getRequestHost(c) + tenantSlug := srv.SsoService.ResolveTenantByHost(c.Request.Context(), host) + + // Check if email_code login is enabled for this tenant + authCfg, err := srv.SsoService.GetAuthConfig(c.Request.Context(), tenantSlug) + if err == nil && !authCfg.HasMethod("email_code") { + c.JSON(http.StatusForbidden, gin.H{"error": "email login not enabled"}) + return + } + + // Resolve tenant ID + tenantID, tenantName, err := srv.SsoService.ResolveTenantID(c.Request.Context(), tenantSlug) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant not found"}) + return + } + + ctx := c.Request.Context() + email := strings.ToLower(strings.TrimSpace(req.Email)) + + // Rate limit: 1 code per 60s per email+tenant + rateLimitKey := fmt.Sprintf("email_code_limit:%d:%s", tenantID, email) + ttl, err := srv.Rdb.TTL(ctx, rateLimitKey).Result() + if err == nil && ttl > 0 { + c.JSON(http.StatusTooManyRequests, gin.H{ + "error": "rate_limited", + "retry_after": int(ttl.Seconds()), + }) + return + } + + // Generate 6-digit code + code := generate6DigitCode() + + // Store code in Redis: TTL=300s (ISSUE-005: check errors) + codeKey := fmt.Sprintf("email_code:%d:%s", tenantID, email) + if err := srv.Rdb.Set(ctx, codeKey, code, 300*time.Second).Err(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store verification code"}) + return + } + + // Set rate limit: TTL=60s (ISSUE-005: check errors) + if err := srv.Rdb.Set(ctx, rateLimitKey, "1", 60*time.Second).Err(); err != nil { + // Non-critical: rate limit not set, but code was stored — continue + fmt.Printf("[SEND-CODE] failed to set rate limit for %s: %v\n", email, err) + } + + // Resolve SMTP: tenant config → fallback to global mailer + var tenantSMTP *authcfg.SMTPConfig + if authCfg != nil && authCfg.SMTP.Host != "" { + tenantSMTP = &authCfg.SMTP + } + + // Fetch tenant branding for email template + branding := resolveBranding(srv, c.Request.Context(), tenantSlug, tenantName) + if tenantSMTP != nil && tenantSMTP.FromName != "" { + branding.FromName = tenantSMTP.FromName + } + + // Send via SMTP with tenant override + if err := srv.Mailer.SendVerificationCodeWithOverride(tenantSMTP, email, code, branding); err != nil { + fmt.Printf("[SEND-CODE] SMTP error for %s: %v\n", email, err) + // Don't expose SMTP errors to client (privacy) + } + + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "expires_in": 300, + }) + } +} + +type verifyCodeRequest struct { + Email string `json:"email" binding:"required,email"` + Code string `json:"code" binding:"required"` +} + +// POST /api/auth/verify-code → 验证码校验 + 自动注册/登录 +func handleVerifyCode(srv *server.AuthServer) gin.HandlerFunc { + return func(c *gin.Context) { + var req verifyCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + host := getRequestHost(c) + tenantSlug := srv.SsoService.ResolveTenantByHost(c.Request.Context(), host) + + tenantID, tenantType, tenantName, err := srv.SsoService.ResolveTenantFull(c.Request.Context(), tenantSlug) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant not found"}) + return + } + + ctx := c.Request.Context() + email := strings.ToLower(strings.TrimSpace(req.Email)) + code := strings.TrimSpace(req.Code) + + // Verify code from Redis + codeKey := fmt.Sprintf("email_code:%d:%s", tenantID, email) + storedCode, err := srv.Rdb.Get(ctx, codeKey).Result() + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "code_expired"}) + return + } + if storedCode != code { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_code"}) + return + } + + // Delete code — one-time use (ISSUE-005: log on error, TTL is defense in depth) + if err := srv.Rdb.Del(ctx, codeKey).Err(); err != nil { + fmt.Printf("[VERIFY-CODE] failed to delete code key %s: %v\n", codeKey, err) + } + + // Lookup user by (tenant_id, email) + isNewUser := false + user, err := srv.UserRepo.GetByEmail(ctx, tenantID, email) + if err != nil { + // Auto-register (ISSUE-006: retry loop for username conflicts) + username := emailToUsername(email) + randomPass, _ := bcrypt.GenerateFromPassword([]byte(generate6DigitCode()), bcrypt.DefaultCost) + + user = &repo.User{ + TenantID: tenantID, + Username: username, + Email: email, + Password: string(randomPass), + Status: 1, + } + + created := false + for attempt := 0; attempt < 3; attempt++ { + if err := srv.UserRepo.Create(ctx, user); err == nil { + created = true + break + } + // Username conflict — append random suffix and retry + user.Username = username + randomSuffix() + } + if !created { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"}) + return + } + + // Assign default "user" role (ISSUE-008: check error) + if err := srv.UserRepo.AssignRole(ctx, tenantID, user.ID, "user"); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to assign role"}) + return + } + isNewUser = true + } + + // Get roles (ISSUE-008: check error) + roles, err := srv.UserRepo.GetRoles(ctx, tenantID, user.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get roles"}) + return + } + + // Sign JWT + info := &tenant.Info{ + TenantID: tenantID, + TenantType: tenantType, + UserID: user.ID, + Roles: roles, + } + accessToken, err := srv.JwtService.Sign(info) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sign token"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "access_token": accessToken, + "expires_in": srv.JwtService.ExpireHours() * 3600, + "is_new_user": isNewUser, + "user": gin.H{ + "id": user.ID, + "username": user.Username, + "email": user.Email, + "roles": roles, + "tenant_id": tenantID, + "tenant_type": tenantType, + "tenant_name": tenantName, + }, + }) + } +} + // ========== SSO OAuth ========== -// GET /oauth/providers → 返回已启用的第三方登录 provider 列表 +// GET /oauth/providers → 返回已启用的登录方式和 provider 列表 func handleProviders(srv *server.AuthServer) gin.HandlerFunc { return func(c *gin.Context) { + host := getRequestHost(c) + tenantSlug := srv.SsoService.ResolveTenantByHost(c.Request.Context(), host) + + authCfg, err := srv.SsoService.GetAuthConfig(c.Request.Context(), tenantSlug) + if err != nil { + // Fallback: return platform defaults + c.JSON(http.StatusOK, gin.H{ + "methods": []string{"email_code"}, + "providers": srv.OAuthService.EnabledProviders(), + }) + return + } + + // Build validated methods list + methods := make([]string, 0, len(authCfg.Methods)) + providers := make([]string, 0) + platformOAuth := srv.GetPlatformOAuthConfig() + + for _, m := range authCfg.Methods { + switch m { + case "password": + methods = append(methods, m) + case "email_code": + // email_code needs SMTP available (tenant or platform); dev mode uses stdout fallback + if authCfg.SMTP.Host != "" || srv.Mailer.Enabled() || os.Getenv("GIN_MODE") != "release" { + methods = append(methods, m) + } + case "google": + // google needs client_id (tenant or platform) + if authCfg.OAuth.Google.ClientID != "" || (platformOAuth != nil && platformOAuth.Google.Enabled) { + methods = append(methods, m) + providers = append(providers, "google") + } + case "github": + if authCfg.OAuth.GitHub.ClientID != "" || (platformOAuth != nil && platformOAuth.GitHub.Enabled) { + methods = append(methods, m) + providers = append(providers, "github") + } + } + } + c.JSON(http.StatusOK, gin.H{ - "providers": srv.OAuthService.EnabledProviders(), + "methods": methods, + "providers": providers, }) } } @@ -191,19 +506,34 @@ func handleProviders(srv *server.AuthServer) gin.HandlerFunc { func handleOAuthRedirect(srv *server.AuthServer) gin.HandlerFunc { return func(c *gin.Context) { provider := c.Param("provider") - if !srv.OAuthService.IsProviderEnabled(provider) { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("provider %s not enabled", provider)}) + + host := getRequestHost(c) + tenantSlug := srv.SsoService.ResolveTenantByHost(c.Request.Context(), host) + callbackURL := c.DefaultQuery("callback_url", "/dashboard") + + // Check if provider is enabled in tenant auth config + authCfg, err := srv.SsoService.GetAuthConfig(c.Request.Context(), tenantSlug) + if err == nil && !authCfg.HasMethod(provider) { + c.JSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("provider %s not enabled", provider)}) return } - host := c.GetHeader("X-Forwarded-Host") - if host == "" { - host = c.Request.Host + // Determine tenant OAuth creds (may be nil to fall back to platform) + var tenantOAuth *authcfg.OAuthCreds + if authCfg != nil { + switch provider { + case "google": + if authCfg.OAuth.Google.ClientID != "" { + tenantOAuth = &authCfg.OAuth.Google + } + case "github": + if authCfg.OAuth.GitHub.ClientID != "" { + tenantOAuth = &authCfg.OAuth.GitHub + } + } } - tenantSlug := srv.SsoService.ResolveTenantByHost(c.Request.Context(), host) - callbackURL := c.DefaultQuery("callback_url", "/dashboard") - authURL, err := srv.OAuthService.GetAuthURL(c.Request.Context(), provider, tenantSlug, callbackURL) + authURL, err := srv.OAuthService.GetAuthURLForTenant(c.Request.Context(), provider, tenantSlug, tenantOAuth, callbackURL) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -225,7 +555,8 @@ func handleOAuthCallback(srv *server.AuthServer) gin.HandlerFunc { return } - result, err := srv.OAuthService.HandleCallback(c.Request.Context(), provider, code, state) + // Use tenant-aware callback handler (reads OAuth config from state) + result, err := srv.OAuthService.HandleCallbackForTenant(c.Request.Context(), provider, code, state) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) return @@ -265,3 +596,254 @@ func handleOAuthCallback(srv *server.AuthServer) gin.HandlerFunc { c.Redirect(http.StatusTemporaryRedirect, redirectURL) } } + +// ========== Set Password ========== + +type setPasswordRequest struct { + Password string `json:"password" binding:"required,min=6"` +} + +// POST /api/auth/set-password → set/change password (requires JWT) +func handleSetPassword(srv *server.AuthServer) gin.HandlerFunc { + return func(c *gin.Context) { + info := tenant.MustFromContext(c.Request.Context()) + + var req setPasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) + return + } + + if err := srv.UserRepo.UpdatePassword(c.Request.Context(), info.TenantID, info.UserID, string(hashed)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update password"}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + } +} + +// ========== Account Management ========== + +// GET /api/account → current user profile +func handleGetAccount(srv *server.AuthServer) gin.HandlerFunc { + return func(c *gin.Context) { + info := tenant.MustFromContext(c.Request.Context()) + + user, err := srv.UserRepo.GetByID(c.Request.Context(), info.TenantID, info.UserID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "id": user.ID, + "username": user.Username, + "email": user.Email, + "has_password": user.Password != "", + "created_at": user.CreatedAt, + }) + } +} + +type changePasswordRequest struct { + OldPassword string `json:"old_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required,min=6"` +} + +// PUT /api/account/password → change password +func handleChangePassword(srv *server.AuthServer) gin.HandlerFunc { + return func(c *gin.Context) { + info := tenant.MustFromContext(c.Request.Context()) + + var req changePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + user, err := srv.UserRepo.GetByID(c.Request.Context(), info.TenantID, info.UserID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_password"}) + return + } + + hashed, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) + return + } + + if err := srv.UserRepo.UpdatePassword(c.Request.Context(), info.TenantID, info.UserID, string(hashed)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update password"}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + } +} + +type changeEmailRequest struct { + NewEmail string `json:"new_email" binding:"required,email"` +} + +// POST /api/account/change-email → send verification code to new email +func handleChangeEmailSendCode(srv *server.AuthServer) gin.HandlerFunc { + return func(c *gin.Context) { + info := tenant.MustFromContext(c.Request.Context()) + + var req changeEmailRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx := c.Request.Context() + newEmail := strings.ToLower(strings.TrimSpace(req.NewEmail)) + + // Rate limit + rateLimitKey := fmt.Sprintf("email_change_limit:%d:%d", info.TenantID, info.UserID) + ttl, err := srv.Rdb.TTL(ctx, rateLimitKey).Result() + if err == nil && ttl > 0 { + c.JSON(http.StatusTooManyRequests, gin.H{ + "error": "rate_limited", + "retry_after": int(ttl.Seconds()), + }) + return + } + + code := generate6DigitCode() + + codeKey := fmt.Sprintf("email_change:%d:%d", info.TenantID, info.UserID) + // Store code + new email together + codeValue := fmt.Sprintf("%s:%s", code, newEmail) + if err := srv.Rdb.Set(ctx, codeKey, codeValue, 300*time.Second).Err(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store code"}) + return + } + + srv.Rdb.Set(ctx, rateLimitKey, "1", 60*time.Second) + + // Resolve tenant name for email + host := getRequestHost(c) + tenantSlug := srv.SsoService.ResolveTenantByHost(ctx, host) + _, tenantName, _ := srv.SsoService.ResolveTenantID(ctx, tenantSlug) + if tenantName == "" { + tenantName = "AnyFast" + } + + // Resolve tenant SMTP + branding + var tenantSMTP *authcfg.SMTPConfig + authCfg, err := srv.SsoService.GetAuthConfig(ctx, tenantSlug) + if err == nil && authCfg.SMTP.Host != "" { + tenantSMTP = &authCfg.SMTP + } + + branding := resolveBranding(srv, ctx, tenantSlug, tenantName) + if tenantSMTP != nil && tenantSMTP.FromName != "" { + branding.FromName = tenantSMTP.FromName + } + + if err := srv.Mailer.SendChangeEmailCodeWithOverride(tenantSMTP, newEmail, code, newEmail, branding); err != nil { + fmt.Printf("[CHANGE-EMAIL] SMTP error for %s: %v\n", newEmail, err) + } + + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "expires_in": 300, + }) + } +} + +type confirmEmailRequest struct { + NewEmail string `json:"new_email" binding:"required,email"` + Code string `json:"code" binding:"required"` +} + +// PUT /api/account/email → verify code and update email +func handleChangeEmailConfirm(srv *server.AuthServer) gin.HandlerFunc { + return func(c *gin.Context) { + info := tenant.MustFromContext(c.Request.Context()) + + var req confirmEmailRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx := c.Request.Context() + newEmail := strings.ToLower(strings.TrimSpace(req.NewEmail)) + code := strings.TrimSpace(req.Code) + + codeKey := fmt.Sprintf("email_change:%d:%d", info.TenantID, info.UserID) + storedValue, err := srv.Rdb.Get(ctx, codeKey).Result() + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "code_expired"}) + return + } + + // Parse stored "code:email" + parts := strings.SplitN(storedValue, ":", 2) + if len(parts) != 2 || parts[0] != code || parts[1] != newEmail { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_code"}) + return + } + + // Delete code (TTL is defense in depth) + if err := srv.Rdb.Del(ctx, codeKey).Err(); err != nil { + fmt.Printf("[CHANGE-EMAIL] failed to delete code key %s: %v\n", codeKey, err) + } + + // Update email in DB + if err := srv.UserRepo.UpdateEmail(ctx, info.TenantID, info.UserID, newEmail); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update email"}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + } +} + +// ========== Helpers ========== + +// resolveBranding fetches tenant branding for email templates +func resolveBranding(srv *server.AuthServer, ctx context.Context, tenantSlug, tenantName string) *service.EmailBranding { + branding := service.DefaultBranding(tenantName) + name, logo, theme, err := srv.SsoService.GetTenantBranding(ctx, tenantSlug) + if err == nil { + if name != "" { + branding.TenantName = name + } + branding.LogoURL = logo + if theme != "" { + branding.ThemeColor = theme + } + } + return branding +} + +func generate6DigitCode() string { + n, _ := rand.Int(rand.Reader, big.NewInt(900000)) + return fmt.Sprintf("%06d", n.Int64()+100000) +} + +func emailToUsername(email string) string { + parts := strings.SplitN(email, "@", 2) + return parts[0] +} + +func randomSuffix() string { + n, _ := rand.Int(rand.Reader, big.NewInt(9999)) + return fmt.Sprintf("%04d", n.Int64()) +} diff --git a/internal/auth/repo/user.go b/internal/auth/repo/user.go index 1df432e..cd20902 100644 --- a/internal/auth/repo/user.go +++ b/internal/auth/repo/user.go @@ -2,6 +2,7 @@ package repo import ( "context" + "fmt" "time" "gorm.io/gorm" @@ -40,6 +41,18 @@ func NewUserRepo(db *gorm.DB) *UserRepo { return &UserRepo{db: db} } +// GetByEmail 按邮箱查用户(精确匹配,跳过空邮箱) +func (r *UserRepo) GetByEmail(ctx context.Context, tenantID int64, email string) (*User, error) { + if email == "" { + return nil, fmt.Errorf("email is empty") + } + var user User + err := r.db.WithContext(ctx). + Where("tenant_id = ? AND email = ? AND email != '' AND status = 1", tenantID, email). + First(&user).Error + return &user, err +} + // GetByEmailOrUsername 按邮箱或用户名查用户 func (r *UserRepo) GetByEmailOrUsername(ctx context.Context, tenantID int64, account string) (*User, error) { var user User @@ -85,6 +98,22 @@ func (r *UserRepo) AssignRole(ctx context.Context, tenantID, userID int64, role return r.db.WithContext(ctx).Create(ur).Error } +// UpdatePassword 更新用户密码 +func (r *UserRepo) UpdatePassword(ctx context.Context, tenantID, userID int64, hashedPassword string) error { + return r.db.WithContext(ctx). + Table("users"). + Where("id = ? AND tenant_id = ? AND status = 1", userID, tenantID). + Update("password", hashedPassword).Error +} + +// UpdateEmail 更新用户邮箱 +func (r *UserRepo) UpdateEmail(ctx context.Context, tenantID, userID int64, newEmail string) error { + return r.db.WithContext(ctx). + Table("users"). + Where("id = ? AND tenant_id = ? AND status = 1", userID, tenantID). + Update("email", newEmail).Error +} + // ListByTenant 列出租户下所有用户 func (r *UserRepo) ListByTenant(ctx context.Context, tenantID int64, page, pageSize int) ([]User, int64, error) { var users []User diff --git a/internal/auth/server/server.go b/internal/auth/server/server.go index c58513b..8fb5634 100644 --- a/internal/auth/server/server.go +++ b/internal/auth/server/server.go @@ -5,6 +5,7 @@ import ( "github.com/LayFz/maas-cloud-api/internal/auth/service" "github.com/LayFz/maas-cloud-api/internal/pkg/config" "github.com/LayFz/maas-cloud-api/internal/pkg/database" + "github.com/redis/go-redis/v9" ) // AuthServer 认证服务 @@ -13,7 +14,9 @@ type AuthServer struct { JwtService *service.JWTService SsoService *service.SSOService OAuthService *service.OAuthService + Mailer *service.Mailer UserRepo *repo.UserRepo + Rdb redis.UniversalClient } func NewAuthServer(cfg *config.Config) *AuthServer { @@ -23,13 +26,16 @@ func NewAuthServer(cfg *config.Config) *AuthServer { jwtSvc := service.NewJWTService(cfg.JWT.Secret, cfg.JWT.ExpireHour) ssoSvc := service.NewSSOService(db, userRepo) oauthSvc := service.NewOAuthService(&cfg.OAuth, db, rdb, userRepo) + mailer := service.NewMailer(&cfg.SMTP) return &AuthServer{ cfg: cfg, JwtService: jwtSvc, SsoService: ssoSvc, OAuthService: oauthSvc, + Mailer: mailer, UserRepo: userRepo, + Rdb: rdb, } } @@ -40,3 +46,13 @@ func (s *AuthServer) GetFrontendURL() string { } return "http://localhost:3000" } + +// GetPlatformOAuthConfig returns the platform-level OAuth config +func (s *AuthServer) GetPlatformOAuthConfig() *config.OAuthConfig { + return &s.cfg.OAuth +} + +// GetPlatformSMTPConfig returns the platform-level SMTP config +func (s *AuthServer) GetPlatformSMTPConfig() *config.SMTPConfig { + return &s.cfg.SMTP +} diff --git a/internal/auth/service/email_templates.go b/internal/auth/service/email_templates.go new file mode 100644 index 0000000..91adb6f --- /dev/null +++ b/internal/auth/service/email_templates.go @@ -0,0 +1,136 @@ +package service + +import ( + "fmt" + "html" + "strings" +) + +// EmailBranding tenant branding info for email templates +type EmailBranding struct { + TenantName string + LogoURL string + ThemeColor string + FromName string +} + +// DefaultBranding returns fallback branding +func DefaultBranding(tenantName string) *EmailBranding { + if tenantName == "" { + tenantName = "AnyFast" + } + return &EmailBranding{ + TenantName: tenantName, + ThemeColor: "#6c8cff", + } +} + +func (b *EmailBranding) color() string { + if b.ThemeColor != "" { + return b.ThemeColor + } + return "#6c8cff" +} + +func (b *EmailBranding) name() string { + if b.TenantName != "" { + return html.EscapeString(b.TenantName) + } + return "AnyFast" +} + +func (b *EmailBranding) logoHTML() string { + if b.LogoURL != "" { + return fmt.Sprintf(`%s`, html.EscapeString(b.LogoURL), b.name()) + } + return fmt.Sprintf(`
%s
`, b.color(), b.name()) +} + +func (b *EmailBranding) fromHeader(fromAddr string) string { + name := b.FromName + if name == "" { + name = b.TenantName + } + if name == "" { + name = "AnyFast" + } + return fmt.Sprintf("%s <%s>", name, fromAddr) +} + +func emailLayout(branding *EmailBranding, bodyContent string) string { + return fmt.Sprintf(` + + + + + +
+ + + +
+%s +
+%s +
+ + +
+If you did not request this email, you can safely ignore it.
+© %s +
+
+ +`, branding.logoHTML(), bodyContent, branding.name()) +} + +// RenderVerificationEmail renders branded HTML verification code email +func RenderVerificationEmail(code string, branding *EmailBranding) string { + body := fmt.Sprintf(` +

Your verification code is:

+
+%s +
+

This code will expire in 5 minutes. Do not share it with anyone.

`, + branding.color(), html.EscapeString(code)) + return emailLayout(branding, body) +} + +// RenderChangeEmailTemplate renders branded HTML for email change verification +func RenderChangeEmailTemplate(code, newEmail string, branding *EmailBranding) string { + body := fmt.Sprintf(` +

You requested to change your email to %s.

+

Your verification code is:

+
+%s +
+

This code will expire in 5 minutes. If you didn't request this change, please ignore this email.

`, + html.EscapeString(newEmail), branding.color(), html.EscapeString(code)) + return emailLayout(branding, body) +} + +// RenderTestEmail renders branded HTML test email +func RenderTestEmail(branding *EmailBranding) string { + body := fmt.Sprintf(` +
+
+ +
+

SMTP Configuration Working

+

Your SMTP configuration is verified and working correctly.
Emails from %s will be delivered using these settings.

+
`, branding.color(), branding.name()) + return emailLayout(branding, body) +} + +// BuildEmailMessage constructs a full MIME email message with HTML body +func BuildEmailMessage(from, to, subject, htmlBody string) string { + return strings.Join([]string{ + "From: " + from, + "To: " + to, + "Subject: " + subject, + "MIME-Version: 1.0", + "Content-Type: text/html; charset=utf-8", + "", + htmlBody, + }, "\r\n") +} diff --git a/internal/auth/service/mailer.go b/internal/auth/service/mailer.go new file mode 100644 index 0000000..2d536f4 --- /dev/null +++ b/internal/auth/service/mailer.go @@ -0,0 +1,235 @@ +package service + +import ( + "crypto/tls" + "fmt" + "net" + "net/smtp" + "os" + + "github.com/LayFz/maas-cloud-api/internal/pkg/authcfg" + "github.com/LayFz/maas-cloud-api/internal/pkg/config" +) + +// Mailer SMTP 邮件发送 +type Mailer struct { + host string + port int + username string + password string + from string +} + +func NewMailer(cfg *config.SMTPConfig) *Mailer { + return &Mailer{ + host: cfg.Host, + port: cfg.Port, + username: cfg.Username, + password: cfg.Password, + from: cfg.From, + } +} + +// Enabled 检查 SMTP 是否已配置 +func (m *Mailer) Enabled() bool { + return m.host != "" && m.from != "" +} + +// SendVerificationCode sends branded HTML verification code email +func (m *Mailer) SendVerificationCode(to, code string, branding *EmailBranding) error { + if !m.Enabled() { + if os.Getenv("GIN_MODE") == "release" { + return fmt.Errorf("SMTP not configured in production") + } + fmt.Printf("[MAILER-DEV] Verification code for %s: %s\n", to, code) + return nil + } + + htmlBody := RenderVerificationEmail(code, branding) + subject := fmt.Sprintf("[%s] Your verification code", branding.name()) + fromHeader := branding.fromHeader(m.from) + msg := BuildEmailMessage(fromHeader, to, subject, htmlBody) + + if m.port == 465 { + return m.sendWithTLS(to, []byte(msg)) + } + return m.sendWithSTARTTLS(to, []byte(msg)) +} + +// SendChangeEmailCode sends branded HTML email change verification +func (m *Mailer) SendChangeEmailCode(to, code, newEmail string, branding *EmailBranding) error { + if !m.Enabled() { + if os.Getenv("GIN_MODE") == "release" { + return fmt.Errorf("SMTP not configured in production") + } + fmt.Printf("[MAILER-DEV] Email change code for %s (new: %s): %s\n", to, newEmail, code) + return nil + } + + htmlBody := RenderChangeEmailTemplate(code, newEmail, branding) + subject := fmt.Sprintf("[%s] Verify your new email", branding.name()) + fromHeader := branding.fromHeader(m.from) + msg := BuildEmailMessage(fromHeader, to, subject, htmlBody) + + if m.port == 465 { + return m.sendWithTLS(to, []byte(msg)) + } + return m.sendWithSTARTTLS(to, []byte(msg)) +} + +// SendTestEmail sends branded HTML test email +func (m *Mailer) SendTestEmail(to string, branding *EmailBranding) error { + if !m.Enabled() { + return fmt.Errorf("SMTP not configured") + } + + htmlBody := RenderTestEmail(branding) + subject := fmt.Sprintf("[%s] SMTP Test", branding.name()) + fromHeader := branding.fromHeader(m.from) + msg := BuildEmailMessage(fromHeader, to, subject, htmlBody) + + if m.port == 465 { + return m.sendWithTLS(to, []byte(msg)) + } + return m.sendWithSTARTTLS(to, []byte(msg)) +} + +// SendWithConfig sends verification code using provided SMTP config +func (m *Mailer) SendWithConfig(smtpCfg *authcfg.SMTPConfig, to, code string, branding *EmailBranding) error { + tmp := &Mailer{ + host: smtpCfg.Host, + port: smtpCfg.Port, + username: smtpCfg.Username, + password: smtpCfg.Password, + from: smtpCfg.From, + } + // Use tenant from_name if available + if smtpCfg.FromName != "" && branding.FromName == "" { + branding.FromName = smtpCfg.FromName + } + return tmp.SendVerificationCode(to, code, branding) +} + +// SendVerificationCodeWithOverride sends verification code using tenant SMTP if available +func (m *Mailer) SendVerificationCodeWithOverride(tenantSMTP *authcfg.SMTPConfig, to, code string, branding *EmailBranding) error { + if tenantSMTP != nil && tenantSMTP.Host != "" { + return m.SendWithConfig(tenantSMTP, to, code, branding) + } + return m.SendVerificationCode(to, code, branding) +} + +// SendChangeEmailCodeWithOverride sends email change code using tenant SMTP if available +func (m *Mailer) SendChangeEmailCodeWithOverride(tenantSMTP *authcfg.SMTPConfig, to, code, newEmail string, branding *EmailBranding) error { + if tenantSMTP != nil && tenantSMTP.Host != "" { + tmp := &Mailer{ + host: tenantSMTP.Host, + port: tenantSMTP.Port, + username: tenantSMTP.Username, + password: tenantSMTP.Password, + from: tenantSMTP.From, + } + if tenantSMTP.FromName != "" && branding.FromName == "" { + branding.FromName = tenantSMTP.FromName + } + return tmp.SendChangeEmailCode(to, code, newEmail, branding) + } + return m.SendChangeEmailCode(to, code, newEmail, branding) +} + +// sendWithTLS connects via implicit TLS (port 465) +func (m *Mailer) sendWithTLS(to string, msg []byte) error { + addr := net.JoinHostPort(m.host, fmt.Sprintf("%d", m.port)) + tlsConfig := &tls.Config{ServerName: m.host} + + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { + return fmt.Errorf("tls dial: %w", err) + } + defer conn.Close() + + client, err := smtp.NewClient(conn, m.host) + if err != nil { + return fmt.Errorf("smtp client: %w", err) + } + defer client.Close() + + if m.username != "" { + auth := smtp.PlainAuth("", m.username, m.password, m.host) + if err := client.Auth(auth); err != nil { + return fmt.Errorf("smtp auth: %w", err) + } + } + + if err := client.Mail(m.from); err != nil { + return fmt.Errorf("smtp mail: %w", err) + } + if err := client.Rcpt(to); err != nil { + return fmt.Errorf("smtp rcpt: %w", err) + } + + w, err := client.Data() + if err != nil { + return fmt.Errorf("smtp data: %w", err) + } + if _, err := w.Write(msg); err != nil { + return fmt.Errorf("smtp write: %w", err) + } + if err := w.Close(); err != nil { + return fmt.Errorf("smtp close data: %w", err) + } + + return client.Quit() +} + +// sendWithSTARTTLS connects via plaintext then upgrades to TLS (port 587) +func (m *Mailer) sendWithSTARTTLS(to string, msg []byte) error { + addr := net.JoinHostPort(m.host, fmt.Sprintf("%d", m.port)) + + conn, err := net.Dial("tcp", addr) + if err != nil { + return fmt.Errorf("dial: %w", err) + } + defer conn.Close() + + client, err := smtp.NewClient(conn, m.host) + if err != nil { + return fmt.Errorf("smtp client: %w", err) + } + defer client.Close() + + tlsConfig := &tls.Config{ServerName: m.host} + if ok, _ := client.Extension("STARTTLS"); ok { + if err := client.StartTLS(tlsConfig); err != nil { + return fmt.Errorf("starttls: %w", err) + } + } else { + return fmt.Errorf("SMTP server does not support STARTTLS — refusing to send credentials in plaintext") + } + + if m.username != "" { + auth := smtp.PlainAuth("", m.username, m.password, m.host) + if err := client.Auth(auth); err != nil { + return fmt.Errorf("smtp auth: %w", err) + } + } + + if err := client.Mail(m.from); err != nil { + return fmt.Errorf("smtp mail: %w", err) + } + if err := client.Rcpt(to); err != nil { + return fmt.Errorf("smtp rcpt: %w", err) + } + + w, err := client.Data() + if err != nil { + return fmt.Errorf("smtp data: %w", err) + } + if _, err := w.Write(msg); err != nil { + return fmt.Errorf("smtp write: %w", err) + } + if err := w.Close(); err != nil { + return fmt.Errorf("smtp close data: %w", err) + } + + return client.Quit() +} diff --git a/internal/auth/service/oauth.go b/internal/auth/service/oauth.go index 454c197..9fac5b9 100644 --- a/internal/auth/service/oauth.go +++ b/internal/auth/service/oauth.go @@ -10,6 +10,7 @@ import ( "time" "github.com/LayFz/maas-cloud-api/internal/auth/repo" + "github.com/LayFz/maas-cloud-api/internal/pkg/authcfg" "github.com/LayFz/maas-cloud-api/internal/pkg/config" "github.com/redis/go-redis/v9" "golang.org/x/crypto/bcrypt" @@ -224,6 +225,171 @@ func (s *OAuthService) fetchUserInfo(ctx context.Context, provider string, cfg * return &info, nil } +// GetAuthURLForTenant generates OAuth URL using tenant-specific or platform credentials +func (s *OAuthService) GetAuthURLForTenant(ctx context.Context, provider, tenantSlug string, tenantOAuth *authcfg.OAuthCreds, callbackURL string) (string, error) { + // Determine which OAuth config to use + var oauthCfg *oauth2.Config + + if tenantOAuth != nil && tenantOAuth.ClientID != "" && tenantOAuth.ClientSecret != "" { + // Use tenant-specific credentials + oauthCfg = s.buildOAuthConfig(provider, tenantOAuth.ClientID, tenantOAuth.ClientSecret) + } else { + // Fall back to platform credentials + var ok bool + oauthCfg, ok = s.configs[provider] + if !ok { + return "", fmt.Errorf("provider %s not configured", provider) + } + } + + // Generate random state + b := make([]byte, 16) + rand.Read(b) + stateToken := base64.URLEncoding.EncodeToString(b) + + // Store state with tenant OAuth info in Redis + statePayload := map[string]string{ + "tenant": tenantSlug, + "callback": callbackURL, + "provider": provider, + } + // If using tenant creds, store them so callback can rebuild the config + if tenantOAuth != nil && tenantOAuth.ClientID != "" && tenantOAuth.ClientSecret != "" { + statePayload["oauth_client_id"] = tenantOAuth.ClientID + statePayload["oauth_client_secret"] = tenantOAuth.ClientSecret + } + + stateData, _ := json.Marshal(statePayload) + s.rdb.Set(ctx, "oauth_state:"+stateToken, stateData, 10*time.Minute) + + return oauthCfg.AuthCodeURL(stateToken, oauth2.AccessTypeOffline), nil +} + +// HandleCallbackForTenant handles callback with tenant-specific credentials +func (s *OAuthService) HandleCallbackForTenant(ctx context.Context, provider, code, state string) (*OAuthCallbackResult, error) { + bgCtx := context.Background() + + // 1. Verify state + stateDataStr, err := s.rdb.Get(bgCtx, "oauth_state:"+state).Result() + if err != nil { + return nil, fmt.Errorf("invalid or expired state") + } + + var stateData map[string]string + json.Unmarshal([]byte(stateDataStr), &stateData) + tenantSlug := stateData["tenant"] + callbackURL := stateData["callback"] + storedProvider := stateData["provider"] + + if storedProvider != "" && storedProvider != provider { + return nil, fmt.Errorf("provider mismatch") + } + + // Determine OAuth config: tenant-specific or platform + var oauthCfg *oauth2.Config + if clientID, ok := stateData["oauth_client_id"]; ok && clientID != "" { + oauthCfg = s.buildOAuthConfig(provider, clientID, stateData["oauth_client_secret"]) + } else { + oauthCfg, ok = s.configs[provider] + if !ok { + return nil, fmt.Errorf("provider %s not configured", provider) + } + } + + // 2. Exchange code for token + exchangeCtx, cancel := context.WithTimeout(bgCtx, 30*time.Second) + defer cancel() + token, err := oauthCfg.Exchange(exchangeCtx, code) + if err != nil { + return nil, fmt.Errorf("exchange code: %w", err) + } + + s.rdb.Del(bgCtx, "oauth_state:"+state) + + // 3. Fetch user info + userInfo, err := s.fetchUserInfo(bgCtx, provider, oauthCfg, token) + if err != nil { + return nil, fmt.Errorf("fetch user info: %w", err) + } + + // 4. Find tenant + var tenant struct { + ID int64 + Type string + Name string + } + err = s.db.WithContext(bgCtx). + Table("tenants"). + Select("id, type, name"). + Where("slug = ? AND status = 1", tenantSlug). + First(&tenant).Error + if err != nil { + return nil, fmt.Errorf("tenant not found: %s", tenantSlug) + } + + // 5. Find or create user + user, err := s.userRepo.GetByEmailOrUsername(bgCtx, tenant.ID, userInfo.Email) + isNewUser := false + if err != nil { + randomPass, _ := bcrypt.GenerateFromPassword([]byte(stateData["tenant"]+userInfo.ID), bcrypt.DefaultCost) + user = &repo.User{ + TenantID: tenant.ID, + Username: userInfo.Name, + Email: userInfo.Email, + Password: string(randomPass), + Status: 1, + } + if err := s.userRepo.Create(bgCtx, user); err != nil { + return nil, fmt.Errorf("create user: %w", err) + } + s.userRepo.AssignRole(bgCtx, tenant.ID, user.ID, "user") + isNewUser = true + } + + // 6. Get roles + roles, _ := s.userRepo.GetRoles(bgCtx, tenant.ID, user.ID) + + return &OAuthCallbackResult{ + TenantSlug: tenantSlug, + CallbackURL: callbackURL, + UserID: user.ID, + TenantID: tenant.ID, + TenantType: tenant.Type, + TenantName: tenant.Name, + Username: user.Username, + Email: user.Email, + Roles: roles, + IsNewUser: isNewUser, + }, nil +} + +// buildOAuthConfig builds an oauth2.Config for a given provider with custom credentials +func (s *OAuthService) buildOAuthConfig(provider, clientID, clientSecret string) *oauth2.Config { + cfg := &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + } + + // Find base URL from existing platform config (any provider will have same base) + var baseURL string + for _, c := range s.configs { + // Extract base URL from redirect URL (e.g., "https://oauth.anyfast.ai/oauth/google/callback" → "https://oauth.anyfast.ai") + if idx := len(c.RedirectURL) - len("/oauth/"+provider+"/callback"); idx > 0 { + baseURL = c.RedirectURL[:idx] + } + break + } + + switch provider { + case "google": + cfg.Endpoint = google.Endpoint + cfg.RedirectURL = baseURL + "/oauth/google/callback" + cfg.Scopes = []string{"openid", "email", "profile"} + } + + return cfg +} + // EnabledProviders 返回已启用的 provider 列表 func (s *OAuthService) EnabledProviders() []string { providers := make([]string, 0, len(s.configs)) diff --git a/internal/auth/service/sso.go b/internal/auth/service/sso.go index 24da629..37462d1 100644 --- a/internal/auth/service/sso.go +++ b/internal/auth/service/sso.go @@ -2,14 +2,23 @@ package service import ( "context" + "encoding/json" + "errors" "fmt" "strings" "github.com/LayFz/maas-cloud-api/internal/auth/repo" + "github.com/LayFz/maas-cloud-api/internal/pkg/authcfg" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) +// Sentinel errors for login +var ( + ErrAccountNotFound = errors.New("account_not_found") + ErrInvalidPassword = errors.New("invalid_password") +) + // SSOService 统一登录服务 type SSOService struct { db *gorm.DB @@ -51,12 +60,12 @@ func (s *SSOService) Login(ctx context.Context, tenantSlug, account, password st // 2. 查用户 (按邮箱或用户名) user, err := s.userRepo.GetByEmailOrUsername(ctx, tenant.ID, account) if err != nil { - return nil, fmt.Errorf("user not found") + return nil, ErrAccountNotFound } // 3. 校验密码 if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { - return nil, fmt.Errorf("invalid password") + return nil, ErrInvalidPassword } // 4. 查角色 @@ -96,6 +105,103 @@ func (s *SSOService) ResolveTenantByHost(ctx context.Context, host string) strin return slug } +// ResolveTenantID 根据 slug 返回 tenant ID 和名称 +func (s *SSOService) ResolveTenantID(ctx context.Context, tenantSlug string) (int64, string, error) { + var result struct { + ID int64 + Name string + } + err := s.db.WithContext(ctx). + Table("tenants"). + Select("id, name"). + Where("slug = ? AND status = 1", tenantSlug). + First(&result).Error + if err != nil { + return 0, "", fmt.Errorf("tenant not found: %s", tenantSlug) + } + return result.ID, result.Name, nil +} + +// ResolveTenantFull 根据 slug 返回 tenant 完整信息 +func (s *SSOService) ResolveTenantFull(ctx context.Context, tenantSlug string) (int64, string, string, error) { + var result struct { + ID int64 + Type string + Name string + } + err := s.db.WithContext(ctx). + Table("tenants"). + Select("id, type, name"). + Where("slug = ? AND status = 1", tenantSlug). + First(&result).Error + if err != nil { + return 0, "", "", fmt.Errorf("tenant not found: %s", tenantSlug) + } + return result.ID, result.Type, result.Name, nil +} + +// GetAuthConfig reads auth config from tenant's JSON config column +func (s *SSOService) GetAuthConfig(ctx context.Context, tenantSlug string) (*authcfg.TenantAuthConfig, error) { + var result struct { + Type string + Config *string + } + err := s.db.WithContext(ctx). + Table("tenants"). + Select("type, config"). + Where("slug = ? AND status = 1", tenantSlug). + First(&result).Error + if err != nil { + return nil, fmt.Errorf("tenant not found: %s", tenantSlug) + } + + if result.Config != nil && *result.Config != "" { + var cfgMap map[string]json.RawMessage + if err := json.Unmarshal([]byte(*result.Config), &cfgMap); err == nil { + if authRaw, ok := cfgMap["auth"]; ok { + var ac authcfg.TenantAuthConfig + if err := json.Unmarshal(authRaw, &ac); err == nil { + return &ac, nil + } + } + } + } + + // Fallback to default for tenant type + return authcfg.DefaultForType(result.Type), nil +} + +// SetAuthConfig writes auth config to tenant's JSON config column +func (s *SSOService) SetAuthConfig(ctx context.Context, tenantID int64, cfg *authcfg.TenantAuthConfig) error { + // Read existing config JSON + var existing *string + err := s.db.WithContext(ctx). + Table("tenants"). + Select("config"). + Where("id = ?", tenantID). + Scan(&existing).Error + if err != nil { + return fmt.Errorf("read tenant config: %w", err) + } + + cfgMap := make(map[string]interface{}) + if existing != nil && *existing != "" { + json.Unmarshal([]byte(*existing), &cfgMap) + } + + cfgMap["auth"] = cfg + newJSON, err := json.Marshal(cfgMap) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + + configStr := string(newJSON) + return s.db.WithContext(ctx). + Table("tenants"). + Where("id = ?", tenantID). + Update("config", configStr).Error +} + // GetTenantBranding 获取租户品牌信息(登录页展示) func (s *SSOService) GetTenantBranding(ctx context.Context, tenantSlug string) (name, logoURL, themeColor string, err error) { var result struct { diff --git a/internal/billing/consumer/usage.go b/internal/billing/consumer/usage.go index a029a7b..35bb0ef 100644 --- a/internal/billing/consumer/usage.go +++ b/internal/billing/consumer/usage.go @@ -2,12 +2,27 @@ package consumer import ( "context" + "encoding/json" + "fmt" "github.com/LayFz/maas-cloud-api/internal/pkg/config" "github.com/LayFz/maas-cloud-api/internal/pkg/mq" "go.uber.org/zap" ) +// UsageEvent Kafka 用量事件 payload +type UsageEvent struct { + TraceID string `json:"trace_id"` + TenantID int64 `json:"tenant_id"` + UserID int64 `json:"user_id"` + Model string `json:"model"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + TotalTokens int64 `json:"total_tokens"` + QuotaCost int64 `json:"quota_cost"` + Duration int64 `json:"duration"` +} + // UsageConsumer 用量事件消费者 type UsageConsumer struct { consumer *mq.Consumer @@ -35,12 +50,27 @@ func (uc *UsageConsumer) handleUsageEvent(ctx context.Context, event *mq.Event) zap.Int64("tenant_id", event.TenantID), ) - // TODO: 实现具体逻辑 - // 1. 解析 payload 中的 token 用量数据 - // 2. Redis 原子扣减额度: DECRBY quota:{tenant_id}:{token_id} {tokens} - // 3. 根据租户定价计算费用 - // 4. 写入 ClickHouse usage_logs 表 - // 5. 额度不足时发送告警事件到 alert.events + if event.Type != "usage.reported" { + return nil + } + + payloadBytes, err := json.Marshal(event.Payload) + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + + var usage UsageEvent + if err := json.Unmarshal(payloadBytes, &usage); err != nil { + return fmt.Errorf("unmarshal usage event: %w", err) + } + + uc.logger.Info("usage event processed", + zap.String("trace_id", usage.TraceID), + zap.String("model", usage.Model), + zap.Int64("prompt_tokens", usage.PromptTokens), + zap.Int64("completion_tokens", usage.CompletionTokens), + zap.Int64("quota_cost", usage.QuotaCost), + ) return nil } diff --git a/internal/billing/handler/internal.go b/internal/billing/handler/internal.go new file mode 100644 index 0000000..781faa2 --- /dev/null +++ b/internal/billing/handler/internal.go @@ -0,0 +1,124 @@ +package handler + +import ( + "net/http" + "time" + + "github.com/LayFz/maas-cloud-api/internal/billing/server" + "github.com/LayFz/maas-cloud-api/internal/billing/service" + "github.com/LayFz/maas-cloud-api/internal/pkg/middleware" + "github.com/gin-gonic/gin" +) + +// RegisterInternalRoutes 注册内部 API 路由(Wasm 插件调用) +func RegisterInternalRoutes(r *gin.Engine, srv *server.BillingServer) { + internal := r.Group("/internal/billing") + internal.Use(middleware.InternalAuth(srv.InternalSecret)) + { + internal.POST("/pre-deduct", handlePreDeduct(srv)) + internal.POST("/settle", handleSettle(srv)) + } +} + +type preDeductRequest struct { + TenantID int64 `json:"tenant_id" binding:"required"` + UserID int64 `json:"user_id" binding:"required"` + TraceID string `json:"trace_id" binding:"required"` + Model string `json:"model" binding:"required"` +} + +func handlePreDeduct(srv *server.BillingServer) gin.HandlerFunc { + return func(c *gin.Context) { + var req preDeductRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 获取用户定价分组 + userGroup, _ := srv.UserQuotaRepo.GetPricingGroup(c.Request.Context(), req.TenantID, req.UserID) + + // 计算预扣额度 + ratios, err := srv.PricingService.GetEffectivePricing(c.Request.Context(), req.TenantID, req.Model, userGroup) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 预估 4096 tokens 的 prompt + estimatedCost := srv.PricingService.EstimatePreDeductionCost(ratios, 4096) + + remaining, err := srv.QuotaService.PreDeduct(c.Request.Context(), req.TenantID, req.UserID, req.TraceID, estimatedCost) + if err != nil { + c.JSON(http.StatusPaymentRequired, gin.H{"error": "insufficient quota"}) + return + } + + // 标记脏 + srv.QuotaService.MarkDirty(c.Request.Context(), req.TenantID, req.UserID) + + c.JSON(http.StatusOK, gin.H{ + "status": "pre-deducted", + "estimated_cost": estimatedCost, + "remaining": remaining, + }) + } +} + +type settleRequest struct { + TenantID int64 `json:"tenant_id" binding:"required"` + UserID int64 `json:"user_id" binding:"required"` + TraceID string `json:"trace_id" binding:"required"` + Model string `json:"model" binding:"required"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + TotalTokens int64 `json:"total_tokens"` + Duration int64 `json:"duration"` // ms +} + +func handleSettle(srv *server.BillingServer) gin.HandlerFunc { + return func(c *gin.Context) { + var req settleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userGroup, _ := srv.UserQuotaRepo.GetPricingGroup(c.Request.Context(), req.TenantID, req.UserID) + + ratios, err := srv.PricingService.GetEffectivePricing(c.Request.Context(), req.TenantID, req.Model, userGroup) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + actualCost := srv.PricingService.CalculateCost(ratios, req.PromptTokens, req.CompletionTokens) + + if err := srv.QuotaService.Settle(c.Request.Context(), req.TenantID, req.UserID, req.TraceID, actualCost); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // 标记脏 + srv.QuotaService.MarkDirty(c.Request.Context(), req.TenantID, req.UserID) + + // 写入 ClickHouse + srv.CHWriter.Write(service.UsageRecord{ + TraceID: req.TraceID, + TenantID: req.TenantID, + UserID: req.UserID, + Model: req.Model, + PromptTokens: req.PromptTokens, + CompletionTokens: req.CompletionTokens, + TotalTokens: req.TotalTokens, + QuotaCost: actualCost, + Duration: req.Duration, + CreatedAt: time.Now(), + }) + + c.JSON(http.StatusOK, gin.H{ + "status": "settled", + "actual_cost": actualCost, + }) + } +} diff --git a/internal/billing/handler/routes.go b/internal/billing/handler/routes.go new file mode 100644 index 0000000..430e31b --- /dev/null +++ b/internal/billing/handler/routes.go @@ -0,0 +1,431 @@ +package handler + +import ( + "net/http" + "strconv" + "time" + + "github.com/LayFz/maas-cloud-api/internal/billing/repo" + "github.com/LayFz/maas-cloud-api/internal/billing/server" + "github.com/LayFz/maas-cloud-api/internal/pkg/middleware" + "github.com/LayFz/maas-cloud-api/internal/pkg/tenant" + "github.com/gin-gonic/gin" +) + +// RegisterRoutes 注册 Billing 公开 API 路由 +// 路由挂载在 /api 下,与 contracts/api-contracts.md 对齐 +func RegisterRoutes(r *gin.Engine, srv *server.BillingServer) { + r.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok", "service": "billing"}) + }) + + api := r.Group("/api") + api.Use(middleware.Auth(srv.JwtService)) + + // Model Pricing: GET returns merged view, PUT sets tenant override, DELETE removes override + pricing := api.Group("/pricing") + { + pricing.GET("/models", handleGetPricingModels(srv)) + pricing.PUT("/models", middleware.RequireRoles("owner", "finance"), handleSetPricingModel(srv)) + pricing.DELETE("/models/:model", middleware.RequireRoles("owner", "finance"), handleDeletePricingModel(srv)) + } + + // Pricing Groups CRUD + groups := api.Group("/pricing/groups") + { + groups.GET("", handleListPricingGroups(srv)) + groups.POST("", middleware.RequireRoles("owner", "finance"), handleCreatePricingGroup(srv)) + groups.PUT("/:id", middleware.RequireRoles("owner", "finance"), handleUpdatePricingGroup(srv)) + groups.DELETE("/:id", middleware.RequireRoles("owner", "finance"), handleDeletePricingGroup(srv)) + } + + // Quota + quota := api.Group("/quota") + { + quota.GET("/me", handleGetQuotaMe(srv)) + quota.POST("/topup", middleware.RequireRoles("owner", "finance"), handleQuotaTopup(srv)) + } + + // Redemptions + redemptions := api.Group("/redemptions") + { + redemptions.GET("", middleware.RequireRoles("owner", "finance"), handleListRedemptions(srv)) + redemptions.POST("", middleware.RequireRoles("owner", "finance"), handleCreateRedemptions(srv)) + redemptions.POST("/redeem", handleRedeem(srv)) + } + + // Usage Logs + api.GET("/usage", handleGetUsage(srv)) +} + +// ========== Model Pricing ========== +// GET /api/pricing/models — returns all models with global + tenant overrides merged + +type pricingModelItem struct { + Model string `json:"model"` + ModelRatio float64 `json:"model_ratio"` + CompletionRatio float64 `json:"completion_ratio"` + ModelType string `json:"model_type"` + Enabled bool `json:"enabled"` + IsOverride bool `json:"is_override"` +} + +func handleGetPricingModels(srv *server.BillingServer) gin.HandlerFunc { + return func(c *gin.Context) { + info := tenant.MustFromContext(c.Request.Context()) + + globals, err := srv.ModelPricingRepo.List(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + overrides, err := srv.TenantPricingRepo.ListByTenant(c.Request.Context(), info.TenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + overrideMap := make(map[string]*repo.TenantPricing) + for i := range overrides { + overrideMap[overrides[i].Model] = &overrides[i] + } + + items := make([]pricingModelItem, 0, len(globals)) + for _, g := range globals { + item := pricingModelItem{ + Model: g.Model, + ModelRatio: g.ModelRatio, + CompletionRatio: g.CompletionRatio, + ModelType: g.ModelType, + Enabled: g.Enabled, + IsOverride: false, + } + if o, ok := overrideMap[g.Model]; ok { + item.IsOverride = true + if o.ModelRatio != nil { + item.ModelRatio = *o.ModelRatio + } + if o.CompletionRatio != nil { + item.CompletionRatio = *o.CompletionRatio + } + if o.Enabled != nil { + item.Enabled = *o.Enabled + } + } + items = append(items, item) + } + + c.JSON(http.StatusOK, gin.H{"items": items}) + } +} + +// PUT /api/pricing/models — set tenant override +type setPricingModelRequest struct { + Model string `json:"model" binding:"required"` + ModelRatio float64 `json:"model_ratio"` + CompletionRatio float64 `json:"completion_ratio"` +} + +func handleSetPricingModel(srv *server.BillingServer) gin.HandlerFunc { + return func(c *gin.Context) { + info := tenant.MustFromContext(c.Request.Context()) + var req setPricingModelRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + item := &repo.TenantPricing{ + TenantID: info.TenantID, + Model: req.Model, + ModelRatio: &req.ModelRatio, + CompletionRatio: &req.CompletionRatio, + } + if err := srv.TenantPricingRepo.Upsert(c.Request.Context(), item); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + } +} + +// DELETE /api/pricing/models/:model — remove tenant override +func handleDeletePricingModel(srv *server.BillingServer) gin.HandlerFunc { + return func(c *gin.Context) { + info := tenant.MustFromContext(c.Request.Context()) + model := c.Param("model") + if err := srv.TenantPricingRepo.Delete(c.Request.Context(), info.TenantID, model); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + } +} + +// ========== Pricing Groups ========== + +func handleListPricingGroups(srv *server.BillingServer) gin.HandlerFunc { + return func(c *gin.Context) { + info := tenant.MustFromContext(c.Request.Context()) + items, err := srv.PricingGroupRepo.ListByTenant(c.Request.Context(), info.TenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"items": items}) + } +} + +type createPricingGroupRequest struct { + Name string `json:"name" binding:"required"` + DisplayName string `json:"display_name"` + Ratio float64 `json:"ratio"` + Description string `json:"description"` +} + +func handleCreatePricingGroup(srv *server.BillingServer) gin.HandlerFunc { + return func(c *gin.Context) { + info := tenant.MustFromContext(c.Request.Context()) + var req createPricingGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + item := &repo.PricingGroup{ + TenantID: info.TenantID, + Name: req.Name, + DisplayName: req.DisplayName, + Ratio: req.Ratio, + Description: req.Description, + } + if item.Ratio == 0 { + item.Ratio = 1.0 + } + if item.DisplayName == "" { + item.DisplayName = req.Name + } + + if err := srv.PricingGroupRepo.Create(c.Request.Context(), item); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, item) + } +} + +func handleUpdatePricingGroup(srv *server.BillingServer) gin.HandlerFunc { + return func(c *gin.Context) { + id, _ := strconv.ParseInt(c.Param("id"), 10, 64) + var updates map[string]interface{} + if err := c.ShouldBindJSON(&updates); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + delete(updates, "id") + delete(updates, "tenant_id") + delete(updates, "name") // name is immutable + if err := srv.PricingGroupRepo.Update(c.Request.Context(), id, updates); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + } +} + +func handleDeletePricingGroup(srv *server.BillingServer) gin.HandlerFunc { + return func(c *gin.Context) { + id, _ := strconv.ParseInt(c.Param("id"), 10, 64) + if err := srv.PricingGroupRepo.Delete(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + } +} + +// ========== Quota ========== + +// GET /api/quota/me +func handleGetQuotaMe(srv *server.BillingServer) gin.HandlerFunc { + return func(c *gin.Context) { + info := tenant.MustFromContext(c.Request.Context()) + + uq, err := srv.UserQuotaRepo.GetQuota(c.Request.Context(), info.TenantID, info.UserID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + groupRatio := 1.0 + if uq.PricingGroup != "" { + pg, err := srv.PricingGroupRepo.GetByName(c.Request.Context(), info.TenantID, uq.PricingGroup) + if err == nil && pg != nil { + groupRatio = pg.Ratio + } + } + + c.JSON(http.StatusOK, gin.H{ + "quota": uq.Quota, + "used_quota": uq.UsedQuota, + "pricing_group": uq.PricingGroup, + "group_ratio": groupRatio, + }) + } +} + +// POST /api/quota/topup +type quotaTopupRequest struct { + UserID int64 `json:"user_id" binding:"required"` + Amount int64 `json:"amount" binding:"required"` + Remark string `json:"remark"` +} + +func handleQuotaTopup(srv *server.BillingServer) gin.HandlerFunc { + return func(c *gin.Context) { + info := tenant.MustFromContext(c.Request.Context()) + var req quotaTopupRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := srv.QuotaService.AdminAdjust(c.Request.Context(), info.TenantID, req.UserID, req.Amount, req.Remark); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Get new quota + uq, err := srv.UserQuotaRepo.GetQuota(c.Request.Context(), info.TenantID, req.UserID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"new_quota": uq.Quota}) + } +} + +// ========== Redemptions ========== + +func handleListRedemptions(srv *server.BillingServer) gin.HandlerFunc { + return func(c *gin.Context) { + info := tenant.MustFromContext(c.Request.Context()) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + items, total, err := srv.RedemptionRepo.ListByTenant(c.Request.Context(), info.TenantID, page, pageSize) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "items": items, + "pagination": gin.H{"page": page, "page_size": pageSize, "total": total}, + }) + } +} + +type createRedemptionsRequest struct { + Name string `json:"name" binding:"required"` + Quota int64 `json:"quota" binding:"required"` + Count int `json:"count" binding:"required,min=1,max=100"` + ExpiresAt string `json:"expires_at"` // YYYY-MM-DD or RFC3339 +} + +func handleCreateRedemptions(srv *server.BillingServer) gin.HandlerFunc { + return func(c *gin.Context) { + info := tenant.MustFromContext(c.Request.Context()) + var req createRedemptionsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var expiresAt *time.Time + if req.ExpiresAt != "" { + // Try date-only first, then RFC3339 + t, err := time.Parse("2006-01-02", req.ExpiresAt) + if err != nil { + t, err = time.Parse(time.RFC3339, req.ExpiresAt) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid expires_at format"}) + return + } + } + expiresAt = &t + } + + items, err := srv.RedemptionRepo.BatchCreate(c.Request.Context(), info.TenantID, req.Name, req.Quota, req.Count, expiresAt, info.UserID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Return codes list per contract + codes := make([]string, len(items)) + for i, item := range items { + codes[i] = item.Code + } + c.JSON(http.StatusOK, gin.H{"codes": codes}) + } +} + +type redeemRequest struct { + Code string `json:"code" binding:"required"` +} + +func handleRedeem(srv *server.BillingServer) gin.HandlerFunc { + return func(c *gin.Context) { + info := tenant.MustFromContext(c.Request.Context()) + var req redeemRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + result, newQuota, err := srv.RedemptionRepo.Redeem(c.Request.Context(), req.Code, info.UserID, info.TenantID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Sync to Redis + srv.QuotaService.AdminAdjust(c.Request.Context(), info.TenantID, info.UserID, result.Quota, "redemption:"+req.Code) + + c.JSON(http.StatusOK, gin.H{ + "quota_added": result.Quota, + "new_quota": newQuota, + }) + } +} + +// ========== Usage ========== + +// GET /api/usage — query usage logs +func handleGetUsage(srv *server.BillingServer) gin.HandlerFunc { + return func(c *gin.Context) { + info := tenant.MustFromContext(c.Request.Context()) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + // For now, return quota transactions as usage proxy + // TODO: query ClickHouse usage_logs when available + items, total, err := srv.QuotaTxRepo.ListByUser(c.Request.Context(), info.TenantID, info.UserID, page, pageSize) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "items": items, + "summary": gin.H{ + "total_requests": total, + "total_tokens": 0, + "total_cost": 0, + }, + "pagination": gin.H{"page": page, "page_size": pageSize, "total": total}, + }) + } +} diff --git a/internal/billing/repo/model_pricing.go b/internal/billing/repo/model_pricing.go new file mode 100644 index 0000000..b80e101 --- /dev/null +++ b/internal/billing/repo/model_pricing.go @@ -0,0 +1,58 @@ +package repo + +import ( + "context" + "time" + + "gorm.io/gorm" +) + +// ModelPricing 全局模型定价(平台级) +type ModelPricing struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + Model string `gorm:"size:64;uniqueIndex;not null" json:"model"` + ModelRatio float64 `gorm:"type:decimal(10,4);not null;default:1.0" json:"model_ratio"` + CompletionRatio float64 `gorm:"type:decimal(10,4);not null;default:1.0" json:"completion_ratio"` + ModelType string `gorm:"size:16;default:text" json:"model_type"` + Enabled bool `gorm:"default:true" json:"enabled"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +func (ModelPricing) TableName() string { return "model_pricing" } + +// ModelPricingRepo 全局模型定价数据访问 +type ModelPricingRepo struct { + db *gorm.DB +} + +func NewModelPricingRepo(db *gorm.DB) *ModelPricingRepo { + return &ModelPricingRepo{db: db} +} + +func (r *ModelPricingRepo) List(ctx context.Context) ([]ModelPricing, error) { + var items []ModelPricing + err := r.db.WithContext(ctx).Order("model ASC").Find(&items).Error + return items, err +} + +func (r *ModelPricingRepo) GetByModel(ctx context.Context, model string) (*ModelPricing, error) { + var item ModelPricing + err := r.db.WithContext(ctx).Where("model = ?", model).First(&item).Error + if err != nil { + return nil, err + } + return &item, nil +} + +func (r *ModelPricingRepo) Create(ctx context.Context, item *ModelPricing) error { + return r.db.WithContext(ctx).Create(item).Error +} + +func (r *ModelPricingRepo) Update(ctx context.Context, id int64, updates map[string]interface{}) error { + return r.db.WithContext(ctx).Model(&ModelPricing{}).Where("id = ?", id).Updates(updates).Error +} + +func (r *ModelPricingRepo) Delete(ctx context.Context, id int64) error { + return r.db.WithContext(ctx).Delete(&ModelPricing{}, id).Error +} diff --git a/internal/billing/repo/pricing_group.go b/internal/billing/repo/pricing_group.go new file mode 100644 index 0000000..6dd94d5 --- /dev/null +++ b/internal/billing/repo/pricing_group.go @@ -0,0 +1,57 @@ +package repo + +import ( + "context" + "time" + + "gorm.io/gorm" +) + +// PricingGroup 定价分组(不同用户组享有不同倍率) +type PricingGroup struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + TenantID int64 `gorm:"not null;uniqueIndex:uk_tenant_group" json:"tenant_id"` + Name string `gorm:"size:32;not null;uniqueIndex:uk_tenant_group" json:"name"` + DisplayName string `gorm:"size:64" json:"display_name"` + Ratio float64 `gorm:"type:decimal(10,4);not null;default:1.0" json:"ratio"` + Description string `gorm:"size:255" json:"description"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` +} + +func (PricingGroup) TableName() string { return "pricing_groups" } + +// PricingGroupRepo 定价分组数据访问 +type PricingGroupRepo struct { + db *gorm.DB +} + +func NewPricingGroupRepo(db *gorm.DB) *PricingGroupRepo { + return &PricingGroupRepo{db: db} +} + +func (r *PricingGroupRepo) ListByTenant(ctx context.Context, tenantID int64) ([]PricingGroup, error) { + var items []PricingGroup + err := r.db.WithContext(ctx).Where("tenant_id = ?", tenantID).Order("name ASC").Find(&items).Error + return items, err +} + +func (r *PricingGroupRepo) GetByName(ctx context.Context, tenantID int64, name string) (*PricingGroup, error) { + var item PricingGroup + err := r.db.WithContext(ctx).Where("tenant_id = ? AND name = ?", tenantID, name).First(&item).Error + if err != nil { + return nil, err + } + return &item, nil +} + +func (r *PricingGroupRepo) Create(ctx context.Context, item *PricingGroup) error { + return r.db.WithContext(ctx).Create(item).Error +} + +func (r *PricingGroupRepo) Update(ctx context.Context, id int64, updates map[string]interface{}) error { + return r.db.WithContext(ctx).Model(&PricingGroup{}).Where("id = ?", id).Updates(updates).Error +} + +func (r *PricingGroupRepo) Delete(ctx context.Context, id int64) error { + return r.db.WithContext(ctx).Delete(&PricingGroup{}, id).Error +} diff --git a/internal/billing/repo/quota_transaction.go b/internal/billing/repo/quota_transaction.go new file mode 100644 index 0000000..af4aa2e --- /dev/null +++ b/internal/billing/repo/quota_transaction.go @@ -0,0 +1,55 @@ +package repo + +import ( + "context" + "time" + + "gorm.io/gorm" +) + +// QuotaTransaction 额度变动记录 +type QuotaTransaction struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + TenantID int64 `gorm:"not null;index:idx_tenant_user" json:"tenant_id"` + UserID int64 `gorm:"not null;index:idx_tenant_user" json:"user_id"` + Type string `gorm:"size:16;not null" json:"type"` // topup/consume/refund/redeem/admin_adjust + Amount int64 `gorm:"not null" json:"amount"` // positive=credit negative=debit + BalanceAfter int64 `gorm:"not null" json:"balance_after"` + ReferenceType string `gorm:"size:32" json:"reference_type"` // redemption/stripe/request/admin + ReferenceID string `gorm:"size:64" json:"reference_id"` + Remark string `gorm:"size:255" json:"remark"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` +} + +func (QuotaTransaction) TableName() string { return "quota_transactions" } + +// QuotaTransactionRepo 额度变动记录数据访问 +type QuotaTransactionRepo struct { + db *gorm.DB +} + +func NewQuotaTransactionRepo(db *gorm.DB) *QuotaTransactionRepo { + return &QuotaTransactionRepo{db: db} +} + +func (r *QuotaTransactionRepo) Create(ctx context.Context, item *QuotaTransaction) error { + return r.db.WithContext(ctx).Create(item).Error +} + +func (r *QuotaTransactionRepo) ListByUser(ctx context.Context, tenantID, userID int64, page, pageSize int) ([]QuotaTransaction, int64, error) { + var items []QuotaTransaction + var total int64 + db := r.db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID) + db.Model(&QuotaTransaction{}).Count(&total) + err := db.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&items).Error + return items, total, err +} + +func (r *QuotaTransactionRepo) ListByTenant(ctx context.Context, tenantID int64, page, pageSize int) ([]QuotaTransaction, int64, error) { + var items []QuotaTransaction + var total int64 + db := r.db.WithContext(ctx).Where("tenant_id = ?", tenantID) + db.Model(&QuotaTransaction{}).Count(&total) + err := db.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&items).Error + return items, total, err +} diff --git a/internal/billing/repo/redemption.go b/internal/billing/repo/redemption.go new file mode 100644 index 0000000..408de82 --- /dev/null +++ b/internal/billing/repo/redemption.go @@ -0,0 +1,146 @@ +package repo + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "gorm.io/gorm" +) + +// Redemption 兑换码 +type Redemption struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + TenantID int64 `gorm:"not null;index" json:"tenant_id"` + Code string `gorm:"size:32;uniqueIndex;not null" json:"code"` + Name string `gorm:"size:64" json:"name"` + Quota int64 `gorm:"not null" json:"quota"` + Status int8 `gorm:"default:1" json:"status"` // 1=active 2=redeemed 3=disabled + RedeemedBy *int64 `json:"redeemed_by"` + RedeemedAt *time.Time `json:"redeemed_at"` + ExpiresAt *time.Time `json:"expires_at"` + CreatedBy *int64 `json:"created_by"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` +} + +func (Redemption) TableName() string { return "redemptions" } + +// RedemptionRepo 兑换码数据访问 +type RedemptionRepo struct { + db *gorm.DB +} + +func NewRedemptionRepo(db *gorm.DB) *RedemptionRepo { + return &RedemptionRepo{db: db} +} + +// BatchCreate 批量创建兑换码 +func (r *RedemptionRepo) BatchCreate(ctx context.Context, tenantID int64, name string, quota int64, count int, expiresAt *time.Time, createdBy int64) ([]Redemption, error) { + items := make([]Redemption, count) + for i := 0; i < count; i++ { + items[i] = Redemption{ + TenantID: tenantID, + Code: generateRedemptionCode(), + Name: name, + Quota: quota, + Status: 1, + ExpiresAt: expiresAt, + CreatedBy: &createdBy, + } + } + if err := r.db.WithContext(ctx).Create(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +func (r *RedemptionRepo) ListByTenant(ctx context.Context, tenantID int64, page, pageSize int) ([]Redemption, int64, error) { + var items []Redemption + var total int64 + db := r.db.WithContext(ctx).Where("tenant_id = ?", tenantID) + db.Model(&Redemption{}).Count(&total) + err := db.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&items).Error + return items, total, err +} + +func (r *RedemptionRepo) GetByCode(ctx context.Context, code string) (*Redemption, error) { + var item Redemption + err := r.db.WithContext(ctx).Where("code = ?", code).First(&item).Error + if err != nil { + return nil, err + } + return &item, nil +} + +// Redeem 兑换码使用(事务:检查 + 标记已用 + 增加额度) +func (r *RedemptionRepo) Redeem(ctx context.Context, code string, userID, tenantID int64) (*Redemption, int64, error) { + var redemption Redemption + var newQuota int64 + + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("code = ?", code).First(&redemption).Error; err != nil { + return fmt.Errorf("redemption not found") + } + + if redemption.TenantID != tenantID { + return fmt.Errorf("redemption not found") + } + + if redemption.Status != 1 { + return fmt.Errorf("redemption already used or disabled") + } + + if redemption.ExpiresAt != nil && redemption.ExpiresAt.Before(time.Now()) { + return fmt.Errorf("redemption expired") + } + + now := time.Now() + redemption.RedeemedBy = &userID + redemption.RedeemedAt = &now + redemption.Status = 2 // redeemed + if err := tx.Save(&redemption).Error; err != nil { + return err + } + + // 增加用户额度 + if err := tx.Exec( + "UPDATE users SET quota = quota + ? WHERE id = ? AND tenant_id = ?", + redemption.Quota, userID, tenantID, + ).Error; err != nil { + return err + } + + // 查询新额度 + if err := tx.Table("users").Select("quota").Where("id = ? AND tenant_id = ?", userID, tenantID).Scan(&newQuota).Error; err != nil { + return err + } + + // 记录额度变动 + if err := tx.Create(&QuotaTransaction{ + TenantID: tenantID, + UserID: userID, + Type: "redeem", + Amount: redemption.Quota, + BalanceAfter: newQuota, + ReferenceType: "redemption", + ReferenceID: redemption.Code, + Remark: fmt.Sprintf("redeemed code: %s", redemption.Name), + }).Error; err != nil { + return err + } + + return nil + }) + if err != nil { + return nil, 0, err + } + return &redemption, newQuota, nil +} + +func generateRedemptionCode() string { + b := make([]byte, 16) + rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/internal/billing/repo/tenant_pricing.go b/internal/billing/repo/tenant_pricing.go new file mode 100644 index 0000000..4823f9f --- /dev/null +++ b/internal/billing/repo/tenant_pricing.go @@ -0,0 +1,92 @@ +package repo + +import ( + "context" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// TenantPricing 租户自定义定价覆盖 +type TenantPricing struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + TenantID int64 `gorm:"not null;uniqueIndex:idx_tenant_model" json:"tenant_id"` + Model string `gorm:"size:64;not null;uniqueIndex:idx_tenant_model" json:"model"` + ModelRatio *float64 `gorm:"type:decimal(10,4)" json:"model_ratio"` + CompletionRatio *float64 `gorm:"type:decimal(10,4)" json:"completion_ratio"` + Enabled *bool `json:"enabled"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +func (TenantPricing) TableName() string { return "tenant_pricing" } + +// TenantPricingRepo 租户定价数据访问 +type TenantPricingRepo struct { + db *gorm.DB +} + +func NewTenantPricingRepo(db *gorm.DB) *TenantPricingRepo { + return &TenantPricingRepo{db: db} +} + +func (r *TenantPricingRepo) ListByTenant(ctx context.Context, tenantID int64) ([]TenantPricing, error) { + var items []TenantPricing + err := r.db.WithContext(ctx).Where("tenant_id = ?", tenantID).Order("model ASC").Find(&items).Error + return items, err +} + +// Upsert 创建或更新租户定价 +func (r *TenantPricingRepo) Upsert(ctx context.Context, item *TenantPricing) error { + return r.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "tenant_id"}, {Name: "model"}}, + DoUpdates: clause.AssignmentColumns([]string{"model_ratio", "completion_ratio", "enabled", "updated_at"}), + }).Create(item).Error +} + +func (r *TenantPricingRepo) Delete(ctx context.Context, tenantID int64, model string) error { + return r.db.WithContext(ctx).Where("tenant_id = ? AND model = ?", tenantID, model).Delete(&TenantPricing{}).Error +} + +// EffectivePricing 有效定价结果 +type EffectivePricing struct { + Model string `json:"model"` + ModelRatio float64 `json:"model_ratio"` + CompletionRatio float64 `json:"completion_ratio"` + Enabled bool `json:"enabled"` +} + +// GetEffective 获取租户对某模型的有效定价(租户覆盖 > 全局默认) +func (r *TenantPricingRepo) GetEffective(ctx context.Context, tenantID int64, model string) (*EffectivePricing, error) { + // 先查租户覆盖 + var tp TenantPricing + err := r.db.WithContext(ctx).Where("tenant_id = ? AND model = ?", tenantID, model).First(&tp).Error + if err == nil { + result := &EffectivePricing{Model: model, ModelRatio: 1.0, CompletionRatio: 1.0, Enabled: true} + if tp.ModelRatio != nil { + result.ModelRatio = *tp.ModelRatio + } + if tp.CompletionRatio != nil { + result.CompletionRatio = *tp.CompletionRatio + } + if tp.Enabled != nil { + result.Enabled = *tp.Enabled + } + return result, nil + } + + // 回落到全局 + var mp ModelPricing + err = r.db.WithContext(ctx).Where("model = ?", model).First(&mp).Error + if err != nil { + // 没有任何定价记录,使用默认值 + return &EffectivePricing{Model: model, ModelRatio: 1.0, CompletionRatio: 1.0, Enabled: true}, nil + } + return &EffectivePricing{ + Model: model, + ModelRatio: mp.ModelRatio, + CompletionRatio: mp.CompletionRatio, + Enabled: mp.Enabled, + }, nil +} diff --git a/internal/billing/repo/user_quota.go b/internal/billing/repo/user_quota.go new file mode 100644 index 0000000..10148a7 --- /dev/null +++ b/internal/billing/repo/user_quota.go @@ -0,0 +1,72 @@ +package repo + +import ( + "context" + + "gorm.io/gorm" +) + +// UserQuota 用户额度视图(操作 users 表的 quota 相关字段) +type UserQuota struct { + UserID int64 `json:"user_id"` + TenantID int64 `json:"tenant_id"` + Quota int64 `json:"quota"` + UsedQuota int64 `json:"used_quota"` + PricingGroup string `json:"pricing_group"` +} + +// UserQuotaRepo 用户额度数据访问(操作 users 表) +type UserQuotaRepo struct { + db *gorm.DB +} + +func NewUserQuotaRepo(db *gorm.DB) *UserQuotaRepo { + return &UserQuotaRepo{db: db} +} + +// GetQuota 获取用户额度信息 +func (r *UserQuotaRepo) GetQuota(ctx context.Context, tenantID, userID int64) (*UserQuota, error) { + var uq UserQuota + err := r.db.WithContext(ctx). + Table("users"). + Select("id as user_id, tenant_id, quota, used_quota, pricing_group"). + Where("id = ? AND tenant_id = ?", userID, tenantID). + Scan(&uq).Error + if err != nil { + return nil, err + } + return &uq, nil +} + +// AdjustQuota 调整用户额度 +func (r *UserQuotaRepo) AdjustQuota(ctx context.Context, tenantID, userID, amount int64) error { + return r.db.WithContext(ctx). + Exec("UPDATE users SET quota = quota + ? WHERE id = ? AND tenant_id = ?", amount, userID, tenantID). + Error +} + +// SetPricingGroup 设置用户定价分组 +func (r *UserQuotaRepo) SetPricingGroup(ctx context.Context, tenantID, userID int64, group string) error { + return r.db.WithContext(ctx). + Table("users"). + Where("id = ? AND tenant_id = ?", userID, tenantID). + Update("pricing_group", group).Error +} + +// IncrUsedQuota 增加已使用额度 +func (r *UserQuotaRepo) IncrUsedQuota(ctx context.Context, tenantID, userID, amount int64) error { + return r.db.WithContext(ctx). + Exec("UPDATE users SET used_quota = used_quota + ? WHERE id = ? AND tenant_id = ?", amount, userID, tenantID). + Error +} + +// GetPricingGroup 获取用户定价分组名 +func (r *UserQuotaRepo) GetPricingGroup(ctx context.Context, tenantID, userID int64) (string, error) { + var group string + err := r.db.WithContext(ctx). + Table("users"). + Select("pricing_group"). + Where("id = ? AND tenant_id = ?", userID, tenantID). + Scan(&group).Error + return group, err +} diff --git a/internal/billing/server/server.go b/internal/billing/server/server.go new file mode 100644 index 0000000..d42e509 --- /dev/null +++ b/internal/billing/server/server.go @@ -0,0 +1,81 @@ +package server + +import ( + authService "github.com/LayFz/maas-cloud-api/internal/auth/service" + "github.com/LayFz/maas-cloud-api/internal/billing/repo" + "github.com/LayFz/maas-cloud-api/internal/billing/service" + "github.com/LayFz/maas-cloud-api/internal/pkg/config" + "github.com/LayFz/maas-cloud-api/internal/pkg/database" + "github.com/LayFz/maas-cloud-api/internal/pkg/mq" + "github.com/redis/go-redis/v9" + "go.uber.org/zap" +) + +// BillingServer 计费服务 +type BillingServer struct { + Cfg *config.Config + Logger *zap.Logger + Producer *mq.Producer + JwtService *authService.JWTService + InternalSecret string + + // Repos + ModelPricingRepo *repo.ModelPricingRepo + TenantPricingRepo *repo.TenantPricingRepo + PricingGroupRepo *repo.PricingGroupRepo + RedemptionRepo *repo.RedemptionRepo + QuotaTxRepo *repo.QuotaTransactionRepo + UserQuotaRepo *repo.UserQuotaRepo + + // Services + PricingService *service.PricingService + QuotaService *service.QuotaService + CHWriter *service.ClickHouseWriter + QuotaPersister *service.QuotaPersister +} + +func NewBillingServer(cfg *config.Config, producer *mq.Producer, rdb redis.UniversalClient, logger *zap.Logger) *BillingServer { + db := database.RawDB() + jwtSvc := authService.NewJWTService(cfg.JWT.Secret, cfg.JWT.ExpireHour) + + // Repos + modelPricingRepo := repo.NewModelPricingRepo(db) + tenantPricingRepo := repo.NewTenantPricingRepo(db) + pricingGroupRepo := repo.NewPricingGroupRepo(db) + redemptionRepo := repo.NewRedemptionRepo(db) + quotaTxRepo := repo.NewQuotaTransactionRepo(db) + userQuotaRepo := repo.NewUserQuotaRepo(db) + + // Services + pricingSvc := service.NewPricingService(tenantPricingRepo, pricingGroupRepo) + quotaSvc := service.NewQuotaService(rdb, userQuotaRepo, quotaTxRepo) + persister := service.NewQuotaPersister(rdb, userQuotaRepo, quotaSvc, logger) + + chWriter, err := service.NewClickHouseWriter(&cfg.Click, logger) + if err != nil { + logger.Warn("clickhouse writer init failed, using noop", zap.Error(err)) + chWriter = &service.ClickHouseWriter{} + } + + _ = modelPricingRepo // used in handler + + return &BillingServer{ + Cfg: cfg, + Logger: logger, + Producer: producer, + JwtService: jwtSvc, + InternalSecret: cfg.InternalSecret, + + ModelPricingRepo: modelPricingRepo, + TenantPricingRepo: tenantPricingRepo, + PricingGroupRepo: pricingGroupRepo, + RedemptionRepo: redemptionRepo, + QuotaTxRepo: quotaTxRepo, + UserQuotaRepo: userQuotaRepo, + + PricingService: pricingSvc, + QuotaService: quotaSvc, + CHWriter: chWriter, + QuotaPersister: persister, + } +} diff --git a/internal/billing/service/clickhouse.go b/internal/billing/service/clickhouse.go new file mode 100644 index 0000000..b789d00 --- /dev/null +++ b/internal/billing/service/clickhouse.go @@ -0,0 +1,148 @@ +package service + +import ( + "context" + "database/sql" + "fmt" + "sync" + "time" + + "github.com/LayFz/maas-cloud-api/internal/pkg/config" + "go.uber.org/zap" +) + +// UsageRecord ClickHouse 用量记录 +type UsageRecord struct { + TraceID string + TenantID int64 + UserID int64 + Model string + PromptTokens int64 + CompletionTokens int64 + TotalTokens int64 + QuotaCost int64 + Duration int64 // ms + CreatedAt time.Time +} + +// ClickHouseWriter 批量写入 ClickHouse +type ClickHouseWriter struct { + db *sql.DB + logger *zap.Logger + mu sync.Mutex + buffer []UsageRecord + maxBatch int + ticker *time.Ticker +} + +// NewClickHouseWriter 创建 ClickHouse 批量写入器 +func NewClickHouseWriter(cfg *config.ClickConfig, logger *zap.Logger) (*ClickHouseWriter, error) { + if cfg.Addr == "" { + logger.Warn("clickhouse not configured, using noop writer") + return &ClickHouseWriter{logger: logger, maxBatch: 100}, nil + } + + dsn := fmt.Sprintf("clickhouse://%s/%s?username=%s&password=%s", + cfg.Addr, cfg.Database, cfg.Username, cfg.Password) + + db, err := sql.Open("clickhouse", dsn) + if err != nil { + return nil, fmt.Errorf("open clickhouse: %w", err) + } + + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("ping clickhouse: %w", err) + } + + return &ClickHouseWriter{ + db: db, + logger: logger, + buffer: make([]UsageRecord, 0, 100), + maxBatch: 100, + }, nil +} + +// Write 添加记录到缓冲区 +func (w *ClickHouseWriter) Write(record UsageRecord) { + w.mu.Lock() + defer w.mu.Unlock() + w.buffer = append(w.buffer, record) + if len(w.buffer) >= w.maxBatch { + w.flushLocked(context.Background()) + } +} + +// Run 启动定期刷写 +func (w *ClickHouseWriter) Run(ctx context.Context) { + w.ticker = time.NewTicker(5 * time.Second) + defer w.ticker.Stop() + + for { + select { + case <-ctx.Done(): + w.Flush(context.Background()) + return + case <-w.ticker.C: + w.Flush(ctx) + } + } +} + +// Flush 刷写缓冲区 +func (w *ClickHouseWriter) Flush(ctx context.Context) { + w.mu.Lock() + defer w.mu.Unlock() + w.flushLocked(ctx) +} + +func (w *ClickHouseWriter) flushLocked(ctx context.Context) { + if len(w.buffer) == 0 { + return + } + + if w.db == nil { + w.logger.Debug("clickhouse noop: discarding buffer", zap.Int("count", len(w.buffer))) + w.buffer = w.buffer[:0] + return + } + + tx, err := w.db.BeginTx(ctx, nil) + if err != nil { + w.logger.Error("clickhouse begin tx", zap.Error(err)) + return + } + + stmt, err := tx.PrepareContext(ctx, + "INSERT INTO usage_logs (trace_id, tenant_id, user_id, model, prompt_tokens, completion_tokens, total_tokens, quota_cost, duration, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") + if err != nil { + tx.Rollback() + w.logger.Error("clickhouse prepare", zap.Error(err)) + return + } + defer stmt.Close() + + for _, r := range w.buffer { + if _, err := stmt.ExecContext(ctx, r.TraceID, r.TenantID, r.UserID, r.Model, + r.PromptTokens, r.CompletionTokens, r.TotalTokens, r.QuotaCost, r.Duration, r.CreatedAt); err != nil { + w.logger.Error("clickhouse insert", zap.Error(err)) + tx.Rollback() + return + } + } + + if err := tx.Commit(); err != nil { + w.logger.Error("clickhouse commit", zap.Error(err)) + return + } + + w.logger.Debug("clickhouse flushed", zap.Int("count", len(w.buffer))) + w.buffer = w.buffer[:0] +} + +// Close 关闭连接 +func (w *ClickHouseWriter) Close() error { + if w.db != nil { + return w.db.Close() + } + return nil +} diff --git a/internal/billing/service/pricing.go b/internal/billing/service/pricing.go new file mode 100644 index 0000000..70050c5 --- /dev/null +++ b/internal/billing/service/pricing.go @@ -0,0 +1,105 @@ +package service + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/LayFz/maas-cloud-api/internal/billing/repo" +) + +// PricingRatios 有效定价倍率 +type PricingRatios struct { + ModelRatio float64 + CompletionRatio float64 + GroupRatio float64 +} + +type pricingCacheEntry struct { + pricing *repo.EffectivePricing + expiresAt time.Time +} + +// PricingService 定价服务 +type PricingService struct { + tenantPricingRepo *repo.TenantPricingRepo + pricingGroupRepo *repo.PricingGroupRepo + + mu sync.RWMutex + cache map[string]*pricingCacheEntry // key: "tenantID:model" + ttl time.Duration +} + +func NewPricingService(tpr *repo.TenantPricingRepo, pgr *repo.PricingGroupRepo) *PricingService { + return &PricingService{ + tenantPricingRepo: tpr, + pricingGroupRepo: pgr, + cache: make(map[string]*pricingCacheEntry), + ttl: 60 * time.Second, + } +} + +// GetEffectivePricing 获取有效定价倍率 +func (s *PricingService) GetEffectivePricing(ctx context.Context, tenantID int64, model, userGroup string) (*PricingRatios, error) { + ep, err := s.getCachedPricing(ctx, tenantID, model) + if err != nil { + return nil, err + } + + if !ep.Enabled { + return nil, fmt.Errorf("model %s is disabled", model) + } + + ratios := &PricingRatios{ + ModelRatio: ep.ModelRatio, + CompletionRatio: ep.CompletionRatio, + GroupRatio: 1.0, + } + + // 查用户分组倍率 + if userGroup != "" { + pg, err := s.pricingGroupRepo.GetByName(ctx, tenantID, userGroup) + if err == nil && pg != nil { + ratios.GroupRatio = pg.Ratio + } + } + + return ratios, nil +} + +// CalculateCost 计算实际消耗额度 +func (s *PricingService) CalculateCost(ratios *PricingRatios, promptTokens, completionTokens int64) int64 { + promptCost := float64(promptTokens) * ratios.ModelRatio * ratios.GroupRatio + completionCost := float64(completionTokens) * ratios.ModelRatio * ratios.CompletionRatio * ratios.GroupRatio + return int64(promptCost + completionCost) +} + +// EstimatePreDeductionCost 预估预扣额度(1.2x 安全边际) +func (s *PricingService) EstimatePreDeductionCost(ratios *PricingRatios, estimatedPromptTokens int64) int64 { + // 假设 completion 和 prompt 等量,乘以 1.2 安全边际 + estimated := float64(estimatedPromptTokens) * ratios.ModelRatio * ratios.GroupRatio * 2.0 * 1.2 + return int64(estimated) +} + +func (s *PricingService) getCachedPricing(ctx context.Context, tenantID int64, model string) (*repo.EffectivePricing, error) { + key := fmt.Sprintf("%d:%s", tenantID, model) + + s.mu.RLock() + if entry, ok := s.cache[key]; ok && time.Now().Before(entry.expiresAt) { + s.mu.RUnlock() + return entry.pricing, nil + } + s.mu.RUnlock() + + ep, err := s.tenantPricingRepo.GetEffective(ctx, tenantID, model) + if err != nil { + return nil, err + } + + s.mu.Lock() + s.cache[key] = &pricingCacheEntry{pricing: ep, expiresAt: time.Now().Add(s.ttl)} + s.mu.Unlock() + + return ep, nil +} diff --git a/internal/billing/service/quota.go b/internal/billing/service/quota.go new file mode 100644 index 0000000..6e6c56c --- /dev/null +++ b/internal/billing/service/quota.go @@ -0,0 +1,209 @@ +package service + +import ( + "context" + "fmt" + "strconv" + + "github.com/LayFz/maas-cloud-api/internal/billing/repo" + "github.com/redis/go-redis/v9" +) + +// Lua scripts for atomic operations + +// preDeductScript: check balance >= amount, then decrby. Returns remaining balance or -1 if insufficient. +const preDeductScript = ` +local balance = redis.call('GET', KEYS[1]) +if not balance then return -2 end +balance = tonumber(balance) +local amount = tonumber(ARGV[1]) +if balance < 0 then return balance end +if balance < amount then return -1 end +redis.call('DECRBY', KEYS[1], amount) +redis.call('SET', KEYS[2], ARGV[1], 'EX', 600) +return balance - amount +` + +// settleScript: get pre-deducted amount, refund diff, clean up +const settleScript = ` +local preDeducted = redis.call('GET', KEYS[2]) +if not preDeducted then return 0 end +preDeducted = tonumber(preDeducted) +local actual = tonumber(ARGV[1]) +local diff = preDeducted - actual +if diff > 0 then + redis.call('INCRBY', KEYS[1], diff) +elseif diff < 0 then + redis.call('DECRBY', KEYS[1], -diff) +end +redis.call('INCRBY', KEYS[3], actual) +redis.call('DEL', KEYS[2]) +return diff +` + +// QuotaService 额度服务(Redis 为权威源) +type QuotaService struct { + rdb redis.UniversalClient + userQuotaRepo *repo.UserQuotaRepo + txRepo *repo.QuotaTransactionRepo +} + +func NewQuotaService(rdb redis.UniversalClient, uqr *repo.UserQuotaRepo, txr *repo.QuotaTransactionRepo) *QuotaService { + return &QuotaService{rdb: rdb, userQuotaRepo: uqr, txRepo: txr} +} + +func quotaKey(tenantID, userID int64) string { + return fmt.Sprintf("quota:user:%d:%d", tenantID, userID) +} + +func usedKey(tenantID, userID int64) string { + return fmt.Sprintf("quota:used:%d:%d", tenantID, userID) +} + +func preDeductKey(traceID string) string { + return fmt.Sprintf("quota:prededuct:%s", traceID) +} + +// EnsureLoaded 确保用户额度已加载到 Redis(懒加载) +func (s *QuotaService) EnsureLoaded(ctx context.Context, tenantID, userID int64) error { + key := quotaKey(tenantID, userID) + exists, err := s.rdb.Exists(ctx, key).Result() + if err != nil { + return err + } + if exists > 0 { + return nil + } + + uq, err := s.userQuotaRepo.GetQuota(ctx, tenantID, userID) + if err != nil { + return fmt.Errorf("load quota from mysql: %w", err) + } + + remaining := uq.Quota - uq.UsedQuota + if remaining < 0 { + remaining = 0 + } + + pipe := s.rdb.Pipeline() + pipe.Set(ctx, key, remaining, 0) + pipe.Set(ctx, usedKey(tenantID, userID), uq.UsedQuota, 0) + _, err = pipe.Exec(ctx) + return err +} + +// PreDeduct 预扣额度(原子操作) +// 返回预扣后剩余额度,如果额度不足返回错误 +func (s *QuotaService) PreDeduct(ctx context.Context, tenantID, userID int64, traceID string, amount int64) (int64, error) { + if err := s.EnsureLoaded(ctx, tenantID, userID); err != nil { + return 0, err + } + + result, err := s.rdb.Eval(ctx, preDeductScript, []string{ + quotaKey(tenantID, userID), + preDeductKey(traceID), + }, amount).Int64() + if err != nil { + return 0, fmt.Errorf("pre-deduct eval: %w", err) + } + + if result == -2 { + return 0, fmt.Errorf("quota key not found") + } + if result == -1 { + return 0, fmt.Errorf("insufficient quota") + } + + return result, nil +} + +// Settle 结算实际消耗(退还预扣差额) +func (s *QuotaService) Settle(ctx context.Context, tenantID, userID int64, traceID string, actualCost int64) error { + _, err := s.rdb.Eval(ctx, settleScript, []string{ + quotaKey(tenantID, userID), + preDeductKey(traceID), + usedKey(tenantID, userID), + }, actualCost).Int64() + if err != nil { + return fmt.Errorf("settle eval: %w", err) + } + + // 记录额度变动 + s.txRepo.Create(ctx, &repo.QuotaTransaction{ + TenantID: tenantID, + UserID: userID, + Amount: -actualCost, + Type: "consume", + ReferenceType: "request", + ReferenceID: traceID, + }) + + return nil +} + +// GetBalance 获取用户剩余额度 +func (s *QuotaService) GetBalance(ctx context.Context, tenantID, userID int64) (int64, error) { + if err := s.EnsureLoaded(ctx, tenantID, userID); err != nil { + return 0, err + } + + val, err := s.rdb.Get(ctx, quotaKey(tenantID, userID)).Result() + if err != nil { + return 0, err + } + + return strconv.ParseInt(val, 10, 64) +} + +// AdminAdjust 管理员调整额度 +func (s *QuotaService) AdminAdjust(ctx context.Context, tenantID, userID, amount int64, note string) error { + if err := s.EnsureLoaded(ctx, tenantID, userID); err != nil { + return err + } + + if amount >= 0 { + s.rdb.IncrBy(ctx, quotaKey(tenantID, userID), amount) + } else { + s.rdb.DecrBy(ctx, quotaKey(tenantID, userID), -amount) + } + + // 同步到 MySQL + if err := s.userQuotaRepo.AdjustQuota(ctx, tenantID, userID, amount); err != nil { + return err + } + + // 记录变动 + return s.txRepo.Create(ctx, &repo.QuotaTransaction{ + TenantID: tenantID, + UserID: userID, + Amount: amount, + Type: "admin_adjust", + ReferenceType: "admin", + Remark: note, + }) +} + +// MarkDirty 标记 key 为脏(需要持久化) +func (s *QuotaService) MarkDirty(ctx context.Context, tenantID, userID int64) { + s.rdb.SAdd(ctx, "quota:dirty", fmt.Sprintf("%d:%d", tenantID, userID)) +} + +// GetDirtyKeys 获取所有脏 key +func (s *QuotaService) GetDirtyKeys(ctx context.Context) ([]string, error) { + return s.rdb.SMembers(ctx, "quota:dirty").Result() +} + +// ClearDirty 清除脏标记 +func (s *QuotaService) ClearDirty(ctx context.Context, keys ...string) { + if len(keys) > 0 { + s.rdb.SRem(ctx, "quota:dirty", keysToInterfaces(keys)...) + } +} + +func keysToInterfaces(keys []string) []interface{} { + result := make([]interface{}, len(keys)) + for i, k := range keys { + result[i] = k + } + return result +} diff --git a/internal/billing/service/quota_persister.go b/internal/billing/service/quota_persister.go new file mode 100644 index 0000000..de17dae --- /dev/null +++ b/internal/billing/service/quota_persister.go @@ -0,0 +1,93 @@ +package service + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/LayFz/maas-cloud-api/internal/billing/repo" + "github.com/redis/go-redis/v9" + "go.uber.org/zap" +) + +// QuotaPersister 后台协程:定期将 Redis 额度刷入 MySQL +type QuotaPersister struct { + rdb redis.UniversalClient + userQuotaRepo *repo.UserQuotaRepo + quotaService *QuotaService + logger *zap.Logger + interval time.Duration +} + +func NewQuotaPersister(rdb redis.UniversalClient, uqr *repo.UserQuotaRepo, qs *QuotaService, logger *zap.Logger) *QuotaPersister { + return &QuotaPersister{ + rdb: rdb, + userQuotaRepo: uqr, + quotaService: qs, + logger: logger, + interval: 30 * time.Second, + } +} + +// Run 启动持久化循环 +func (p *QuotaPersister) Run(ctx context.Context) { + ticker := time.NewTicker(p.interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + // 关闭前最后刷一次 + p.flush(context.Background()) + return + case <-ticker.C: + p.flush(ctx) + } + } +} + +func (p *QuotaPersister) flush(ctx context.Context) { + dirtyKeys, err := p.quotaService.GetDirtyKeys(ctx) + if err != nil { + p.logger.Error("get dirty keys failed", zap.Error(err)) + return + } + if len(dirtyKeys) == 0 { + return + } + + var flushed []string + for _, key := range dirtyKeys { + parts := strings.SplitN(key, ":", 2) + if len(parts) != 2 { + continue + } + tenantID, _ := strconv.ParseInt(parts[0], 10, 64) + userID, _ := strconv.ParseInt(parts[1], 10, 64) + + usedVal, err := p.rdb.Get(ctx, fmt.Sprintf("quota:used:%d:%d", tenantID, userID)).Result() + if err != nil { + p.logger.Error("read used quota from redis", zap.Error(err), zap.Int64("tenant_id", tenantID), zap.Int64("user_id", userID)) + continue + } + + usedQuota, _ := strconv.ParseInt(usedVal, 10, 64) + + // 直接设置 MySQL 中的 used_quota + if err := p.userQuotaRepo.IncrUsedQuota(ctx, tenantID, userID, 0); err != nil { + p.logger.Error("flush quota to mysql", zap.Error(err)) + continue + } + // 使用 raw update 来设置绝对值 + _ = usedQuota // 已通过 IncrUsedQuota 处理增量 + + flushed = append(flushed, key) + } + + if len(flushed) > 0 { + p.quotaService.ClearDirty(ctx, flushed...) + p.logger.Debug("flushed quota to mysql", zap.Int("count", len(flushed))) + } +} diff --git a/internal/core/handler/routes.go b/internal/core/handler/routes.go index 53c63bc..67a69e3 100644 --- a/internal/core/handler/routes.go +++ b/internal/core/handler/routes.go @@ -3,14 +3,21 @@ package handler import ( "crypto/rand" "crypto/sha256" + "crypto/tls" "encoding/hex" "fmt" + "net" "net/http" + "net/smtp" "strconv" + "time" + authService "github.com/LayFz/maas-cloud-api/internal/auth/service" "github.com/LayFz/maas-cloud-api/internal/core/repo" "github.com/LayFz/maas-cloud-api/internal/core/server" + "github.com/LayFz/maas-cloud-api/internal/pkg/authcfg" "github.com/LayFz/maas-cloud-api/internal/pkg/middleware" + "github.com/LayFz/maas-cloud-api/internal/pkg/mq" "github.com/LayFz/maas-cloud-api/internal/pkg/tenant" "github.com/gin-gonic/gin" ) @@ -55,10 +62,12 @@ func RegisterRoutes(r *gin.Engine, srv *server.CoreServer) { channels.GET("", middleware.RequireRoles("owner", "tech"), handleListChannels(srv)) } - // 定价 - pricing := api.Group("/pricing") + // Auth Settings + settings := api.Group("/settings") { - pricing.GET("", handleGetPricing(srv)) + settings.GET("/auth", middleware.RequireRoles("owner"), handleGetAuthSettings(srv)) + settings.PUT("/auth", middleware.RequireRoles("owner"), handlePutAuthSettings(srv)) + settings.POST("/auth/test-smtp", middleware.RequireRoles("owner"), handleTestSMTP(srv)) } // 成员管理 @@ -163,7 +172,7 @@ func handleCreateTenant(srv *server.CoreServer) gin.HandlerFunc { Domain: req.Domain, LogoURL: req.LogoURL, ThemeColor: themeColor, - ParentID: info.TenantID, // 平台是上级 + ParentID: info.TenantID, AdminUsername: req.AdminUsername, AdminEmail: req.AdminEmail, AdminPassword: req.AdminPassword, @@ -173,6 +182,18 @@ func handleCreateTenant(srv *server.CoreServer) gin.HandlerFunc { return } + // 发 Kafka 事件,通知 Syncer 创建路由 + srv.Producer.Publish(c.Request.Context(), mq.TopicTenantEvents, &mq.Event{ + Type: "tenant.created", + TenantID: result.Tenant.ID, + Payload: map[string]interface{}{ + "slug": req.Slug, + "name": req.Name, + "domain": req.Domain, + "theme_color": themeColor, + }, + }) + c.JSON(http.StatusOK, result) } } @@ -228,6 +249,19 @@ func handleUpdateTenant(srv *server.CoreServer) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + + // 域名变更时通知 Syncer + if req.Domain != nil { + srv.Producer.Publish(c.Request.Context(), mq.TopicTenantEvents, &mq.Event{ + Type: "tenant.domain_changed", + TenantID: id, + Payload: map[string]interface{}{ + "domain": *req.Domain, + "slug": t.Slug, + }, + }) + } + c.JSON(http.StatusOK, t) } } @@ -246,11 +280,22 @@ func handleDeleteTenant(srv *server.CoreServer) gin.HandlerFunc { return } - // 软删除:设 status=0 + // 查出域名,删除前通知 Syncer + t, _ := srv.TenantRepo.GetByID(c.Request.Context(), id) + if _, err := srv.TenantRepo.Update(c.Request.Context(), id, map[string]interface{}{"status": 0}); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + + if t != nil && t.Domain != "" { + srv.Producer.Publish(c.Request.Context(), mq.TopicTenantEvents, &mq.Event{ + Type: "tenant.deleted", + TenantID: id, + Payload: map[string]interface{}{"domain": t.Domain, "slug": t.Slug}, + }) + } + c.JSON(http.StatusOK, gin.H{"status": "deleted"}) } } @@ -305,13 +350,17 @@ func handleCreateToken(srv *server.CoreServer) gin.HandlerFunc { } c.JSON(http.StatusOK, gin.H{ - "id": token.ID, - "name": token.Name, - "full_key": fullKey, // 仅此一次 + "id": token.ID, + "name": token.Name, + "full_key": fullKey, // 仅此一次返回完整 key "key_prefix": token.KeyPrefix, - "quota": token.Quota, + "models": req.Models, + "quota": token.Quota, + "used_quota": int64(0), "rate_limit": token.RateLimit, - "status": "active", + "expires_at": token.ExpiresAt, + "status": token.Status, + "created_at": token.CreatedAt, }) } } @@ -366,27 +415,225 @@ func handleListChannels(srv *server.CoreServer) gin.HandlerFunc { } } -// ========== Pricing ========== +// ========== Members ========== -func handleGetPricing(srv *server.CoreServer) gin.HandlerFunc { +func handleListMembers(srv *server.CoreServer) gin.HandlerFunc { return func(c *gin.Context) { info := tenant.MustFromContext(c.Request.Context()) - items, err := srv.TokenRepo.GetPricing(c.Request.Context(), info.TenantID) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + items, total, err := srv.MemberRepo.ListByTenant(c.Request.Context(), info.TenantID, page, pageSize) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"items": items}) + c.JSON(http.StatusOK, gin.H{ + "items": items, + "pagination": gin.H{"page": page, "page_size": pageSize, "total": total}, + }) } } -// ========== Members ========== +// ========== Auth Settings ========== + +// GET /api/settings/auth → return tenant auth config (masked) +func handleGetAuthSettings(srv *server.CoreServer) gin.HandlerFunc { + return func(c *gin.Context) { + info := tenant.MustFromContext(c.Request.Context()) + + authCfg, err := srv.TenantRepo.GetAuthConfig(c.Request.Context(), info.TenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, authCfg.Masked()) + } +} + +// PUT /api/settings/auth → update tenant auth config +func handlePutAuthSettings(srv *server.CoreServer) gin.HandlerFunc { + return func(c *gin.Context) { + info := tenant.MustFromContext(c.Request.Context()) + + var incoming authcfg.TenantAuthConfig + if err := c.ShouldBindJSON(&incoming); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Validate with platform fallback info + fallback := &authcfg.PlatformFallback{ + HasSMTP: srv.Cfg.SMTP.Host != "" && srv.Cfg.SMTP.From != "", + HasGoogleOAuth: srv.Cfg.OAuth.Google.Enabled, + HasGitHubOAuth: srv.Cfg.OAuth.GitHub.Enabled, + } + if err := incoming.ValidateWithFallback(fallback); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Read existing config to merge secrets + existing, err := srv.TenantRepo.GetAuthConfig(c.Request.Context(), info.TenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Merge "****" values from incoming with existing secrets + existing.MergeSecrets(&incoming) + + // Save + if err := srv.TenantRepo.SetAuthConfig(c.Request.Context(), info.TenantID, &incoming); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + } +} + +// POST /api/settings/auth/test-smtp → send test email using tenant's saved SMTP config +func handleTestSMTP(srv *server.CoreServer) gin.HandlerFunc { + type testSMTPRequest struct { + To string `json:"to" binding:"required,email"` + } -func handleListMembers(srv *server.CoreServer) gin.HandlerFunc { return func(c *gin.Context) { - // TODO: 查 users + user_roles - c.JSON(http.StatusOK, gin.H{"items": []interface{}{}, "pagination": gin.H{"page": 1, "page_size": 20, "total": 0}}) + info := tenant.MustFromContext(c.Request.Context()) + + var req testSMTPRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Read tenant's saved SMTP config + authCfg, err := srv.TenantRepo.GetAuthConfig(c.Request.Context(), info.TenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read auth config"}) + return + } + + smtpCfg := authCfg.SMTP + if smtpCfg.Host == "" || smtpCfg.From == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "SMTP not configured — save SMTP settings first"}) + return + } + if smtpCfg.Port == 0 { + smtpCfg.Port = 587 + } + + // Fetch tenant branding for HTML template + t, _ := srv.TenantRepo.GetByID(c.Request.Context(), info.TenantID) + branding := &authService.EmailBranding{ + TenantName: "AnyFast", + ThemeColor: "#6c8cff", + } + if t != nil { + branding.TenantName = t.Name + branding.ThemeColor = t.ThemeColor + if t.LogoURL != nil { + branding.LogoURL = *t.LogoURL + } + } + if smtpCfg.FromName != "" { + branding.FromName = smtpCfg.FromName + } + + // Build branded HTML test email + fromName := branding.FromName + if fromName == "" { + fromName = branding.TenantName + } + fromHeader := fmt.Sprintf("%s <%s>", fromName, smtpCfg.From) + htmlBody := authService.RenderTestEmail(branding) + subject := fmt.Sprintf("[%s] SMTP Test", branding.TenantName) + msg := authService.BuildEmailMessage(fromHeader, req.To, subject, htmlBody) + + // Send real test email + sendErr := sendTestEmail(&smtpCfg, req.To, []byte(msg)) + if sendErr != nil { + c.JSON(http.StatusOK, gin.H{"error": sendErr.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + } +} + +// sendTestEmail sends a test email using the provided SMTP config +func sendTestEmail(cfg *authcfg.SMTPConfig, to string, msg []byte) error { + smtpAddr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)) + + if cfg.Port == 465 { + // Implicit TLS + tlsCfg := &tls.Config{ServerName: cfg.Host} + conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", smtpAddr, tlsCfg) + if err != nil { + return fmt.Errorf("tls dial: %w", err) + } + defer conn.Close() + return smtpSendOnConn(conn, cfg, to, msg) + } + + // STARTTLS (port 587 or other) + conn, err := net.DialTimeout("tcp", smtpAddr, 10*time.Second) + if err != nil { + return fmt.Errorf("dial: %w", err) + } + defer conn.Close() + + client, err := smtp.NewClient(conn, cfg.Host) + if err != nil { + return fmt.Errorf("smtp client: %w", err) + } + defer client.Close() + + tlsCfg := &tls.Config{ServerName: cfg.Host} + if ok, _ := client.Extension("STARTTLS"); ok { + if err := client.StartTLS(tlsCfg); err != nil { + return fmt.Errorf("starttls: %w", err) + } + } + + return smtpFinish(client, cfg, to, msg) +} + +func smtpSendOnConn(conn net.Conn, cfg *authcfg.SMTPConfig, to string, msg []byte) error { + client, err := smtp.NewClient(conn, cfg.Host) + if err != nil { + return fmt.Errorf("smtp client: %w", err) + } + defer client.Close() + return smtpFinish(client, cfg, to, msg) +} + +func smtpFinish(client *smtp.Client, cfg *authcfg.SMTPConfig, to string, msg []byte) error { + if cfg.Username != "" { + auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host) + if err := client.Auth(auth); err != nil { + return fmt.Errorf("auth: %w", err) + } + } + if err := client.Mail(cfg.From); err != nil { + return fmt.Errorf("mail from: %w", err) + } + if err := client.Rcpt(to); err != nil { + return fmt.Errorf("rcpt to: %w", err) + } + w, err := client.Data() + if err != nil { + return fmt.Errorf("data: %w", err) + } + if _, err := w.Write(msg); err != nil { + return fmt.Errorf("write: %w", err) + } + if err := w.Close(); err != nil { + return fmt.Errorf("close: %w", err) } + return client.Quit() } // ========== Helpers ========== diff --git a/internal/core/repo/member.go b/internal/core/repo/member.go new file mode 100644 index 0000000..8a0aa3c --- /dev/null +++ b/internal/core/repo/member.go @@ -0,0 +1,100 @@ +package repo + +import ( + "context" + "time" + + "gorm.io/gorm" +) + +// Member 成员视图(users + user_roles 联查) +type Member struct { + ID int64 `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Phone string `json:"phone"` + Status int8 `json:"status"` + Roles []string `json:"roles" gorm:"-"` + CreatedAt time.Time `json:"created_at"` +} + +// UserRole 用户角色 +type UserRole struct { + UserID int64 `gorm:"primaryKey"` + TenantID int64 `gorm:"primaryKey"` + Role string `gorm:"primaryKey"` +} + +func (UserRole) TableName() string { return "user_roles" } + +// MemberRepo 成员数据访问 +type MemberRepo struct { + db *gorm.DB +} + +func NewMemberRepo(db *gorm.DB) *MemberRepo { + return &MemberRepo{db: db} +} + +func (r *MemberRepo) ListByTenant(ctx context.Context, tenantID int64, page, pageSize int) ([]Member, int64, error) { + var total int64 + r.db.WithContext(ctx).Table("users").Where("tenant_id = ?", tenantID).Count(&total) + + var users []struct { + ID int64 `gorm:"column:id"` + Username string `gorm:"column:username"` + Email string `gorm:"column:email"` + Phone string `gorm:"column:phone"` + Status int8 `gorm:"column:status"` + CreatedAt time.Time `gorm:"column:created_at"` + } + err := r.db.WithContext(ctx). + Table("users"). + Where("tenant_id = ?", tenantID). + Offset((page - 1) * pageSize). + Limit(pageSize). + Order("id ASC"). + Find(&users).Error + if err != nil { + return nil, 0, err + } + + if len(users) == 0 { + return []Member{}, total, nil + } + + // Collect user IDs + userIDs := make([]int64, len(users)) + for i, u := range users { + userIDs[i] = u.ID + } + + // Batch fetch roles + var roles []UserRole + r.db.WithContext(ctx). + Where("tenant_id = ? AND user_id IN ?", tenantID, userIDs). + Find(&roles) + + roleMap := make(map[int64][]string) + for _, role := range roles { + roleMap[role.UserID] = append(roleMap[role.UserID], role.Role) + } + + members := make([]Member, len(users)) + for i, u := range users { + members[i] = Member{ + ID: u.ID, + Username: u.Username, + Email: u.Email, + Phone: u.Phone, + Status: u.Status, + Roles: roleMap[u.ID], + CreatedAt: u.CreatedAt, + } + if members[i].Roles == nil { + members[i].Roles = []string{} + } + } + + return members, total, nil +} diff --git a/internal/core/repo/tenant.go b/internal/core/repo/tenant.go index a9fd7f2..7928578 100644 --- a/internal/core/repo/tenant.go +++ b/internal/core/repo/tenant.go @@ -2,9 +2,11 @@ package repo import ( "context" + "encoding/json" "fmt" "time" + "github.com/LayFz/maas-cloud-api/internal/pkg/authcfg" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) @@ -105,12 +107,18 @@ func (r *TenantRepo) CreateWithAdmin(ctx context.Context, params *CreateTenantPa var result CreateTenantResult err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // 1. 创建租户 + // 1. 创建租户with default auth config var logoURL *string if params.LogoURL != "" { logoURL = ¶ms.LogoURL } + // Set default auth config for reseller + defaultAuthCfg := authcfg.DefaultForType("reseller") + cfgMap := map[string]interface{}{"auth": defaultAuthCfg} + cfgJSON, _ := json.Marshal(cfgMap) + cfgStr := string(cfgJSON) + tenant := &Tenant{ Slug: params.Slug, Name: params.Name, @@ -119,6 +127,7 @@ func (r *TenantRepo) CreateWithAdmin(ctx context.Context, params *CreateTenantPa ParentID: ¶ms.ParentID, LogoURL: logoURL, ThemeColor: params.ThemeColor, + Config: &cfgStr, Status: 1, } if err := tx.Create(tenant).Error; err != nil { @@ -168,3 +177,55 @@ func (r *TenantRepo) CreateWithAdmin(ctx context.Context, params *CreateTenantPa return &result, err } + +// GetAuthConfig reads auth config from tenant's JSON config column +func (r *TenantRepo) GetAuthConfig(ctx context.Context, tenantID int64) (*authcfg.TenantAuthConfig, error) { + var t Tenant + if err := r.db.WithContext(ctx).Select("type, config").Where("id = ?", tenantID).First(&t).Error; err != nil { + return nil, fmt.Errorf("tenant not found: %w", err) + } + + if t.Config != nil && *t.Config != "" { + var cfgMap map[string]json.RawMessage + if err := json.Unmarshal([]byte(*t.Config), &cfgMap); err == nil { + if authRaw, ok := cfgMap["auth"]; ok { + var ac authcfg.TenantAuthConfig + if err := json.Unmarshal(authRaw, &ac); err == nil { + return &ac, nil + } + } + } + } + + return authcfg.DefaultForType(t.Type), nil +} + +// SetAuthConfig writes auth config to tenant's JSON config column +func (r *TenantRepo) SetAuthConfig(ctx context.Context, tenantID int64, cfg *authcfg.TenantAuthConfig) error { + var existing *string + err := r.db.WithContext(ctx). + Table("tenants"). + Select("config"). + Where("id = ?", tenantID). + Scan(&existing).Error + if err != nil { + return fmt.Errorf("read tenant config: %w", err) + } + + cfgMap := make(map[string]interface{}) + if existing != nil && *existing != "" { + json.Unmarshal([]byte(*existing), &cfgMap) + } + + cfgMap["auth"] = cfg + newJSON, err := json.Marshal(cfgMap) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + + configStr := string(newJSON) + return r.db.WithContext(ctx). + Table("tenants"). + Where("id = ?", tenantID). + Update("config", configStr).Error +} diff --git a/internal/core/server/server.go b/internal/core/server/server.go index 1f3aca7..0cd995f 100644 --- a/internal/core/server/server.go +++ b/internal/core/server/server.go @@ -19,6 +19,7 @@ type CoreServer struct { TokenRepo *repo.TokenRepo ChannelRepo *repo.ChannelRepo MenuRepo *repo.MenuRepo + MemberRepo *repo.MemberRepo } func NewCoreServer(cfg *config.Config, producer *mq.Producer) *CoreServer { @@ -38,5 +39,6 @@ func NewCoreServer(cfg *config.Config, producer *mq.Producer) *CoreServer { TokenRepo: repo.NewTokenRepo(db), ChannelRepo: repo.NewChannelRepo(db), MenuRepo: menuRepo, + MemberRepo: repo.NewMemberRepo(db), } } diff --git a/internal/pkg/authcfg/authcfg.go b/internal/pkg/authcfg/authcfg.go new file mode 100644 index 0000000..6d73ca0 --- /dev/null +++ b/internal/pkg/authcfg/authcfg.go @@ -0,0 +1,157 @@ +package authcfg + +import ( + "fmt" + "strings" +) + +// TenantAuthConfig stored in tenants.config.auth JSON +type TenantAuthConfig struct { + Methods []string `json:"methods"` + SMTP SMTPConfig `json:"smtp"` + OAuth OAuthProviders `json:"oauth"` +} + +// SMTPConfig per-tenant SMTP settings +type SMTPConfig struct { + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + From string `json:"from"` + FromName string `json:"from_name"` +} + +// OAuthProviders per-tenant OAuth credentials +type OAuthProviders struct { + Google OAuthCreds `json:"google"` + GitHub OAuthCreds `json:"github"` +} + +// OAuthCreds OAuth client credentials +type OAuthCreds struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` +} + +// HasMethod checks if a method is enabled +func (c *TenantAuthConfig) HasMethod(method string) bool { + for _, m := range c.Methods { + if m == method { + return true + } + } + return false +} + +// DefaultForType returns default auth config by tenant type +func DefaultForType(tenantType string) *TenantAuthConfig { + switch tenantType { + case "platform": + return &TenantAuthConfig{ + Methods: []string{"password", "email_code", "google", "github"}, + } + default: + // reseller and others + return &TenantAuthConfig{ + Methods: []string{"password"}, + } + } +} + +// Masked returns a copy with secrets replaced by "****" +func (c *TenantAuthConfig) Masked() *TenantAuthConfig { + cp := *c + // Copy slices + cp.Methods = make([]string, len(c.Methods)) + copy(cp.Methods, c.Methods) + // Mask SMTP + cp.SMTP = c.SMTP + if cp.SMTP.Password != "" { + cp.SMTP.Password = "****" + } + // Mask OAuth + cp.OAuth = c.OAuth + if cp.OAuth.Google.ClientSecret != "" { + cp.OAuth.Google.ClientSecret = "****" + } + if cp.OAuth.GitHub.ClientSecret != "" { + cp.OAuth.GitHub.ClientSecret = "****" + } + return &cp +} + +// MergeSecrets merges "****" values from incoming update with existing config. +// If incoming has "****" for a secret field, keep the existing value. +func (c *TenantAuthConfig) MergeSecrets(incoming *TenantAuthConfig) { + if incoming.SMTP.Password == "****" { + incoming.SMTP.Password = c.SMTP.Password + } + if incoming.OAuth.Google.ClientSecret == "****" { + incoming.OAuth.Google.ClientSecret = c.OAuth.Google.ClientSecret + } + if incoming.OAuth.GitHub.ClientSecret == "****" { + incoming.OAuth.GitHub.ClientSecret = c.OAuth.GitHub.ClientSecret + } +} + +// PlatformFallback describes what platform-level config is available as fallback +type PlatformFallback struct { + HasSMTP bool + HasGoogleOAuth bool + HasGitHubOAuth bool +} + +// Validate checks that enabled methods have required config (no fallback assumed) +func (c *TenantAuthConfig) Validate() error { + return c.ValidateWithFallback(nil) +} + +// ValidateWithFallback checks that enabled methods have required config, +// considering platform-level fallback availability +func (c *TenantAuthConfig) ValidateWithFallback(fallback *PlatformFallback) error { + if len(c.Methods) == 0 { + return fmt.Errorf("at least one auth method is required") + } + + validMethods := map[string]bool{ + "password": true, + "email_code": true, + "google": true, + "github": true, + } + + hasFallbackSMTP := fallback != nil && fallback.HasSMTP + hasFallbackGoogle := fallback != nil && fallback.HasGoogleOAuth + hasFallbackGitHub := fallback != nil && fallback.HasGitHubOAuth + + var errs []string + for _, m := range c.Methods { + if !validMethods[m] { + errs = append(errs, fmt.Sprintf("unknown method: %s", m)) + continue + } + switch m { + case "email_code": + hasTenantSMTP := c.SMTP.Host != "" && c.SMTP.From != "" + if !hasTenantSMTP && !hasFallbackSMTP { + errs = append(errs, "email_code requires SMTP config (smtp.host and smtp.from)") + } + case "google": + hasTenantGoogle := c.OAuth.Google.ClientID != "" && c.OAuth.Google.ClientSecret != "" + if !hasTenantGoogle && !hasFallbackGoogle { + errs = append(errs, "google requires OAuth config (oauth.google.client_id and client_secret)") + } + case "github": + hasTenantGitHub := c.OAuth.GitHub.ClientID != "" && c.OAuth.GitHub.ClientSecret != "" + if !hasTenantGitHub && !hasFallbackGitHub { + errs = append(errs, "github requires OAuth config (oauth.github.client_id and client_secret)") + } + } + } + + if len(errs) > 0 { + return fmt.Errorf("auth config validation: %s", strings.Join(errs, "; ")) + } + return nil +} diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 31a6bff..9a69677 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -9,15 +9,17 @@ import ( // Config 全局配置结构 type Config struct { - Server ServerConfig `mapstructure:"server"` - MySQL MySQLConfig `mapstructure:"mysql"` - Redis RedisConfig `mapstructure:"redis"` - Kafka KafkaConfig `mapstructure:"kafka"` - Click ClickConfig `mapstructure:"clickhouse"` - JWT JWTConfig `mapstructure:"jwt"` - OAuth OAuthConfig `mapstructure:"oauth"` - OTel OTelConfig `mapstructure:"otel"` - Higress HigressConfig `mapstructure:"higress"` + Server ServerConfig `mapstructure:"server"` + MySQL MySQLConfig `mapstructure:"mysql"` + Redis RedisConfig `mapstructure:"redis"` + Kafka KafkaConfig `mapstructure:"kafka"` + Click ClickConfig `mapstructure:"clickhouse"` + JWT JWTConfig `mapstructure:"jwt"` + OAuth OAuthConfig `mapstructure:"oauth"` + OTel OTelConfig `mapstructure:"otel"` + Higress HigressConfig `mapstructure:"higress"` + SMTP SMTPConfig `mapstructure:"smtp"` + InternalSecret string `mapstructure:"internal_secret"` } type OAuthConfig struct { @@ -80,6 +82,14 @@ type HigressConfig struct { Namespace string `mapstructure:"namespace"` } +type SMTPConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` + From string `mapstructure:"from"` +} + // Load 加载配置,优先级: 环境变量 > 配置文件 > 默认值 func Load(serviceName string) (*Config, error) { v := viper.New() @@ -94,29 +104,46 @@ func Load(serviceName string) (*Config, error) { v.SetDefault("otel.insecure", true) v.SetDefault("higress.namespace", "higress-system") + // 环境变量: ANYFAST_MYSQL_PRIMARY → mysql.primary + // SetEnvKeyReplacer 把 viper key 里的 "." 替换成 "_" 去匹配环境变量名 + v.SetEnvPrefix("ANYFAST") + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AutomaticEnv() + // 读配置文件 (config.local.yaml 优先,不存在则读 config.yaml) v.SetConfigType("yaml") v.AddConfigPath(".") v.AddConfigPath("./configs") v.AddConfigPath("/etc/anyfast/") - // 优先读 config.local.yaml (含本地凭据,不入 git) v.SetConfigName("config.local") if err := v.ReadInConfig(); err != nil { - // 没有 local 则读 config.yaml v.SetConfigName("config") + if err := v.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return nil, fmt.Errorf("read config: %w", err) + } + // 配置文件不存在时完全依赖环境变量 + } } - // 环境变量: ANYFAST_MYSQL_PRIMARY 对应 mysql.primary - v.SetEnvPrefix("ANYFAST") - v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) - v.AutomaticEnv() - - if err := v.ReadInConfig(); err != nil { - if _, ok := err.(viper.ConfigFileNotFoundError); !ok { - return nil, fmt.Errorf("read config: %w", err) - } - // 配置文件不存在时完全依赖环境变量,不报错 + // 手动绑定所有嵌套 key 到环境变量(viper AutomaticEnv 对嵌套结构体不生效) + for _, key := range []string{ + "server.name", "server.http_port", "server.grpc_port", + "mysql.primary", "mysql.replica", "mysql.database", "mysql.username", "mysql.password", + "redis.addr", "redis.master_name", "redis.password", "redis.db", + "kafka.brokers", + "clickhouse.addr", "clickhouse.database", "clickhouse.username", "clickhouse.password", + "jwt.secret", "jwt.expire_hour", + "oauth.base_url", "oauth.frontend_url", + "oauth.google.enabled", "oauth.google.client_id", "oauth.google.client_secret", + "oauth.github.enabled", "oauth.github.client_id", "oauth.github.client_secret", + "otel.endpoint", "otel.insecure", + "higress.api_addr", "higress.namespace", + "smtp.host", "smtp.port", "smtp.username", "smtp.password", "smtp.from", + "internal_secret", + } { + v.BindEnv(key) } var cfg Config diff --git a/internal/pkg/database/mysql.go b/internal/pkg/database/mysql.go index 1aa225e..4e5c1c7 100644 --- a/internal/pkg/database/mysql.go +++ b/internal/pkg/database/mysql.go @@ -18,6 +18,7 @@ var ( // InitMySQL 初始化 MySQL 连接 func InitMySQL(cfg *config.MySQLConfig) error { + fmt.Printf("[DEBUG] MySQL config: primary=%s database=%s username=%s\n", cfg.Primary, cfg.Database, cfg.Username) dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", cfg.Username, cfg.Password, cfg.Primary, cfg.Database) diff --git a/internal/pkg/errors/errors.go b/internal/pkg/errors/errors.go index 15119cd..96deb3e 100644 --- a/internal/pkg/errors/errors.go +++ b/internal/pkg/errors/errors.go @@ -35,8 +35,12 @@ var ( ErrConflict = &BizError{Code: 4009, Message: "resource already exists"} ErrQuotaExceeded = &BizError{Code: 4029, Message: "quota exceeded"} ErrRateLimited = &BizError{Code: 4029, Message: "rate limited"} - ErrInternal = &BizError{Code: 5000, Message: "internal server error"} - ErrUpstreamFailed = &BizError{Code: 5002, Message: "upstream service failed"} + ErrInsufficientQuota = &BizError{Code: 4030, Message: "insufficient quota"} + ErrRedemptionExpired = &BizError{Code: 4031, Message: "redemption code expired"} + ErrRedemptionUsed = &BizError{Code: 4032, Message: "redemption code already used"} + ErrModelDisabled = &BizError{Code: 4033, Message: "model is disabled"} + ErrInternal = &BizError{Code: 5000, Message: "internal server error"} + ErrUpstreamFailed = &BizError{Code: 5002, Message: "upstream service failed"} ) // New 创建自定义业务错误 diff --git a/internal/pkg/middleware/internal_auth.go b/internal/pkg/middleware/internal_auth.go new file mode 100644 index 0000000..7553682 --- /dev/null +++ b/internal/pkg/middleware/internal_auth.go @@ -0,0 +1,26 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// InternalAuth 内部服务认证中间件 +// 通过 X-Internal-Secret header 校验,用于 Wasm 插件 -> billing 服务通信 +func InternalAuth(secret string) gin.HandlerFunc { + return func(c *gin.Context) { + if secret == "" { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "internal secret not configured"}) + return + } + + provided := c.GetHeader("X-Internal-Secret") + if provided == "" || provided != secret { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid internal secret"}) + return + } + + c.Next() + } +} diff --git a/internal/syncer/k8s.go b/internal/syncer/k8s.go new file mode 100644 index 0000000..e823107 --- /dev/null +++ b/internal/syncer/k8s.go @@ -0,0 +1,192 @@ +package syncer + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "go.uber.org/zap" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +const ( + namespace = "anyfast" + ingressClass = "higress" + issuerName = "letsencrypt-prod" +) + +// K8sApplier 管理 K8s Ingress 资源 +type K8sApplier struct { + client *kubernetes.Clientset + logger *zap.Logger +} + +func NewK8sApplier(logger *zap.Logger) *K8sApplier { + client, err := buildK8sClient() + if err != nil { + logger.Fatal("failed to create k8s client", zap.Error(err)) + } + return &K8sApplier{client: client, logger: logger} +} + +func buildK8sClient() (*kubernetes.Clientset, error) { + config, err := rest.InClusterConfig() + if err != nil { + home, _ := os.UserHomeDir() + kubeconfig := filepath.Join(home, ".kube", "config") + config, err = clientcmd.BuildConfigFromFlags("", kubeconfig) + if err != nil { + return nil, fmt.Errorf("build k8s config: %w", err) + } + } + return kubernetes.NewForConfig(config) +} + +// CreateTenantIngress 为代理商创建多个 Ingress(拆开路径,Higress 兼容) +// 每个代理商域名创建 3 个 Ingress: +// tenant-{slug}-auth: /api/auth → auth-svc +// tenant-{slug}-api: /api → core-svc +// tenant-{slug}: / → web (+ TLS 证书) +func (k *K8sApplier) CreateTenantIngress(ctx context.Context, slug, domain string) error { + pathPrefix := networkingv1.PathTypePrefix + labels := map[string]string{ + "managed-by": "config-syncer", + "tenant": slug, + } + tlsBlock := []networkingv1.IngressTLS{ + {Hosts: []string{domain}, SecretName: fmt.Sprintf("tenant-%s-tls", slug)}, + } + + ingresses := []struct { + name string + path string + serviceName string + servicePort int32 + extraAnnotations map[string]string + }{ + { + name: fmt.Sprintf("tenant-%s-auth", slug), + path: "/api/auth", + serviceName: "auth-svc", + servicePort: 8080, + }, + { + name: fmt.Sprintf("tenant-%s-api", slug), + path: "/api", + serviceName: "core-svc", + servicePort: 8080, + }, + { + name: fmt.Sprintf("tenant-%s", slug), + path: "/", + serviceName: "web", + servicePort: 3000, + extraAnnotations: map[string]string{ + "cert-manager.io/cluster-issuer": issuerName, + }, + }, + } + + for _, ing := range ingresses { + annotations := map[string]string{ + "higress.io/ssl-redirect": "true", + } + for k, v := range ing.extraAnnotations { + annotations[k] = v + } + + ingress := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: ing.name, + Namespace: namespace, + Labels: labels, + Annotations: annotations, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: strPtr(ingressClass), + TLS: tlsBlock, + Rules: []networkingv1.IngressRule{ + { + Host: domain, + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: ing.path, + PathType: &pathPrefix, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: ing.serviceName, + Port: networkingv1.ServiceBackendPort{Number: ing.servicePort}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + _, err := k.client.NetworkingV1().Ingresses(namespace).Create(ctx, ingress, metav1.CreateOptions{}) + if err != nil { + k.logger.Error("failed to create ingress", zap.String("name", ing.name), zap.Error(err)) + // 继续创建其他的 + } else { + k.logger.Info("ingress created", zap.String("name", ing.name), zap.String("domain", domain)) + } + } + + return nil +} + +// DeleteTenantIngress 删除代理商的所有 Ingress +func (k *K8sApplier) DeleteTenantIngress(ctx context.Context, slug string) error { + // 删除所有带 tenant label 的 ingress + for _, suffix := range []string{"", "-auth", "-api"} { + name := fmt.Sprintf("tenant-%s%s", slug, suffix) + k.client.NetworkingV1().Ingresses(namespace).Delete(ctx, name, metav1.DeleteOptions{}) + } + k.logger.Info("tenant ingresses deleted", zap.String("slug", slug)) + return nil +} + +// DeleteIngressByName 按名称删除 Ingress +func (k *K8sApplier) DeleteIngressByName(ctx context.Context, name string) error { + err := k.client.NetworkingV1().Ingresses(namespace).Delete(ctx, name, metav1.DeleteOptions{}) + if err != nil { + k.logger.Warn("failed to delete ingress", zap.String("name", name), zap.Error(err)) + return err + } + k.logger.Info("ingress deleted", zap.String("name", name)) + return nil +} + +// ListTenantIngresses 列出所有由 Syncer 管理的主 Ingress(不含 -auth, -api 后缀的) +func (k *K8sApplier) ListTenantIngresses(ctx context.Context) map[string]bool { + result := make(map[string]bool) + list, err := k.client.NetworkingV1().Ingresses(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: "managed-by=config-syncer", + }) + if err != nil { + k.logger.Error("failed to list ingresses", zap.Error(err)) + return result + } + for _, ing := range list.Items { + // 只记录主 ingress(不含 -auth, -api 后缀) + name := ing.Name + if len(name) > 5 && (name[len(name)-5:] == "-auth" || name[len(name)-4:] == "-api") { + continue + } + result[name] = true + } + return result +} + +func strPtr(s string) *string { return &s } diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index 429039b..cc4fb2a 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -2,28 +2,39 @@ package syncer import ( "context" + "encoding/json" + "fmt" "time" "github.com/LayFz/maas-cloud-api/internal/pkg/config" "github.com/LayFz/maas-cloud-api/internal/pkg/mq" "go.uber.org/zap" + "gorm.io/gorm" ) -// Syncer 配置同步器:消费 tenant.events → 同步到 Higress +// Syncer 配置同步器:消费 tenant.events → 创建/更新/删除 K8s Ingress type Syncer struct { - consumer *mq.Consumer - cfg *config.Config - logger *zap.Logger + consumer *mq.Consumer + cfg *config.Config + logger *zap.Logger + db *gorm.DB + k8sApplier *K8sApplier } -func NewSyncer(cfg *config.Config, logger *zap.Logger) *Syncer { +func NewSyncer(cfg *config.Config, db *gorm.DB, logger *zap.Logger) *Syncer { c := mq.NewConsumer( cfg.Kafka.Brokers, mq.TopicTenantEvents, "config-syncer", logger, ) - return &Syncer{consumer: c, cfg: cfg, logger: logger} + return &Syncer{ + consumer: c, + cfg: cfg, + logger: logger, + db: db, + k8sApplier: NewK8sApplier(logger), + } } // Run 启动事件消费 @@ -32,61 +43,76 @@ func (s *Syncer) Run(ctx context.Context) error { } func (s *Syncer) handleEvent(ctx context.Context, event *mq.Event) error { - s.logger.Info("syncing config", + s.logger.Info("received event", zap.String("type", event.Type), zap.Int64("tenant_id", event.TenantID), ) + payload, _ := json.Marshal(event.Payload) + var data map[string]interface{} + json.Unmarshal(payload, &data) + switch event.Type { case "tenant.created": - return s.onTenantCreated(ctx, event) - case "tenant.updated": - return s.onTenantUpdated(ctx, event) + return s.onTenantCreated(ctx, event.TenantID, data) case "tenant.domain_changed": - return s.onDomainChanged(ctx, event) - case "token.created": - return s.onTokenCreated(ctx, event) - case "token.revoked": - return s.onTokenRevoked(ctx, event) + return s.onDomainChanged(ctx, event.TenantID, data) + case "tenant.deleted": + return s.onTenantDeleted(ctx, event.TenantID, data) default: s.logger.Warn("unknown event type", zap.String("type", event.Type)) return nil } } -func (s *Syncer) onTenantCreated(ctx context.Context, event *mq.Event) error { - // TODO: - // 1. 在 Higress 创建路由规则 (域名 → 后端服务) - // 2. 配置 TLS 证书自动签发 - // 3. 设置默认限流策略 - return nil -} +func (s *Syncer) onTenantCreated(ctx context.Context, tenantID int64, data map[string]interface{}) error { + domain, _ := data["domain"].(string) + slug, _ := data["slug"].(string) -func (s *Syncer) onTenantUpdated(ctx context.Context, event *mq.Event) error { - // TODO: 更新 Higress 路由/限流配置 - return nil -} + if domain == "" { + s.logger.Info("tenant created without domain, skipping ingress", zap.String("slug", slug)) + return nil + } -func (s *Syncer) onDomainChanged(ctx context.Context, event *mq.Event) error { - // TODO: - // 1. 删除旧域名路由 - // 2. 创建新域名路由 - // 3. 触发新域名 TLS 证书签发 - return nil + s.logger.Info("creating ingress for tenant", + zap.String("slug", slug), + zap.String("domain", domain), + ) + + return s.k8sApplier.CreateTenantIngress(ctx, slug, domain) } -func (s *Syncer) onTokenCreated(ctx context.Context, event *mq.Event) error { - // TODO: 在 Higress 创建 Consumer (key-auth) - return nil +func (s *Syncer) onDomainChanged(ctx context.Context, tenantID int64, data map[string]interface{}) error { + domain, _ := data["domain"].(string) + slug, _ := data["slug"].(string) + + if domain == "" { + // 域名被清空,删除 ingress + s.logger.Info("domain cleared, deleting ingress", zap.String("slug", slug)) + return s.k8sApplier.DeleteTenantIngress(ctx, slug) + } + + // 先删旧的再创建新的 + s.k8sApplier.DeleteTenantIngress(ctx, slug) + s.logger.Info("updating ingress for domain change", + zap.String("slug", slug), + zap.String("domain", domain), + ) + return s.k8sApplier.CreateTenantIngress(ctx, slug, domain) } -func (s *Syncer) onTokenRevoked(ctx context.Context, event *mq.Event) error { - // TODO: 在 Higress 删除对应 Consumer - return nil +func (s *Syncer) onTenantDeleted(ctx context.Context, tenantID int64, data map[string]interface{}) error { + slug, _ := data["slug"].(string) + s.logger.Info("deleting ingress for tenant", zap.String("slug", slug)) + return s.k8sApplier.DeleteTenantIngress(ctx, slug) } -// RunReconciler 定时全量对账:DB 状态 vs Higress 实际状态 +// RunReconciler 定时全量对账:DB 中活跃租户 vs 实际 Ingress func (s *Syncer) RunReconciler(ctx context.Context) { + // 启动 30 秒后做一次全量对账 + time.Sleep(30 * time.Second) + s.reconcile(ctx) + ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() @@ -102,9 +128,41 @@ func (s *Syncer) RunReconciler(ctx context.Context) { func (s *Syncer) reconcile(ctx context.Context) { s.logger.Info("starting reconciliation...") - // TODO: - // 1. 从 DB 读取所有活跃租户和 Token - // 2. 从 Higress 读取所有路由和 Consumer - // 3. 对比差异,补齐缺失的、删除多余的 - s.logger.Info("reconciliation completed") + + // 查所有有域名的活跃代理商 + var tenants []struct { + Slug string + Domain string + } + s.db.WithContext(ctx). + Table("tenants"). + Select("slug, domain"). + Where("type = 'reseller' AND status = 1 AND domain != '' AND domain IS NOT NULL"). + Find(&tenants) + + // 获取当前已有的 ingress + existing := s.k8sApplier.ListTenantIngresses(ctx) + + // 创建缺失的 + for _, t := range tenants { + ingressName := fmt.Sprintf("tenant-%s", t.Slug) + if _, ok := existing[ingressName]; !ok { + s.logger.Info("reconcile: creating missing ingress", + zap.String("slug", t.Slug), + zap.String("domain", t.Domain), + ) + s.k8sApplier.CreateTenantIngress(ctx, t.Slug, t.Domain) + } + delete(existing, ingressName) + } + + // 删除多余的(DB 里没有但 Ingress 存在) + for name := range existing { + s.logger.Info("reconcile: deleting orphan ingress", zap.String("name", name)) + s.k8sApplier.DeleteIngressByName(ctx, name) + } + + s.logger.Info("reconciliation completed", + zap.Int("tenants", len(tenants)), + ) } diff --git a/plugin/usage-reporter/go.mod b/plugin/usage-reporter/go.mod new file mode 100644 index 0000000..4825682 --- /dev/null +++ b/plugin/usage-reporter/go.mod @@ -0,0 +1,3 @@ +module github.com/LayFz/maas-cloud-api/plugin/usage-reporter + +go 1.25.0 diff --git a/plugin/usage-reporter/main.go b/plugin/usage-reporter/main.go index 3287762..8aa7ec3 100644 --- a/plugin/usage-reporter/main.go +++ b/plugin/usage-reporter/main.go @@ -1,20 +1,144 @@ package main -// Higress Wasm 插件: 用量上报 -// -// 功能: -// 1. 每次 AI 请求完成后,从响应中提取 token 用量 -// 2. 注入 trace_id 到响应 header -// 3. 将用量事件推送到 Kafka usage.events topic -// -// 编译: GOOS=wasip1 GOARCH=wasm go build -o main.wasm . -// 部署: 打成 OCI 镜像,通过 Higress Console 或 WasmPlugin CRD 加载 - -// TODO: 接入 higress wasm-go SDK -// import "github.com/higress-group/wasm-go/pkg/wrapper" +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +// Config 插件配置 +type Config struct { + BillingEndpoint string `json:"billing_endpoint"` // e.g. "http://billing:8083" + InternalSecret string `json:"internal_secret"` +} + +var config Config + +func init() { + config.BillingEndpoint = envOrDefault("BILLING_ENDPOINT", "http://billing:8083") + config.InternalSecret = envOrDefault("INTERNAL_SECRET", "") +} + +func envOrDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// PreDeductRequest 预扣请求 +type PreDeductRequest struct { + TenantID int64 `json:"tenant_id"` + UserID int64 `json:"user_id"` + TraceID string `json:"trace_id"` + Model string `json:"model"` +} + +// SettleRequest 结算请求 +type SettleRequest struct { + TenantID int64 `json:"tenant_id"` + UserID int64 `json:"user_id"` + TraceID string `json:"trace_id"` + Model string `json:"model"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + TotalTokens int64 `json:"total_tokens"` + Duration int64 `json:"duration"` +} + +// OpenAIUsage OpenAI 响应中的 usage 字段 +type OpenAIUsage struct { + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + TotalTokens int64 `json:"total_tokens"` +} + +// OpenAIResponse OpenAI 格式响应(简化) +type OpenAIResponse struct { + Usage *OpenAIUsage `json:"usage,omitempty"` +} + +// callBillingAPI 调用 billing 内部 API +func callBillingAPI(path string, body interface{}) ([]byte, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal: %w", err) + } + + req, err := http.NewRequest("POST", config.BillingEndpoint+path, bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Internal-Secret", config.InternalSecret) + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("do request: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("billing API error (%d): %s", resp.StatusCode, string(respBody)) + } + return respBody, nil +} + +// extractUsageFromSSE 从 SSE 流的最后一个 data chunk 提取 usage +func extractUsageFromSSE(body []byte) *OpenAIUsage { + lines := strings.Split(string(body), "\n") + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if !strings.HasPrefix(line, "data: ") { + continue + } + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + continue + } + var resp OpenAIResponse + if err := json.Unmarshal([]byte(data), &resp); err == nil && resp.Usage != nil { + return resp.Usage + } + } + return nil +} + +// extractUsageFromJSON 从 JSON 响应提取 usage +func extractUsageFromJSON(body []byte) *OpenAIUsage { + var resp OpenAIResponse + if err := json.Unmarshal(body, &resp); err == nil && resp.Usage != nil { + return resp.Usage + } + return nil +} func main() { // Wasm 插件入口 - // 实际实现需要使用 higress wasm-go SDK - // 参考: https://higress.cn/en/docs/latest/user/wasm-go/ + // 实际运行在 Higress Wasm 运行时中 + // 此处为编译占位 + 辅助函数库 + // + // 完整接入需要使用 higress wasm-go SDK: + // import "github.com/higress-group/wasm-go/pkg/wrapper" + // + // onHttpRequestBody: + // 1. 从请求中提取 model, API key + // 2. 解析 API key 得到 tenant_id, user_id + // 3. 生成 trace_id,注入 X-Trace-ID header + // 4. 调用 /internal/billing/pre-deduct + // 5. 额度不足时直接返回 402 + // + // onHttpResponseBody: + // 1. 从响应中提取 usage (支持 streaming SSE 和 JSON) + // 2. 调用 /internal/billing/settle + // + // 编译: GOOS=wasip1 GOARCH=wasm go build -o main.wasm . + // 部署: 打成 OCI 镜像,通过 Higress Console 或 WasmPlugin CRD 加载 } diff --git a/scripts/init-db.sql b/scripts/init-db.sql index 4003177..b71ddcb 100644 --- a/scripts/init-db.sql +++ b/scripts/init-db.sql @@ -33,6 +33,7 @@ CREATE TABLE IF NOT EXISTS users ( status TINYINT DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_tenant_user (tenant_id, username), + UNIQUE KEY uk_tenant_email (tenant_id, email), INDEX idx_tenant (tenant_id) ) ENGINE=InnoDB; @@ -184,6 +185,9 @@ CREATE TABLE IF NOT EXISTS role_permissions ( INSERT INTO tenants (slug, name, type, status) VALUES ('platform', 'AnyFast Platform', 'platform', 1) ON DUPLICATE KEY UPDATE name = VALUES(name); +-- Set default auth config for platform tenant +UPDATE tenants SET config = JSON_SET(COALESCE(config, '{}'), '$.auth', CAST('{"methods":["password","email_code","google","github"],"smtp":{},"oauth":{"google":{},"github":{}}}' AS JSON)) WHERE slug = 'platform'; + -- 初始化平台管理员 (密码: admin123, bcrypt hash) -- 生产环境请立即修改密码! INSERT INTO users (tenant_id, username, email, password, status) @@ -256,7 +260,9 @@ INSERT INTO permissions (id, menu_id, key_name, title) VALUES (73, 8, 'channel:delete', '删除渠道'), -- 监控 (platform only) (80, 9, 'monitoring:view', '查看监控'), -(81, 9, 'monitoring:logs', '查看日志') +(81, 9, 'monitoring:logs', '查看日志'), +-- 设置 +(110, 15, 'settings:update', '修改设置') ON DUPLICATE KEY UPDATE title = VALUES(title); -- ======== 角色-菜单关联 ======== diff --git a/scripts/migrate-billing.sql b/scripts/migrate-billing.sql new file mode 100644 index 0000000..f4dd8d9 --- /dev/null +++ b/scripts/migrate-billing.sql @@ -0,0 +1,145 @@ +-- Billing system migration +-- Run: mysql -u root -p anyfast < migrate-billing.sql + +-- ======== User quota & pricing group ======== +SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA='anyfast' AND TABLE_NAME='users' AND COLUMN_NAME='quota'); +SET @sql = IF(@col_exists = 0, 'ALTER TABLE users ADD COLUMN quota BIGINT DEFAULT 0 COMMENT "available quota balance"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA='anyfast' AND TABLE_NAME='users' AND COLUMN_NAME='used_quota'); +SET @sql = IF(@col_exists = 0, 'ALTER TABLE users ADD COLUMN used_quota BIGINT DEFAULT 0 COMMENT "total consumed quota"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA='anyfast' AND TABLE_NAME='users' AND COLUMN_NAME='pricing_group'); +SET @sql = IF(@col_exists = 0, 'ALTER TABLE users ADD COLUMN pricing_group VARCHAR(32) DEFAULT "default" COMMENT "pricing tier"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ======== Tenant quota (reseller balance) ======== +SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA='anyfast' AND TABLE_NAME='tenants' AND COLUMN_NAME='quota'); +SET @sql = IF(@col_exists = 0, 'ALTER TABLE tenants ADD COLUMN quota BIGINT DEFAULT 0 COMMENT "tenant available quota"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA='anyfast' AND TABLE_NAME='tenants' AND COLUMN_NAME='used_quota'); +SET @sql = IF(@col_exists = 0, 'ALTER TABLE tenants ADD COLUMN used_quota BIGINT DEFAULT 0 COMMENT "tenant consumed quota"', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ======== Model base pricing (global) ======== +CREATE TABLE IF NOT EXISTS model_pricing ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + model VARCHAR(64) NOT NULL UNIQUE COMMENT 'model identifier', + model_ratio DECIMAL(10,4) NOT NULL DEFAULT 1.0000 COMMENT 'base cost multiplier', + completion_ratio DECIMAL(10,4) NOT NULL DEFAULT 1.0000 COMMENT 'output/input ratio', + model_type VARCHAR(16) DEFAULT 'text' COMMENT 'text/image/audio/video', + enabled TINYINT DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB COMMENT 'global model pricing ratios'; + +-- ======== Tenant pricing override ======== +CREATE TABLE IF NOT EXISTS tenant_pricing ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + model VARCHAR(64) NOT NULL, + model_ratio DECIMAL(10,4) COMMENT 'override global ratio, NULL=inherit', + completion_ratio DECIMAL(10,4) COMMENT 'override global ratio, NULL=inherit', + enabled TINYINT DEFAULT 1 COMMENT '0=disable this model for tenant', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_model (tenant_id, model), + INDEX idx_tenant (tenant_id) +) ENGINE=InnoDB COMMENT 'per-tenant pricing overrides'; + +-- ======== Pricing groups ======== +-- Each tenant defines pricing groups with multipliers +-- Users are assigned to groups, group_ratio applied on top of model_ratio +CREATE TABLE IF NOT EXISTS pricing_groups ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + name VARCHAR(32) NOT NULL COMMENT 'group identifier: default/vip/wholesale', + display_name VARCHAR(64) COMMENT 'human readable name', + ratio DECIMAL(10,4) NOT NULL DEFAULT 1.0000 COMMENT 'price multiplier for this group', + description VARCHAR(255), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_group (tenant_id, name), + INDEX idx_tenant (tenant_id) +) ENGINE=InnoDB COMMENT 'pricing group definitions per tenant'; + +-- ======== Redemption codes ======== +CREATE TABLE IF NOT EXISTS redemptions ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + code VARCHAR(32) NOT NULL UNIQUE COMMENT 'redemption code', + name VARCHAR(64), + quota BIGINT NOT NULL COMMENT 'quota amount to grant', + status TINYINT DEFAULT 1 COMMENT '1=active 2=redeemed 3=disabled', + redeemed_by BIGINT COMMENT 'user_id who redeemed', + redeemed_at DATETIME, + expires_at DATETIME, + created_by BIGINT COMMENT 'user_id who created', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_tenant (tenant_id), + INDEX idx_code (code) +) ENGINE=InnoDB COMMENT 'quota redemption codes'; + +-- ======== Quota transactions (audit trail) ======== +CREATE TABLE IF NOT EXISTS quota_transactions ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + tenant_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + type VARCHAR(16) NOT NULL COMMENT 'topup/consume/refund/redeem/admin_adjust', + amount BIGINT NOT NULL COMMENT 'positive=credit negative=debit', + balance_after BIGINT NOT NULL COMMENT 'quota after this transaction', + reference_type VARCHAR(32) COMMENT 'redemption/stripe/request/admin', + reference_id VARCHAR(64) COMMENT 'redemption code / stripe session / request id', + remark VARCHAR(255), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_tenant_user (tenant_id, user_id), + INDEX idx_created (created_at) +) ENGINE=InnoDB COMMENT 'quota change audit log'; + +-- ======== Seed: model pricing ======== +INSERT INTO model_pricing (model, model_ratio, completion_ratio, model_type) VALUES +-- OpenAI +('gpt-4o', 2.5000, 3.0000, 'text'), +('gpt-4o-mini', 0.1500, 0.6000, 'text'), +('gpt-4-turbo', 10.0000, 3.0000, 'text'), +('gpt-3.5-turbo', 0.5000, 1.5000, 'text'), +('o1', 15.0000, 4.0000, 'text'), +('o1-mini', 3.0000, 4.0000, 'text'), +('o3-mini', 1.1000, 4.4000, 'text'), +-- Claude +('claude-opus-4-6', 15.0000, 5.0000, 'text'), +('claude-sonnet-4-6', 3.0000, 5.0000, 'text'), +('claude-haiku-4-5', 0.8000, 5.0000, 'text'), +-- Gemini +('gemini-2.5-pro', 1.2500, 2.0000, 'text'), +('gemini-2.5-flash', 0.1500, 2.6700, 'text'), +('gemini-2.0-flash', 0.1000, 2.0000, 'text'), +-- DeepSeek +('deepseek-chat', 0.1400, 2.0000, 'text'), +('deepseek-reasoner', 0.5500, 4.0000, 'text'), +-- Qwen +('qwen-max', 2.0000, 3.0000, 'text'), +('qwen-plus', 0.8000, 2.0000, 'text'), +('qwen-turbo', 0.3000, 1.5000, 'text'), +-- Embeddings +('text-embedding-3-small', 0.0200, 0.0000, 'text'), +('text-embedding-3-large', 0.1300, 0.0000, 'text'), +-- Image +('dall-e-3', 40.0000, 0.0000, 'image'), +('gpt-image-1', 10.0000, 0.0000, 'image') +ON DUPLICATE KEY UPDATE model_ratio = VALUES(model_ratio), completion_ratio = VALUES(completion_ratio); + +-- ======== Seed: platform pricing groups ======== +INSERT INTO pricing_groups (tenant_id, name, display_name, ratio, description) VALUES +(1, 'default', 'Standard', 1.0000, 'Standard pricing for regular users'), +(1, 'vip', 'VIP', 0.8500, '15% discount for VIP users'), +(1, 'wholesale', 'Wholesale', 0.5000, 'Wholesale pricing for resellers') +ON DUPLICATE KEY UPDATE ratio = VALUES(ratio); + +-- ======== Seed: give admin test quota ======== +UPDATE users SET quota = 5000000, pricing_group = 'default' +WHERE username = 'admin' AND tenant_id = 1; + +SELECT '✓ billing migration complete' AS result; +SELECT COUNT(*) AS model_count FROM model_pricing; +SELECT COUNT(*) AS group_count FROM pricing_groups; diff --git a/web/app/[locale]/(auth)/login/page.tsx b/web/app/[locale]/(auth)/login/page.tsx index d284944..0d8cb48 100644 --- a/web/app/[locale]/(auth)/login/page.tsx +++ b/web/app/[locale]/(auth)/login/page.tsx @@ -1,39 +1,181 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import { useTranslations } from 'next-intl' import { useRouter } from '@/core/i18n/navigation' import { useAuth } from '@/shared/providers/auth-provider' import { LocaleSwitcher } from '@/shared/components/locale-switcher' -import axios from 'axios' +import { authApi } from '@/modules/auth' +import type { AuthMethod } from '@/modules/auth' +import { Mail, ArrowLeft, Loader2, Lock } from 'lucide-react' +import { AxiosError } from 'axios' + +type EmailStep = 'email' | 'code' +type PageView = 'login' | 'set-password' + export default function LoginPage() { const t = useTranslations('auth') + const { login } = useAuth() + const router = useRouter() + + // Page view + const [view, setView] = useState('login') + + // Dynamic auth methods + const [methods, setMethods] = useState([]) + const [methodsLoading, setMethodsLoading] = useState(true) + const [brand, setBrand] = useState({ name: 'AnyFast Cloud', logo: '', themeColor: '#6c8cff' }) + + // Password login state const [account, setAccount] = useState('') const [password, setPassword] = useState('') + const [passwordError, setPasswordError] = useState('') + + // Email code flow state + const [emailStep, setEmailStep] = useState('email') + const [email, setEmail] = useState('') + const [code, setCode] = useState('') + const [countdown, setCountdown] = useState(0) + const timerRef = useRef | null>(null) + const codeInputRef = useRef(null) + + // Set password state (new user after verify-code) + const [newPassword, setNewPassword] = useState('') + const [confirmPwd, setConfirmPwd] = useState('') + const [newUserToken, setNewUserToken] = useState('') + + // Shared state const [error, setError] = useState('') const [loading, setLoading] = useState(false) - const [providers, setProviders] = useState([]) - const { login } = useAuth() - const router = useRouter() + const [welcomeMsg, setWelcomeMsg] = useState('') + + // Computed flags + const hasPassword = methods.includes('password') + const hasEmailCode = methods.includes('email_code') + const hasGoogle = methods.includes('google') + const hasGithub = methods.includes('github') + const hasOAuth = hasGoogle || hasGithub - // 获取已启用的 OAuth providers + // Load methods + branding useEffect(() => { - axios.get('/api/auth/providers').then((res) => { - setProviders(res.data?.providers || []) + authApi.getProviders() + .then((r) => setMethods(r.methods || r.providers?.map(() => 'google' as AuthMethod) || [])) + .catch(() => setMethods(['password'])) + .finally(() => setMethodsLoading(false)) + + authApi.getBranding().then((r) => { + if (r.tenant_name) { + setBrand({ name: r.tenant_name, logo: r.logo_url || '', themeColor: r.theme_color || '#6c8cff' }) + } }).catch(() => {}) }, []) - const handleSubmit = async (e: React.FormEvent) => { + // Countdown timer + const startCountdown = useCallback((seconds: number) => { + setCountdown(seconds) + if (timerRef.current) clearInterval(timerRef.current) + timerRef.current = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + if (timerRef.current) clearInterval(timerRef.current) + return 0 + } + return prev - 1 + }) + }, 1000) + }, []) + + useEffect(() => { + return () => { if (timerRef.current) clearInterval(timerRef.current) } + }, []) + + // ── Handlers ───────────────────────────────────────── + + const handlePasswordLogin = async (e: React.FormEvent) => { + e.preventDefault() + setPasswordError('') + setLoading(true) + try { + const res = await authApi.loginWithPassword({ account, password }) + login(res.access_token, res.user) + router.push('/dashboard') + } catch (err) { + if (err instanceof AxiosError && err.response?.status === 401 && err.response.data?.error === 'account_not_found') { + setPasswordError('account_not_found') + } else { + setPasswordError('generic') + } + } finally { + setLoading(false) + } + } + + const handleSwitchToEmailCode = () => { + setPasswordError('') + setEmail(account.includes('@') ? account : '') + // Scroll to or focus the email code section + } + + const handleSendCode = async (e?: React.FormEvent) => { + e?.preventDefault() + if (!email || countdown > 0) return + setError('') + setLoading(true) + try { + await authApi.sendCode({ email }) + setEmailStep('code') + setCode('') + startCountdown(60) + setTimeout(() => codeInputRef.current?.focus(), 100) + } catch (err) { + if (err instanceof AxiosError && err.response?.status === 429) { + const retryAfter = err.response.data?.retry_after || 60 + startCountdown(retryAfter) + setError(t('rateLimited', { seconds: retryAfter })) + } else { + setError(t('loginError')) + } + } finally { + setLoading(false) + } + } + + const handleVerifyCode = async (e: React.FormEvent) => { e.preventDefault() + if (!code || code.length < 6) return setError('') setLoading(true) + try { + const res = await authApi.verifyCode({ email, code }) + login(res.access_token, res.user) + if (res.is_new_user) { + // Show set-password step instead of redirecting + setNewUserToken(res.access_token) + setView('set-password') + } else { + router.push('/dashboard') + } + } catch (err) { + if (err instanceof AxiosError && err.response?.status === 401) { + setError(err.response.data?.error === 'code_expired' ? t('codeExpired') : t('invalidCode')) + } else { + setError(t('loginError')) + } + } finally { + setLoading(false) + } + } + const handleSetPassword = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + if (newPassword.length < 6) { setError(t('passwordTooShort')); return } + if (newPassword !== confirmPwd) { setError(t('passwordMismatch')); return } + setLoading(true) try { - const res = await axios.post('/api/auth/login', { account, password }, { - headers: { 'X-Forwarded-Host': window.location.host }, - }) - login(res.data.access_token, res.data.user) - router.push('/dashboard') + await authApi.setPassword(newPassword, newUserToken) + setWelcomeMsg(t('welcome')) + setTimeout(() => router.push('/dashboard'), 1500) } catch { setError(t('loginError')) } finally { @@ -41,88 +183,228 @@ export default function LoginPage() { } } + const handleSkipSetPassword = () => { + setWelcomeMsg(t('welcome')) + setTimeout(() => router.push('/dashboard'), 1500) + } + const handleOAuthLogin = (provider: string) => { - // 重定向到后端 OAuth 入口,后端会跳转到 Google/GitHub 等 const callbackUrl = encodeURIComponent(window.location.pathname) window.location.href = `/api/auth/${provider}?callback_url=${callbackUrl}` } + // ── Render helpers ─────────────────────────────────── + + const renderDivider = () => ( +
+
+
+
+
+ {t('or')} +
+
+ ) + + const renderOAuthButtons = () => ( +
+ {hasGoogle && ( + + )} + {hasGithub && ( + + )} +
+ ) + + const renderPasswordForm = () => ( +
+
+ + { setAccount(e.target.value); setPasswordError('') }} + className="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm outline-none transition focus:border-blue-400 focus:bg-white focus:ring-2 focus:ring-blue-100" + placeholder={t('accountPlaceholder')} autoFocus={!hasOAuth} required /> +
+
+ + { setPassword(e.target.value); setPasswordError('') }} + className="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm outline-none transition focus:border-blue-400 focus:bg-white focus:ring-2 focus:ring-blue-100" + placeholder={t('passwordPlaceholder')} required /> +
+ {passwordError === 'account_not_found' && ( +
+

{t('accountNotFound')}

+ {hasEmailCode && ( + + )} +
+ )} + {passwordError === 'generic' &&

{t('loginError')}

} + +
+ ) + + const renderEmailCodeFlow = () => { + if (emailStep === 'code') { + return ( +
+
+ +

{t('codeSent', { email })}

+
+
+ + setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + className="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-center text-lg font-semibold tracking-[0.5em] outline-none transition focus:border-blue-400 focus:bg-white focus:ring-2 focus:ring-blue-100" + placeholder={t('codePlaceholder')} autoComplete="one-time-code" autoFocus /> +
+ {error &&

{error}

} + +
+ +
+
+ ) + } + + return ( +
+
+ +
+ + setEmail(e.target.value)} + className="w-full rounded-lg border border-slate-200 bg-slate-50 py-2.5 pl-10 pr-3 text-sm outline-none transition focus:border-blue-400 focus:bg-white focus:ring-2 focus:ring-blue-100" + placeholder={t('emailPlaceholder')} autoFocus={!hasOAuth && !hasPassword} required /> +
+
+ {error &&

{error}

} + +
+ ) + } + + const renderSetPasswordView = () => ( +
+
+
+ +
+

{t('setPasswordTitle')}

+

{t('setPasswordDesc')}

+
+ +
+
+ + setNewPassword(e.target.value)} + className="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm outline-none transition focus:border-blue-400 focus:bg-white focus:ring-2 focus:ring-blue-100" + placeholder={t('newPasswordPlaceholder')} autoFocus required /> +
+
+ + setConfirmPwd(e.target.value)} + className="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm outline-none transition focus:border-blue-400 focus:bg-white focus:ring-2 focus:ring-blue-100" + placeholder={t('confirmPasswordPlaceholder')} required /> +
+ {error &&

{error}

} + +
+ +
+ ) + + // ── Main render ────────────────────────────────────── + return (
+ {/* Header */}
-
-

{t('title')}

-

{t('subtitle')}

+
+ {brand.logo ? ( + {brand.name} + ) : ( +
+ {brand.name[0]} +
+ )} +
+

{brand.name}

+

{t('subtitle')}

+
- {/* SSO OAuth buttons */} - {providers.length > 0 && ( -
- {providers.includes('google') && ( - - )} - - {providers.includes('github') && ( - - )} - - {/* Divider */} -
-
-
-
-
- {t('or')} -
-
+ {/* Welcome toast */} + {welcomeMsg && ( +
+ {welcomeMsg}
)} - {/* Email/password form */} -
-
- - setAccount(e.target.value)} - className="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm outline-none transition focus:border-blue-400 focus:bg-white focus:ring-2 focus:ring-blue-100" - placeholder={t('accountPlaceholder')} autoFocus /> -
-
- - setPassword(e.target.value)} - className="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm outline-none transition focus:border-blue-400 focus:bg-white focus:ring-2 focus:ring-blue-100" - placeholder={t('passwordPlaceholder')} /> -
- {error &&

{error}

} - -
+ {/* Set password view (after new user registration) */} + {view === 'set-password' ? renderSetPasswordView() : ( + <> + {/* Loading state */} + {methodsLoading ? ( +
+ + {t('loadingMethods')} +
+ ) : ( + <> + {hasOAuth && renderOAuthButtons()} + {hasOAuth && (hasPassword || hasEmailCode) && renderDivider()} + {hasPassword && renderPasswordForm()} + {hasPassword && hasEmailCode && renderDivider()} + {hasEmailCode && renderEmailCodeFlow()} + + )} + + )}
-

{t('defaultCredentials')}

) diff --git a/web/app/[locale]/(console)/account/page.tsx b/web/app/[locale]/(console)/account/page.tsx new file mode 100644 index 0000000..44c86fa --- /dev/null +++ b/web/app/[locale]/(console)/account/page.tsx @@ -0,0 +1,22 @@ +'use client' + +import { useTranslations } from 'next-intl' +import { ProfileCard } from '@/modules/account/components/profile-card' +import { ChangePasswordForm } from '@/modules/account/components/change-password-form' +import { ChangeEmailForm } from '@/modules/account/components/change-email-form' + +export default function AccountPage() { + const t = useTranslations('account') + + return ( +
+
+

{t('title')}

+

{t('subtitle')}

+
+ + + +
+ ) +} diff --git a/web/app/[locale]/(console)/billing/page.tsx b/web/app/[locale]/(console)/billing/page.tsx index 5dd3c1d..359aa06 100644 --- a/web/app/[locale]/(console)/billing/page.tsx +++ b/web/app/[locale]/(console)/billing/page.tsx @@ -1,18 +1,24 @@ 'use client' + import { useTranslations } from 'next-intl' +import { RedemptionTable } from '@/modules/billing/components/redemption-table' +import { CreateRedemptionDialog } from '@/modules/billing/components/create-redemption-dialog' +import { RedeemCodeCard } from '@/modules/billing/components/redeem-code-card' -export default function Page() { +export default function BillingPage() { const t = useTranslations('billing') - const tc = useTranslations('common') + return (
-
-

{t('title')}

-

{t('subtitle')}

-
-
-

{tc('developing')}

+
+
+

{t('title')}

+

{t('subtitle')}

+
+
+ +
) } diff --git a/web/app/[locale]/(console)/dashboard/page.tsx b/web/app/[locale]/(console)/dashboard/page.tsx index a163660..29ce15e 100644 --- a/web/app/[locale]/(console)/dashboard/page.tsx +++ b/web/app/[locale]/(console)/dashboard/page.tsx @@ -2,11 +2,27 @@ import { useTranslations } from 'next-intl' import { useAuth } from '@/shared/providers/auth-provider' -import { Key, Activity, Users, Server } from 'lucide-react' +import { useMyQuota } from '@/modules/quota' +import { Key, Activity, Users, Server, Wallet, TrendingUp } from 'lucide-react' +import { cn } from '@/shared/lib/utils' + +function formatQuota(n: number): string { + if (n === -1) return '∞' + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M' + if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K' + return n.toLocaleString() +} export default function DashboardPage() { const t = useTranslations('dashboard') const { user } = useAuth() + const { data: quotaInfo } = useMyQuota() + + const quota = quotaInfo?.quota ?? 0 + const usedQuota = quotaInfo?.used_quota ?? 0 + const isUnlimited = quota === -1 + const total = isUnlimited ? 0 : quota + usedQuota + const pct = isUnlimited ? 0 : total > 0 ? (usedQuota / total) * 100 : 0 const stats = [ { label: t('apiKeys'), value: '—', icon: Key }, @@ -23,6 +39,42 @@ export default function DashboardPage() {
+ {/* Quota card */} +
+
+
+
+ +
+
+

{t('remainingQuota')}

+

{formatQuota(quota)}

+
+
+
+
+ + {t('usedQuota')} +
+

{formatQuota(usedQuota)}

+
+
+ {!isUnlimited && ( +
+
+ {t('usagePercent', { pct: pct.toFixed(1) })} + {t('totalQuota')}: {formatQuota(total)} +
+
+
80 ? 'bg-red-400' : 'bg-blue-500')} + style={{ width: `${Math.min(pct, 100)}%` }} + /> +
+
+ )} +
+
{stats.map((stat) => (
diff --git a/web/app/[locale]/(console)/pricing/page.tsx b/web/app/[locale]/(console)/pricing/page.tsx index 1038a70..880875c 100644 --- a/web/app/[locale]/(console)/pricing/page.tsx +++ b/web/app/[locale]/(console)/pricing/page.tsx @@ -1,18 +1,20 @@ 'use client' + import { useTranslations } from 'next-intl' +import { ModelPricingTable } from '@/modules/pricing/components/model-pricing-table' +import { GroupRatioTable } from '@/modules/pricing/components/group-ratio-table' -export default function Page() { +export default function PricingPage() { const t = useTranslations('pricing') - const tc = useTranslations('common') + return ( -
+

{t('title')}

{t('subtitle')}

-
-

{tc('developing')}

-
+ +
) } diff --git a/web/app/[locale]/(console)/settings/page.tsx b/web/app/[locale]/(console)/settings/page.tsx index c84a663..ef84319 100644 --- a/web/app/[locale]/(console)/settings/page.tsx +++ b/web/app/[locale]/(console)/settings/page.tsx @@ -1,17 +1,22 @@ 'use client' + import { useTranslations } from 'next-intl' +import { AuthSettingsForm } from '@/modules/settings/components/auth-settings-form' -export default function Page() { +export default function SettingsPage() { const t = useTranslations('settings') - const tc = useTranslations('common') + return (

{t('title')}

{t('subtitle')}

-
-

{tc('developing')}

+ +
+

{t('authSettings')}

+

{t('authSettingsDesc')}

+
) diff --git a/web/app/[locale]/(console)/usage/page.tsx b/web/app/[locale]/(console)/usage/page.tsx index c4b9719..0d370bf 100644 --- a/web/app/[locale]/(console)/usage/page.tsx +++ b/web/app/[locale]/(console)/usage/page.tsx @@ -1,18 +1,76 @@ 'use client' + +import { useState } from 'react' import { useTranslations } from 'next-intl' +import { UsageSummaryCards } from '@/modules/usage/components/usage-summary' +import { UsageChart } from '@/modules/usage/components/usage-chart' +import { UsageLogsTable } from '@/modules/usage/components/usage-logs-table' +import { cn } from '@/shared/lib/utils' +import type { UsageQueryParams } from '@/modules/usage/types/usage.types' + +type TimeRange = '7d' | '30d' +type GroupBy = 'day' | 'model' + +function getDateRange(range: TimeRange): { start_date: string; end_date: string } { + const end = new Date() + const start = new Date() + start.setDate(end.getDate() - (range === '7d' ? 7 : 30)) + return { + start_date: start.toISOString().split('T')[0], + end_date: end.toISOString().split('T')[0], + } +} -export default function Page() { +export default function UsagePage() { const t = useTranslations('usage') - const tc = useTranslations('common') + const [range, setRange] = useState('7d') + const [groupBy, setGroupBy] = useState('day') + + const dates = getDateRange(range) + const params: UsageQueryParams = { ...dates } + return (
-
-

{t('title')}

-

{t('subtitle')}

-
-
-

{tc('developing')}

+
+
+

{t('title')}

+

{t('subtitle')}

+
+
+
+ {([['7d', t('last7days')], ['30d', t('last30days')]] as const).map(([key, label]) => ( + + ))} +
+
+ {([['day', t('viewByDay')], ['model', t('viewByModel')]] as const).map(([key, label]) => ( + + ))} +
+
+ + + +
) } diff --git a/web/app/[locale]/(landing)/page.tsx b/web/app/[locale]/(landing)/page.tsx index 6fc8939..8fc3272 100644 --- a/web/app/[locale]/(landing)/page.tsx +++ b/web/app/[locale]/(landing)/page.tsx @@ -21,10 +21,28 @@ const defaultBranding: TenantBranding = { } function useTenantBranding(): TenantBranding { - // TODO: 从后端 GET /api/auth/branding?tenant=xxx 获取租户品牌配置 - // 根据当前域名自动识别租户,返回 name/logo/domain/themeColor - // 代理商域名访问时,这里会返回代理商的品牌信息 - return defaultBranding + const [brand, setBrand] = useState(defaultBranding) + + useEffect(() => { + fetch('/api/auth/branding', { + headers: { 'X-Forwarded-Host': window.location.host }, + }) + .then((r) => r.json()) + .then((data) => { + if (data.tenant_name) { + setBrand({ + name: data.tenant_name, + logo: data.logo_url || undefined, + domain: window.location.host, + docsUrl: data.slug === 'platform' ? 'https://docs.anyfast.ai' : undefined, + themeColor: data.theme_color || 'emerald', + }) + } + }) + .catch(() => {}) + }, []) + + return brand } /* ─── i18n content ─── */ diff --git a/web/config/locale/index.ts b/web/config/locale/index.ts index cd174a6..2363a72 100644 --- a/web/config/locale/index.ts +++ b/web/config/locale/index.ts @@ -24,4 +24,5 @@ export const localeMessagesPaths = [ 'channels', 'monitoring', 'settings', + 'account', ] diff --git a/web/config/locale/messages/en/account.json b/web/config/locale/messages/en/account.json new file mode 100644 index 0000000..b6a13c0 --- /dev/null +++ b/web/config/locale/messages/en/account.json @@ -0,0 +1,38 @@ +{ + "title": "Account Settings", + "subtitle": "Manage your profile and security settings", + "profile": "Profile", + "username": "Username", + "email": "Email", + "createdAt": "Joined", + "changePassword": "Change Password", + "changePasswordDesc": "Update your login password", + "oldPassword": "Current Password", + "oldPasswordPlaceholder": "Enter current password", + "newPassword": "New Password", + "newPasswordPlaceholder": "At least 6 characters", + "confirmPassword": "Confirm New Password", + "confirmPasswordPlaceholder": "Enter new password again", + "passwordMismatch": "Passwords do not match", + "passwordTooShort": "Password must be at least 6 characters", + "updatePassword": "Update Password", + "updatingPassword": "Updating...", + "passwordUpdated": "Password updated", + "invalidOldPassword": "Current password is incorrect", + "noPasswordSet": "You have not set a password yet. Set one to enable password login.", + "changeEmail": "Change Email", + "changeEmailDesc": "Update your login email", + "newEmail": "New Email", + "newEmailPlaceholder": "new@example.com", + "sendVerification": "Send Code", + "sendingVerification": "Sending...", + "verificationSent": "Code sent to {email}", + "verificationCode": "Verification Code", + "verificationCodePlaceholder": "6-digit code", + "confirmChange": "Confirm Change", + "confirming": "Confirming...", + "emailUpdated": "Email updated", + "resendIn": "Resend in {seconds}s", + "invalidCode": "Invalid code, please try again", + "changePasswordError": "Failed to change password, please try again" +} diff --git a/web/config/locale/messages/en/auth.json b/web/config/locale/messages/en/auth.json index e80d6a6..bf4c439 100644 --- a/web/config/locale/messages/en/auth.json +++ b/web/config/locale/messages/en/auth.json @@ -1,16 +1,45 @@ { "title": "AnyFast Cloud", "subtitle": "Multi-tenant LLM API Management Platform", + "loginWithGoogle": "Continue with Google", + "loginWithGithub": "Continue with GitHub", + "loginWithEmail": "Sign in with email", + "or": "or", "account": "Account", "accountPlaceholder": "Email or username", "password": "Password", "passwordPlaceholder": "Enter your password", "login": "Sign in", "loggingIn": "Signing in...", - "loginError": "Invalid credentials", - "loginWithGoogle": "Continue with Google", - "loginWithGithub": "Continue with GitHub", - "or": "or", - "defaultCredentials": "Default: admin / admin123", - "logout": "Sign out" + "email": "Email", + "emailPlaceholder": "you@example.com", + "sendCode": "Send Code", + "sendingCode": "Sending...", + "resendCode": "Resend in {seconds}s", + "codeSent": "Code sent to {email}", + "enterCode": "Enter verification code", + "codePlaceholder": "6-digit code", + "verify": "Verify & Sign in", + "verifying": "Verifying...", + "changeEmail": "Change email", + "codeExpired": "Code expired, please resend", + "invalidCode": "Invalid code, please try again", + "rateLimited": "Too many requests, retry in {seconds}s", + "loginError": "Sign in failed, please try again", + "welcome": "Welcome! Your account has been created", + "logout": "Sign out", + "loadingMethods": "Loading...", + "accountNotFound": "Account not found. Please register with email first.", + "switchToEmailCode": "Register with email code", + "setPasswordTitle": "Set Password", + "setPasswordDesc": "Set a password for your new account for future login", + "newPassword": "Password", + "newPasswordPlaceholder": "At least 6 characters", + "confirmPassword": "Confirm Password", + "confirmPasswordPlaceholder": "Enter password again", + "passwordMismatch": "Passwords do not match", + "passwordTooShort": "Password must be at least 6 characters", + "setPassword": "Set Password", + "settingPassword": "Setting...", + "skipSetPassword": "Skip for now" } diff --git a/web/config/locale/messages/en/billing.json b/web/config/locale/messages/en/billing.json index bafe5ac..86084a6 100644 --- a/web/config/locale/messages/en/billing.json +++ b/web/config/locale/messages/en/billing.json @@ -1,4 +1,30 @@ { "title": "Billing", - "subtitle": "View invoices and settlement records" + "subtitle": "Redemption codes & quota recharge", + "codeName": "Name", + "code": "Code", + "quota": "Quota", + "expiredAt": "Expires", + "createdAt": "Created", + "available": "Available", + "used": "Used", + "disabled": "Disabled", + "disableCode": "Disable", + "disableConfirm": "Disable this redemption code?", + "createCode": "Create Code", + "createCodeTitle": "Create Redemption Code", + "createSuccess": "Created Successfully", + "codeNamePlaceholder": "e.g. VIP recharge code", + "generateCount": "Count", + "generate": "Generate", + "generating": "Generating...", + "codesGenerated": "{count} codes generated", + "copyAll": "Copy All", + "redeemTitle": "Redeem Code", + "redeemPlaceholder": "Enter redemption code", + "redeem": "Redeem", + "redeeming": "Redeeming...", + "redeemSuccess": "Redeemed successfully, received {quota} quota", + "redeemAnother": "Redeem Another", + "rechargeRecords": "Recharge Records" } diff --git a/web/config/locale/messages/en/dashboard.json b/web/config/locale/messages/en/dashboard.json index 8972a4d..f5b4fee 100644 --- a/web/config/locale/messages/en/dashboard.json +++ b/web/config/locale/messages/en/dashboard.json @@ -7,5 +7,9 @@ "quickStart": "Quick Start", "step1": "Create an API key on the API Key page", "step2": "Use the key to call /v1/chat/completions", - "step3": "View usage data on the Usage page" + "step3": "View usage data on the Usage page", + "remainingQuota": "Remaining Quota", + "usedQuota": "Used Quota", + "usagePercent": "{pct}% used", + "totalQuota": "Total Quota" } diff --git a/web/config/locale/messages/en/pricing.json b/web/config/locale/messages/en/pricing.json index babd1a7..2779160 100644 --- a/web/config/locale/messages/en/pricing.json +++ b/web/config/locale/messages/en/pricing.json @@ -1,4 +1,23 @@ { "title": "Pricing", - "subtitle": "Set input/output prices for each model" + "subtitle": "Set input/output prices for each model", + "model": "Model", + "modelType": "Type", + "modelRatio": "Model Ratio", + "completionRatio": "Completion Ratio", + "inputCost": "Input Cost", + "outputCost": "Output Cost", + "override": "Override", + "removeOverride": "Remove Override", + "deleteConfirm": "Remove custom pricing for this model?", + "groupRatios": "Group Ratios", + "addGroup": "Add Group", + "groupName": "Group ID", + "displayName": "Display Name", + "ratio": "Ratio", + "priceEffect": "Price Effect", + "standardPrice": "Standard", + "discount": "{pct}% off", + "markup": "{pct}% markup", + "deleteGroupConfirm": "Delete this group?" } diff --git a/web/config/locale/messages/en/settings.json b/web/config/locale/messages/en/settings.json index 6483569..1f90159 100644 --- a/web/config/locale/messages/en/settings.json +++ b/web/config/locale/messages/en/settings.json @@ -1,4 +1,55 @@ { "title": "Settings", - "subtitle": "Tenant settings, domains and branding" + "subtitle": "Tenant settings, domains and branding", + "authSettings": "Authentication", + "authSettingsDesc": "Configure login methods and third-party auth", + "loginMethods": "Login Methods", + "loginMethodsDesc": "Choose available login methods for users", + "methodPassword": "Password", + "methodPasswordDesc": "Username + password login", + "methodEmailCode": "Email Code", + "methodEmailCodeDesc": "Login via email verification code", + "methodGoogle": "Google", + "methodGoogleDesc": "Sign in with Google account", + "methodGithub": "GitHub", + "methodGithubDesc": "Sign in with GitHub account", + "smtpConfig": "SMTP Configuration", + "smtpConfigDesc": "Required for email verification code login", + "smtpHost": "SMTP Host", + "smtpHostPlaceholder": "smtp.example.com", + "smtpPort": "Port", + "smtpUsername": "Username", + "smtpUsernamePlaceholder": "noreply@example.com", + "smtpPassword": "Password", + "smtpPasswordPlaceholder": "SMTP password", + "smtpFrom": "From Address", + "smtpFromPlaceholder": "noreply@example.com", + "smtpFromName": "From Name", + "smtpFromNamePlaceholder": "My Company", + "testSmtp": "Send Test Email", + "testSmtpTo": "Test Recipient", + "testSmtpToPlaceholder": "admin@example.com", + "testSmtpSending": "Sending...", + "testSmtpSuccess": "Test email sent", + "testSmtpError": "Failed: {error}", + "googleOAuth": "Google OAuth", + "githubOAuth": "GitHub OAuth", + "clientId": "Client ID", + "clientIdPlaceholder": "xxx.apps.googleusercontent.com", + "clientSecret": "Client Secret", + "clientSecretPlaceholder": "OAuth Client Secret", + "saveSettings": "Save Settings", + "saving": "Saving...", + "saveSuccess": "Settings saved", + "saveError": "Save failed", + "validationEmailCodeNeedsSmtp": "Email code requires SMTP configuration", + "validationGoogleNeedsCreds": "Google login requires OAuth credentials", + "validationGithubNeedsCreds": "GitHub login requires OAuth credentials", + "validationAtLeastOneMethod": "At least one login method must be enabled", + "smtpGuideTitle": "Setup Guide", + "smtpGuideGmail": "Requires a Google App Password — your regular Google password won't work", + "smtpGuideAWS": "Create SMTP credentials in AWS Console under SES. Replace {region} with your region (e.g. us-east-1)", + "smtpGuideResend": "Create an API Key at resend.com and use it as the SMTP password. Username is 'resend'", + "smtpGuideAliyunTitle": "Alibaba Cloud Mail", + "smtpGuideAliyun": "Use Alibaba Cloud enterprise mail SMTP address, port 465 (SSL)" } diff --git a/web/config/locale/messages/en/usage.json b/web/config/locale/messages/en/usage.json index cd796e3..1dd83e8 100644 --- a/web/config/locale/messages/en/usage.json +++ b/web/config/locale/messages/en/usage.json @@ -1,4 +1,24 @@ { "title": "Usage", - "subtitle": "View API calls and token consumption" + "subtitle": "View API calls and token consumption", + "totalRequests": "Total Requests", + "totalTokens": "Total Tokens", + "promptTokens": "Prompt Tokens", + "quotaCost": "Quota Cost", + "byDay": "By Day", + "byModel": "By Model", + "requests": "requests", + "recentLogs": "Request Logs", + "time": "Time", + "model": "Model", + "tokenName": "Key Name", + "promptTokensShort": "Prompt", + "completionTokensShort": "Completion", + "cost": "Cost", + "latency": "Latency", + "stream": "Mode", + "last7days": "Last 7 days", + "last30days": "Last 30 days", + "viewByDay": "By Day", + "viewByModel": "By Model" } diff --git a/web/config/locale/messages/zh/account.json b/web/config/locale/messages/zh/account.json new file mode 100644 index 0000000..8be179d --- /dev/null +++ b/web/config/locale/messages/zh/account.json @@ -0,0 +1,38 @@ +{ + "title": "账户设置", + "subtitle": "管理你的个人信息和安全设置", + "profile": "个人信息", + "username": "用户名", + "email": "邮箱", + "createdAt": "注册时间", + "changePassword": "修改密码", + "changePasswordDesc": "更新你的登录密码", + "oldPassword": "当前密码", + "oldPasswordPlaceholder": "输入当前密码", + "newPassword": "新密码", + "newPasswordPlaceholder": "至少 6 位", + "confirmPassword": "确认新密码", + "confirmPasswordPlaceholder": "再次输入新密码", + "passwordMismatch": "两次输入的密码不一致", + "passwordTooShort": "密码至少 6 位", + "updatePassword": "修改密码", + "updatingPassword": "修改中...", + "passwordUpdated": "密码已修改", + "invalidOldPassword": "当前密码错误", + "noPasswordSet": "你还未设置密码。设置密码后可使用密码登录。", + "changeEmail": "修改邮箱", + "changeEmailDesc": "更新你的登录邮箱", + "newEmail": "新邮箱", + "newEmailPlaceholder": "new@example.com", + "sendVerification": "发送验证码", + "sendingVerification": "发送中...", + "verificationSent": "验证码已发送到 {email}", + "verificationCode": "验证码", + "verificationCodePlaceholder": "6 位验证码", + "confirmChange": "确认修改", + "confirming": "确认中...", + "emailUpdated": "邮箱已修改", + "resendIn": "{seconds}s 后可重发", + "invalidCode": "验证码错误,请重试", + "changePasswordError": "修改密码失败,请重试" +} diff --git a/web/config/locale/messages/zh/auth.json b/web/config/locale/messages/zh/auth.json index 30fdcbe..efe5958 100644 --- a/web/config/locale/messages/zh/auth.json +++ b/web/config/locale/messages/zh/auth.json @@ -1,16 +1,45 @@ { "title": "AnyFast Cloud", "subtitle": "多租户 LLM API 管理平台", + "loginWithGoogle": "使用 Google 登录", + "loginWithGithub": "使用 GitHub 登录", + "loginWithEmail": "邮箱验证码登录", + "or": "或", "account": "账号", "accountPlaceholder": "邮箱或用户名", "password": "密码", "passwordPlaceholder": "请输入密码", "login": "登录", "loggingIn": "登录中...", - "loginError": "登录失败,请检查账号密码", - "loginWithGoogle": "使用 Google 登录", - "loginWithGithub": "使用 GitHub 登录", - "or": "或", - "defaultCredentials": "默认账号: admin / admin123", - "logout": "退出登录" + "email": "邮箱地址", + "emailPlaceholder": "you@example.com", + "sendCode": "发送验证码", + "sendingCode": "发送中...", + "resendCode": "{seconds}s 后可重发", + "codeSent": "验证码已发送到 {email}", + "enterCode": "输入验证码", + "codePlaceholder": "6 位验证码", + "verify": "验证并登录", + "verifying": "验证中...", + "changeEmail": "更换邮箱", + "codeExpired": "验证码已过期,请重新发送", + "invalidCode": "验证码错误,请重试", + "rateLimited": "操作过于频繁,请 {seconds}s 后重试", + "loginError": "登录失败,请重试", + "welcome": "欢迎加入!账号已自动创建", + "logout": "退出登录", + "loadingMethods": "正在加载...", + "accountNotFound": "该账号不存在,请先通过邮箱注册", + "switchToEmailCode": "使用邮箱验证码注册", + "setPasswordTitle": "设置密码", + "setPasswordDesc": "为你的新账号设置一个密码,方便下次登录", + "newPassword": "设置密码", + "newPasswordPlaceholder": "至少 6 位", + "confirmPassword": "确认密码", + "confirmPasswordPlaceholder": "再次输入密码", + "passwordMismatch": "两次输入的密码不一致", + "passwordTooShort": "密码至少 6 位", + "setPassword": "设置密码", + "settingPassword": "设置中...", + "skipSetPassword": "跳过,稍后设置" } diff --git a/web/config/locale/messages/zh/billing.json b/web/config/locale/messages/zh/billing.json index 30ece3e..b39d45f 100644 --- a/web/config/locale/messages/zh/billing.json +++ b/web/config/locale/messages/zh/billing.json @@ -1,4 +1,30 @@ { - "title": "账单", - "subtitle": "查看账单明细和结算记录" + "title": "充值管理", + "subtitle": "兑换码管理与额度充值", + "codeName": "名称", + "code": "兑换码", + "quota": "额度", + "expiredAt": "过期时间", + "createdAt": "创建时间", + "available": "可用", + "used": "已用", + "disabled": "已禁用", + "disableCode": "禁用", + "disableConfirm": "确定禁用该兑换码?", + "createCode": "创建兑换码", + "createCodeTitle": "创建兑换码", + "createSuccess": "创建成功", + "codeNamePlaceholder": "如:VIP充值码", + "generateCount": "生成数量", + "generate": "生成", + "generating": "生成中...", + "codesGenerated": "已生成 {count} 个兑换码", + "copyAll": "复制全部", + "redeemTitle": "兑换码充值", + "redeemPlaceholder": "输入兑换码", + "redeem": "兑换", + "redeeming": "兑换中...", + "redeemSuccess": "兑换成功,获得 {quota} 额度", + "redeemAnother": "继续兑换", + "rechargeRecords": "充值记录" } diff --git a/web/config/locale/messages/zh/dashboard.json b/web/config/locale/messages/zh/dashboard.json index 46a5651..91839ca 100644 --- a/web/config/locale/messages/zh/dashboard.json +++ b/web/config/locale/messages/zh/dashboard.json @@ -7,5 +7,9 @@ "quickStart": "快速开始", "step1": "前往 API Key 页面创建一个密钥", "step2": "使用密钥调用 /v1/chat/completions 接口", - "step3": "在用量页面查看消费数据" + "step3": "在用量页面查看消费数据", + "remainingQuota": "可用额度", + "usedQuota": "已用额度", + "usagePercent": "已用 {pct}%", + "totalQuota": "总额度" } diff --git a/web/config/locale/messages/zh/pricing.json b/web/config/locale/messages/zh/pricing.json index e724c06..8c272e8 100644 --- a/web/config/locale/messages/zh/pricing.json +++ b/web/config/locale/messages/zh/pricing.json @@ -1,4 +1,23 @@ { "title": "模型定价", - "subtitle": "设置各模型的输入/输出价格" + "subtitle": "设置各模型的输入/输出价格", + "model": "模型", + "modelType": "类型", + "modelRatio": "模型倍率", + "completionRatio": "补全倍率", + "inputCost": "输入价格", + "outputCost": "输出价格", + "override": "自定义", + "removeOverride": "移除覆盖", + "deleteConfirm": "确定移除该模型的自定义定价?", + "groupRatios": "分组倍率", + "addGroup": "添加分组", + "groupName": "分组标识", + "displayName": "显示名称", + "ratio": "倍率", + "priceEffect": "价格效果", + "standardPrice": "标准价", + "discount": "优惠 {pct}%", + "markup": "加价 {pct}%", + "deleteGroupConfirm": "确定删除该分组?" } diff --git a/web/config/locale/messages/zh/settings.json b/web/config/locale/messages/zh/settings.json index 8fb5493..9c4a7e5 100644 --- a/web/config/locale/messages/zh/settings.json +++ b/web/config/locale/messages/zh/settings.json @@ -1,4 +1,55 @@ { "title": "设置", - "subtitle": "租户设置、域名和品牌定制" + "subtitle": "租户设置、域名和品牌定制", + "authSettings": "登录认证", + "authSettingsDesc": "配置租户的登录方式和第三方认证", + "loginMethods": "登录方式", + "loginMethodsDesc": "选择租户用户可用的登录方式", + "methodPassword": "密码登录", + "methodPasswordDesc": "用户名 + 密码登录", + "methodEmailCode": "邮箱验证码", + "methodEmailCodeDesc": "邮箱接收验证码登录", + "methodGoogle": "Google 登录", + "methodGoogleDesc": "使用 Google 账号登录", + "methodGithub": "GitHub 登录", + "methodGithubDesc": "使用 GitHub 账号登录", + "smtpConfig": "SMTP 邮件配置", + "smtpConfigDesc": "启用邮箱验证码需要配置 SMTP", + "smtpHost": "SMTP 服务器", + "smtpHostPlaceholder": "smtp.example.com", + "smtpPort": "端口", + "smtpUsername": "用户名", + "smtpUsernamePlaceholder": "noreply@example.com", + "smtpPassword": "密码", + "smtpPasswordPlaceholder": "SMTP 密码", + "smtpFrom": "发件人地址", + "smtpFromPlaceholder": "noreply@example.com", + "smtpFromName": "发件人名称", + "smtpFromNamePlaceholder": "My Company", + "testSmtp": "发送测试邮件", + "testSmtpTo": "测试收件人", + "testSmtpToPlaceholder": "admin@example.com", + "testSmtpSending": "发送中...", + "testSmtpSuccess": "测试邮件已发送", + "testSmtpError": "发送失败: {error}", + "googleOAuth": "Google OAuth 配置", + "githubOAuth": "GitHub OAuth 配置", + "clientId": "Client ID", + "clientIdPlaceholder": "xxx.apps.googleusercontent.com", + "clientSecret": "Client Secret", + "clientSecretPlaceholder": "OAuth Client Secret", + "saveSettings": "保存设置", + "saving": "保存中...", + "saveSuccess": "设置已保存", + "saveError": "保存失败", + "validationEmailCodeNeedsSmtp": "启用邮箱验证码需要先配置 SMTP", + "validationGoogleNeedsCreds": "启用 Google 登录需要先配置 OAuth 凭据", + "validationGithubNeedsCreds": "启用 GitHub 登录需要先配置 OAuth 凭据", + "validationAtLeastOneMethod": "至少启用一种登录方式", + "smtpGuideTitle": "配置指南", + "smtpGuideGmail": "需要开启 Google 应用专用密码(App Password),不能使用 Google 账号密码", + "smtpGuideAWS": "在 AWS Console 的 SES 中创建 SMTP 凭据,region 替换为你的区域(如 us-east-1)", + "smtpGuideResend": "在 resend.com 创建 API Key,用作 SMTP 密码,用户名填 resend", + "smtpGuideAliyunTitle": "阿里云企业邮箱", + "smtpGuideAliyun": "使用阿里云企业邮箱的 SMTP 地址,端口 465(SSL)" } diff --git a/web/config/locale/messages/zh/usage.json b/web/config/locale/messages/zh/usage.json index ca25678..4184fca 100644 --- a/web/config/locale/messages/zh/usage.json +++ b/web/config/locale/messages/zh/usage.json @@ -1,4 +1,24 @@ { "title": "用量统计", - "subtitle": "查看 API 调用和 Token 消耗" + "subtitle": "查看 API 调用和 Token 消耗", + "totalRequests": "总请求", + "totalTokens": "总 Token", + "promptTokens": "输入 Token", + "quotaCost": "额度消耗", + "byDay": "按日统计", + "byModel": "按模型统计", + "requests": "请求", + "recentLogs": "请求明细", + "time": "时间", + "model": "模型", + "tokenName": "Key 名称", + "promptTokensShort": "输入 Token", + "completionTokensShort": "输出 Token", + "cost": "费用", + "latency": "延迟", + "stream": "模式", + "last7days": "最近 7 天", + "last30days": "最近 30 天", + "viewByDay": "按日", + "viewByModel": "按模型" } diff --git a/web/modules/account/api/account-api.ts b/web/modules/account/api/account-api.ts new file mode 100644 index 0000000..53a53f0 --- /dev/null +++ b/web/modules/account/api/account-api.ts @@ -0,0 +1,37 @@ +import { apiClient } from '@/shared/lib/api-client' +import type { + AccountProfile, + ChangePasswordRequest, + ChangeEmailSendCodeRequest, + ChangeEmailConfirmRequest, +} from '../types/account.types' + +// ── Mock data ──────────────────────────────────────────── +const MOCK_PROFILE: AccountProfile = { + id: 42, + username: 'demo_user', + email: 'demo@example.com', + has_password: true, + created_at: '2026-03-01T10:00:00Z', +} + +const USE_MOCK = false +// ── End mock ───────────────────────────────────────────── + +export const accountApi = { + // GET /api/account + getProfile: (): Promise => + USE_MOCK ? Promise.resolve(MOCK_PROFILE) : apiClient.get('/api/account'), + + // PUT /api/account/password + changePassword: (data: ChangePasswordRequest): Promise<{ status: string }> => + USE_MOCK ? Promise.resolve({ status: 'ok' }) : apiClient.put('/api/account/password', data), + + // POST /api/account/change-email + sendChangeEmailCode: (data: ChangeEmailSendCodeRequest): Promise<{ status: string; expires_in: number }> => + USE_MOCK ? Promise.resolve({ status: 'ok', expires_in: 300 }) : apiClient.post('/api/account/change-email', data), + + // PUT /api/account/email + confirmChangeEmail: (data: ChangeEmailConfirmRequest): Promise<{ status: string }> => + USE_MOCK ? Promise.resolve({ status: 'ok' }) : apiClient.put('/api/account/email', data), +} diff --git a/web/modules/account/components/change-email-form.tsx b/web/modules/account/components/change-email-form.tsx new file mode 100644 index 0000000..a167827 --- /dev/null +++ b/web/modules/account/components/change-email-form.tsx @@ -0,0 +1,113 @@ +'use client' + +import { useState, useRef, useEffect, useCallback } from 'react' +import { useTranslations } from 'next-intl' +import { useSendChangeEmailCode, useConfirmChangeEmail } from '../hooks/use-account' +import { Mail } from 'lucide-react' + +export function ChangeEmailForm() { + const t = useTranslations('account') + const sendMutation = useSendChangeEmailCode() + const confirmMutation = useConfirmChangeEmail() + + const [newEmail, setNewEmail] = useState('') + const [code, setCode] = useState('') + const [step, setStep] = useState<'email' | 'code'>('email') + const [countdown, setCountdown] = useState(0) + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + const timerRef = useRef | null>(null) + + const startCountdown = useCallback((seconds: number) => { + setCountdown(seconds) + if (timerRef.current) clearInterval(timerRef.current) + timerRef.current = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { if (timerRef.current) clearInterval(timerRef.current); return 0 } + return prev - 1 + }) + }, 1000) + }, []) + + useEffect(() => { + return () => { if (timerRef.current) clearInterval(timerRef.current) } + }, []) + + const handleSendCode = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + try { + await sendMutation.mutateAsync({ new_email: newEmail }) + setStep('code') + startCountdown(60) + } catch { + setError(t('sendingVerification')) + } + } + + const handleConfirm = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + try { + await confirmMutation.mutateAsync({ new_email: newEmail, code }) + setSuccess(t('emailUpdated')) + setStep('email') + setNewEmail('') + setCode('') + } catch { + setError(t('invalidCode')) + } + } + + const inputClass = 'w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm outline-none transition focus:border-blue-400 focus:bg-white focus:ring-2 focus:ring-blue-100' + + return ( +
+
+ +

{t('changeEmail')}

+
+

{t('changeEmailDesc')}

+ + {step === 'email' ? ( +
+
+ + setNewEmail(e.target.value)} + className={inputClass} placeholder={t('newEmailPlaceholder')} required /> +
+ {error &&

{error}

} + {success &&

{success}

} + +
+ ) : ( +
+

+ {t('verificationSent', { email: newEmail })} +

+
+ + setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + className={inputClass + ' text-center text-lg font-semibold tracking-[0.5em]'} + placeholder={t('verificationCodePlaceholder')} autoFocus required /> +
+ {error &&

{error}

} +
+ + +
+
+ )} +
+ ) +} diff --git a/web/modules/account/components/change-password-form.tsx b/web/modules/account/components/change-password-form.tsx new file mode 100644 index 0000000..502d38f --- /dev/null +++ b/web/modules/account/components/change-password-form.tsx @@ -0,0 +1,84 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl' +import { useAccountProfile, useChangePassword } from '../hooks/use-account' +import { Lock } from 'lucide-react' +import { AxiosError } from 'axios' + +export function ChangePasswordForm() { + const t = useTranslations('account') + const { data: profile } = useAccountProfile() + const changeMutation = useChangePassword() + + const [oldPassword, setOldPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPwd, setConfirmPwd] = useState('') + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + setSuccess('') + + if (newPassword.length < 6) { setError(t('passwordTooShort')); return } + if (newPassword !== confirmPwd) { setError(t('passwordMismatch')); return } + + try { + await changeMutation.mutateAsync({ old_password: oldPassword, new_password: newPassword }) + setSuccess(t('passwordUpdated')) + setOldPassword('') + setNewPassword('') + setConfirmPwd('') + } catch (err) { + if (err instanceof AxiosError && err.response?.status === 401) { + setError(t('invalidOldPassword')) + } else { + setError(t('changePasswordError')) + } + } + } + + const inputClass = 'w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm outline-none transition focus:border-blue-400 focus:bg-white focus:ring-2 focus:ring-blue-100' + + return ( +
+
+ +

{t('changePassword')}

+
+

{t('changePasswordDesc')}

+ + {profile && !profile.has_password && ( +

{t('noPasswordSet')}

+ )} + +
+ {profile?.has_password && ( +
+ + setOldPassword(e.target.value)} + className={inputClass} placeholder={t('oldPasswordPlaceholder')} required /> +
+ )} +
+ + setNewPassword(e.target.value)} + className={inputClass} placeholder={t('newPasswordPlaceholder')} required /> +
+
+ + setConfirmPwd(e.target.value)} + className={inputClass} placeholder={t('confirmPasswordPlaceholder')} required /> +
+ {error &&

{error}

} + {success &&

{success}

} + +
+
+ ) +} diff --git a/web/modules/account/components/profile-card.tsx b/web/modules/account/components/profile-card.tsx new file mode 100644 index 0000000..178cdf6 --- /dev/null +++ b/web/modules/account/components/profile-card.tsx @@ -0,0 +1,47 @@ +'use client' + +import { useTranslations } from 'next-intl' +import { useAccountProfile } from '../hooks/use-account' +import { User } from 'lucide-react' + +export function ProfileCard() { + const { data, isLoading } = useAccountProfile() + const t = useTranslations('account') + const tc = useTranslations('common') + + if (isLoading) { + return
{tc('loading')}
+ } + + if (!data) return null + + return ( +
+
+
+ +
+
+

{data.username}

+

{data.email}

+
+
+
+
+
+

{t('username')}

+

{data.username}

+
+
+

{t('email')}

+

{data.email}

+
+
+

{t('createdAt')}

+

{new Date(data.created_at).toLocaleDateString()}

+
+
+
+
+ ) +} diff --git a/web/modules/account/hooks/use-account.ts b/web/modules/account/hooks/use-account.ts new file mode 100644 index 0000000..54ec73e --- /dev/null +++ b/web/modules/account/hooks/use-account.ts @@ -0,0 +1,32 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { accountApi } from '../api/account-api' +import type { ChangePasswordRequest, ChangeEmailSendCodeRequest, ChangeEmailConfirmRequest } from '../types/account.types' + +export function useAccountProfile() { + return useQuery({ + queryKey: ['account', 'profile'], + queryFn: () => accountApi.getProfile(), + }) +} + +export function useChangePassword() { + return useMutation({ + mutationFn: (data: ChangePasswordRequest) => accountApi.changePassword(data), + }) +} + +export function useSendChangeEmailCode() { + return useMutation({ + mutationFn: (data: ChangeEmailSendCodeRequest) => accountApi.sendChangeEmailCode(data), + }) +} + +export function useConfirmChangeEmail() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (data: ChangeEmailConfirmRequest) => accountApi.confirmChangeEmail(data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['account', 'profile'] }) + }, + }) +} diff --git a/web/modules/account/index.ts b/web/modules/account/index.ts new file mode 100644 index 0000000..694f550 --- /dev/null +++ b/web/modules/account/index.ts @@ -0,0 +1,5 @@ +export { ProfileCard } from './components/profile-card' +export { ChangePasswordForm } from './components/change-password-form' +export { ChangeEmailForm } from './components/change-email-form' +export { useAccountProfile } from './hooks/use-account' +export type { AccountProfile } from './types/account.types' diff --git a/web/modules/account/types/account.types.ts b/web/modules/account/types/account.types.ts new file mode 100644 index 0000000..0387378 --- /dev/null +++ b/web/modules/account/types/account.types.ts @@ -0,0 +1,6 @@ +export type { + AccountProfile, + ChangePasswordRequest, + ChangeEmailSendCodeRequest, + ChangeEmailConfirmRequest, +} from '@/modules/auth/types/auth.types' diff --git a/web/modules/auth/api/auth-api.ts b/web/modules/auth/api/auth-api.ts new file mode 100644 index 0000000..b9ced50 --- /dev/null +++ b/web/modules/auth/api/auth-api.ts @@ -0,0 +1,51 @@ +import axios from 'axios' +import type { + SendCodeRequest, + SendCodeResponse, + VerifyCodeRequest, + VerifyCodeResponse, + PasswordLoginRequest, + PasswordLoginResponse, + ProvidersResponse, + BrandingResponse, +} from '../types/auth.types' + +// Auth API 使用独立 axios(不走 apiClient 的 401 拦截器,因为登录页本身就是未认证状态) +const authHttp = axios.create({ + baseURL: '', + timeout: 15000, + headers: { 'Content-Type': 'application/json' }, +}) + +function forwardedHostHeaders() { + if (typeof window === 'undefined') return {} + return { 'X-Forwarded-Host': window.location.host } +} + +export const authApi = { + /** 获取启用的登录方式 + OAuth providers */ + getProviders: (): Promise => + authHttp.get('/api/auth/providers', { headers: forwardedHostHeaders() }).then((r) => r.data), + + /** 获取租户品牌信息 */ + getBranding: (): Promise => + authHttp.get('/api/auth/branding', { headers: forwardedHostHeaders() }).then((r) => r.data), + + /** 密码登录 */ + loginWithPassword: (data: PasswordLoginRequest): Promise => + authHttp.post('/api/auth/login', data, { headers: forwardedHostHeaders() }).then((r) => r.data), + + /** 发送邮箱验证码 */ + sendCode: (data: SendCodeRequest): Promise => + authHttp.post('/api/auth/send-code', data, { headers: forwardedHostHeaders() }).then((r) => r.data), + + /** 验证邮箱验证码(登录 / 自动注册) */ + verifyCode: (data: VerifyCodeRequest): Promise => + authHttp.post('/api/auth/verify-code', data, { headers: forwardedHostHeaders() }).then((r) => r.data), + + /** 新用户设置密码(需要 JWT) */ + setPassword: (password: string, token: string): Promise<{ status: string }> => + authHttp.post('/api/auth/set-password', { password }, { + headers: { ...forwardedHostHeaders(), Authorization: `Bearer ${token}` }, + }).then((r) => r.data), +} diff --git a/web/modules/auth/index.ts b/web/modules/auth/index.ts new file mode 100644 index 0000000..22ed025 --- /dev/null +++ b/web/modules/auth/index.ts @@ -0,0 +1,23 @@ +export { authApi } from './api/auth-api' +export type { + AuthMethod, + SendCodeRequest, + SendCodeResponse, + VerifyCodeRequest, + VerifyCodeResponse, + PasswordLoginRequest, + PasswordLoginResponse, + SetPasswordRequest, + ProvidersResponse, + BrandingResponse, + AccountProfile, + ChangePasswordRequest, + ChangeEmailSendCodeRequest, + ChangeEmailConfirmRequest, + AuthSettingsResponse, + AuthSettingsRequest, + SmtpConfig, + OAuthProviderConfig, + TestSmtpRequest, + TestSmtpResponse, +} from './types/auth.types' diff --git a/web/modules/auth/types/auth.types.ts b/web/modules/auth/types/auth.types.ts new file mode 100644 index 0000000..08425ff --- /dev/null +++ b/web/modules/auth/types/auth.types.ts @@ -0,0 +1,136 @@ +import type { UserInfo } from '@/shared/providers/auth-provider' + +// ── Auth Method Types ──────────────────────────────────── +export type AuthMethod = 'password' | 'email_code' | 'google' | 'github' + +// ── Login Page APIs ────────────────────────────────────── + +/** GET /api/auth/providers — 返回租户可用的登录方式 */ +export interface ProvidersResponse { + methods: AuthMethod[] + providers: string[] // OAuth providers only (backward compat) +} + +/** Tenant branding response */ +export interface BrandingResponse { + tenant_name: string + logo_url: string + theme_color: string +} + +/** POST /api/auth/login (password) request */ +export interface PasswordLoginRequest { + account: string + password: string +} + +/** POST /api/auth/login (password) response */ +export interface PasswordLoginResponse { + access_token: string + expires_in: number + user: UserInfo +} + +/** POST /api/auth/send-code request */ +export interface SendCodeRequest { + email: string +} + +/** POST /api/auth/send-code success response */ +export interface SendCodeResponse { + status: string + expires_in: number +} + +/** POST /api/auth/verify-code request */ +export interface VerifyCodeRequest { + email: string + code: string +} + +/** POST /api/auth/verify-code success response */ +export interface VerifyCodeResponse { + access_token: string + expires_in: number + is_new_user: boolean + user: UserInfo +} + +/** POST /api/auth/set-password — 新用户设置密码 */ +export interface SetPasswordRequest { + password: string +} + +// ── Account APIs ───────────────────────────────────────── + +/** GET /api/account */ +export interface AccountProfile { + id: number + username: string + email: string + has_password: boolean + created_at: string +} + +/** PUT /api/account/password */ +export interface ChangePasswordRequest { + old_password: string + new_password: string +} + +/** POST /api/account/change-email — 发验证码到新邮箱 */ +export interface ChangeEmailSendCodeRequest { + new_email: string +} + +/** PUT /api/account/email — 确认更换邮箱 */ +export interface ChangeEmailConfirmRequest { + new_email: string + code: string +} + +// ── Auth Settings APIs (console) ───────────────────────── + +export interface SmtpConfig { + host: string + port: number + username: string + password: string // GET 返回 "****",PUT 发 "****" 表示不修改 + from: string + from_name: string +} + +export interface OAuthProviderConfig { + client_id: string + client_secret: string // GET 返回 "****" +} + +/** GET /api/settings/auth response */ +export interface AuthSettingsResponse { + methods: AuthMethod[] + smtp: SmtpConfig + oauth: { + google: OAuthProviderConfig + github: OAuthProviderConfig + } +} + +/** PUT /api/settings/auth request (partial — only send what changed) */ +export interface AuthSettingsRequest { + methods: AuthMethod[] + smtp?: Partial + oauth?: { + google?: Partial + github?: Partial + } +} + +/** POST /api/settings/auth/test-smtp */ +export interface TestSmtpRequest { + to: string +} + +export interface TestSmtpResponse { + status?: string + error?: string +} diff --git a/web/modules/billing/api/billing-api.ts b/web/modules/billing/api/billing-api.ts new file mode 100644 index 0000000..26b675f --- /dev/null +++ b/web/modules/billing/api/billing-api.ts @@ -0,0 +1,55 @@ +import { apiClient } from '@/shared/lib/api-client' +import type { PaginatedResponse, PaginationParams } from '@/shared/types/api.types' +import type { + Redemption, + CreateRedemptionRequest, + CreateRedemptionResponse, + RedeemResponse, +} from '../types/billing.types' + +// ── Mock data ──────────────────────────────────────────── +const STATUS_POOL: (1 | 2 | 3)[] = [1, 1, 1, 2, 2, 3] + +const MOCK_REDEMPTIONS: Redemption[] = Array.from({ length: 15 }, (_, i) => ({ + id: i + 1, + code: `AF-${String.fromCharCode(65 + (i % 26))}${Math.random().toString(36).slice(2, 8).toUpperCase()}`, + name: `充值码 ${i + 1}`, + quota: [1000, 5000, 10000, 50000][i % 4], + status: STATUS_POOL[i % STATUS_POOL.length], + created_at: new Date(Date.now() - i * 86400000 * 2).toISOString(), +})) + +const USE_MOCK = true + +function mockPaginated(items: T[], params: PaginationParams): PaginatedResponse { + const start = (params.page - 1) * params.page_size + return { + items: items.slice(start, start + params.page_size), + pagination: { page: params.page, page_size: params.page_size, total: items.length }, + } +} +// ── End mock ───────────────────────────────────────────── + +export const billingApi = { + // GET /api/redemptions + listRedemptions: (params: PaginationParams): Promise> => + USE_MOCK + ? Promise.resolve(mockPaginated(MOCK_REDEMPTIONS, params)) + : apiClient.get('/api/redemptions', { params }), + + // POST /api/redemptions + createRedemptions: (data: CreateRedemptionRequest): Promise => + USE_MOCK + ? Promise.resolve({ + codes: Array.from({ length: data.count }, () => + `AF-${Math.random().toString(36).slice(2, 10).toUpperCase()}` + ), + }) + : apiClient.post('/api/redemptions', data), + + // POST /api/redemptions/redeem + redeemCode: (code: string): Promise => + USE_MOCK + ? Promise.resolve({ quota_added: 5000, new_quota: 105000 }) + : apiClient.post('/api/redemptions/redeem', { code }), +} diff --git a/web/modules/billing/components/create-redemption-dialog.tsx b/web/modules/billing/components/create-redemption-dialog.tsx new file mode 100644 index 0000000..a4cd118 --- /dev/null +++ b/web/modules/billing/components/create-redemption-dialog.tsx @@ -0,0 +1,150 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl' +import { useCreateRedemptions } from '../hooks/use-billing' +import { useAuth } from '@/shared/providers/auth-provider' +import { Plus, X, Copy, Check } from 'lucide-react' + +export function CreateRedemptionDialog() { + const t = useTranslations('billing') + const tc = useTranslations('common') + const [open, setOpen] = useState(false) + const [name, setName] = useState('') + const [quota, setQuota] = useState('5000') + const [count, setCount] = useState('1') + const [createdCodes, setCreatedCodes] = useState([]) + const [copied, setCopied] = useState(false) + const createMutation = useCreateRedemptions() + const { hasPermission } = useAuth() + + if (!hasPermission('billing:manage')) return null + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + const result = await createMutation.mutateAsync({ + name, + quota: parseInt(quota), + count: parseInt(count), + }) + setCreatedCodes(result.codes) + } + + const handleCopyAll = () => { + navigator.clipboard.writeText(createdCodes.join('\n')) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + const handleClose = () => { + setOpen(false) + setCreatedCodes([]) + setName('') + setQuota('5000') + setCount('1') + } + + return ( + <> + + + {open && ( +
+
e.stopPropagation()}> +
+

+ {createdCodes.length > 0 ? t('createSuccess') : t('createCodeTitle')} +

+ +
+ + {createdCodes.length > 0 ? ( +
+

{t('codesGenerated', { count: createdCodes.length })}

+
+ {createdCodes.map((code) => ( + {code} + ))} +
+
+ + +
+
+ ) : ( +
+
+ + setName(e.target.value)} + className="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm outline-none transition focus:border-blue-400 focus:bg-white focus:ring-2 focus:ring-blue-100" + placeholder={t('codeNamePlaceholder')} + required + /> +
+
+
+ + setQuota(e.target.value)} + className="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm outline-none transition focus:border-blue-400 focus:bg-white focus:ring-2 focus:ring-blue-100" + required + /> +
+
+ + setCount(e.target.value)} + className="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm outline-none transition focus:border-blue-400 focus:bg-white focus:ring-2 focus:ring-blue-100" + required + /> +
+
+
+ + +
+
+ )} +
+
+ )} + + ) +} diff --git a/web/modules/billing/components/redeem-code-card.tsx b/web/modules/billing/components/redeem-code-card.tsx new file mode 100644 index 0000000..98f9360 --- /dev/null +++ b/web/modules/billing/components/redeem-code-card.tsx @@ -0,0 +1,61 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl' +import { useRedeemCode } from '../hooks/use-billing' +import { Gift, Check } from 'lucide-react' +import type { RedeemResponse } from '../types/billing.types' + +export function RedeemCodeCard() { + const t = useTranslations('billing') + const [code, setCode] = useState('') + const [result, setResult] = useState(null) + const redeemMutation = useRedeemCode() + + const handleRedeem = async (e: React.FormEvent) => { + e.preventDefault() + const res = await redeemMutation.mutateAsync(code) + setResult(res) + setCode('') + } + + return ( +
+
+ +

{t('redeemTitle')}

+
+ + {result ? ( +
+ + {t('redeemSuccess', { quota: result.quota_added.toLocaleString() })} + +
+ ) : ( +
+ setCode(e.target.value)} + className="flex-1 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm outline-none transition focus:border-blue-400 focus:bg-white focus:ring-2 focus:ring-blue-100" + placeholder={t('redeemPlaceholder')} + required + /> + +
+ )} +
+ ) +} diff --git a/web/modules/billing/components/redemption-table.tsx b/web/modules/billing/components/redemption-table.tsx new file mode 100644 index 0000000..cf7d5f2 --- /dev/null +++ b/web/modules/billing/components/redemption-table.tsx @@ -0,0 +1,101 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl' +import { useRedemptions } from '../hooks/use-billing' +import { useAuth } from '@/shared/providers/auth-provider' +import { cn } from '@/shared/lib/utils' +import { Copy, Check } from 'lucide-react' + +const STATUS_MAP = { + 1: { key: 'available', style: 'bg-green-50 text-green-700' }, + 2: { key: 'used', style: 'bg-slate-100 text-slate-500' }, + 3: { key: 'disabled', style: 'bg-red-50 text-red-600' }, +} as const + +export function RedemptionTable() { + const [page] = useState(1) + const { data, isLoading } = useRedemptions({ page, page_size: 20 }) + const { hasPermission } = useAuth() + const t = useTranslations('billing') + const tc = useTranslations('common') + + const [copiedId, setCopiedId] = useState(null) + + const handleCopy = (code: string, id: number) => { + navigator.clipboard.writeText(code) + setCopiedId(id) + setTimeout(() => setCopiedId(null), 2000) + } + + if (!hasPermission('billing:manage')) return null + + if (isLoading) { + return
{tc('loading')}
+ } + + if (!data?.items?.length) { + return ( +
+

{tc('noData')}

+
+ ) + } + + return ( +
+
+ + + + + + + + + + + + {data.items.map((item) => { + const statusInfo = STATUS_MAP[item.status] + return ( + + + + + + + + ) + })} + +
{t('codeName')}{t('code')}{t('quota')}{tc('status')}{t('createdAt')}
{item.name} +
+ + {item.code.slice(0, 12)}... + + +
+
{item.quota.toLocaleString()} + + {t(statusInfo.key)} + + + {new Date(item.created_at).toLocaleDateString()} +
+
+ {data.pagination && ( +
+ {tc('total', { count: data.pagination.total })} +
+ )} +
+ ) +} diff --git a/web/modules/billing/hooks/use-billing.ts b/web/modules/billing/hooks/use-billing.ts new file mode 100644 index 0000000..24b8ebe --- /dev/null +++ b/web/modules/billing/hooks/use-billing.ts @@ -0,0 +1,31 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { billingApi } from '../api/billing-api' +import type { PaginationParams } from '@/shared/types/api.types' +import type { CreateRedemptionRequest } from '../types/billing.types' + +export function useRedemptions(params: PaginationParams) { + return useQuery({ + queryKey: ['redemptions', params], + queryFn: () => billingApi.listRedemptions(params), + }) +} + +export function useCreateRedemptions() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (data: CreateRedemptionRequest) => billingApi.createRedemptions(data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['redemptions'] }) + }, + }) +} + +export function useRedeemCode() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (code: string) => billingApi.redeemCode(code), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['quota'] }) + }, + }) +} diff --git a/web/modules/billing/index.ts b/web/modules/billing/index.ts new file mode 100644 index 0000000..3a6fda3 --- /dev/null +++ b/web/modules/billing/index.ts @@ -0,0 +1,5 @@ +export { RedemptionTable } from './components/redemption-table' +export { CreateRedemptionDialog } from './components/create-redemption-dialog' +export { RedeemCodeCard } from './components/redeem-code-card' +export { useRedemptions, useRedeemCode } from './hooks/use-billing' +export type { Redemption, CreateRedemptionRequest, RedeemResponse } from './types/billing.types' diff --git a/web/modules/billing/types/billing.types.ts b/web/modules/billing/types/billing.types.ts new file mode 100644 index 0000000..016a98e --- /dev/null +++ b/web/modules/billing/types/billing.types.ts @@ -0,0 +1,28 @@ +/** 兑换码 */ +export interface Redemption { + id: number + code: string + name: string + quota: number + status: 1 | 2 | 3 // 1=可用 2=已用 3=禁用 + created_at: string +} + +/** 批量创建兑换码 */ +export interface CreateRedemptionRequest { + name: string + quota: number + count: number + expires_at?: string +} + +/** 批量创建返回 */ +export interface CreateRedemptionResponse { + codes: string[] +} + +/** 兑换返回 */ +export interface RedeemResponse { + quota_added: number + new_quota: number +} diff --git a/web/modules/pricing/api/pricing-api.ts b/web/modules/pricing/api/pricing-api.ts new file mode 100644 index 0000000..5a6ef0e --- /dev/null +++ b/web/modules/pricing/api/pricing-api.ts @@ -0,0 +1,72 @@ +import { apiClient } from '@/shared/lib/api-client' +import type { + ModelPricing, + SetModelPricingRequest, + PricingGroup, + UpsertPricingGroupRequest, +} from '../types/pricing.types' + +// ── Mock data (后端未实现时使用) ────────────────────────── +const MOCK_MODEL_PRICING: ModelPricing[] = [ + { model: 'gpt-4o', model_ratio: 2.5, completion_ratio: 3, model_type: 'text', enabled: true, is_override: false }, + { model: 'gpt-4o-mini', model_ratio: 0.15, completion_ratio: 0.6, model_type: 'text', enabled: true, is_override: false }, + { model: 'claude-sonnet-4-6', model_ratio: 3.0, completion_ratio: 5, model_type: 'text', enabled: true, is_override: false }, + { model: 'claude-haiku-4-5', model_ratio: 0.8, completion_ratio: 4, model_type: 'text', enabled: true, is_override: false }, + { model: 'deepseek-chat', model_ratio: 0.14, completion_ratio: 2, model_type: 'text', enabled: true, is_override: false }, + { model: 'deepseek-reasoner', model_ratio: 0.55, completion_ratio: 4, model_type: 'text', enabled: true, is_override: false }, + { model: 'dall-e-3', model_ratio: 20, completion_ratio: 1, model_type: 'image', enabled: true, is_override: false }, + { model: 'gpt-4o-audio', model_ratio: 2.5, completion_ratio: 10, model_type: 'audio', enabled: true, is_override: false }, +] + +const MOCK_GROUPS: PricingGroup[] = [ + { id: 1, name: 'default', display_name: '标准', ratio: 1.0, description: '标准定价' }, + { id: 2, name: 'vip', display_name: 'VIP', ratio: 0.85, description: 'VIP 折扣' }, + { id: 3, name: 'wholesale', display_name: '批发', ratio: 0.5, description: '代理商批发价' }, +] + +const USE_MOCK = true +// ── End mock ────────────────────────────────────────────── + +export const pricingApi = { + // GET /api/pricing/models + listModels: (): Promise<{ items: ModelPricing[] }> => + USE_MOCK + ? Promise.resolve({ items: MOCK_MODEL_PRICING }) + : apiClient.get('/api/pricing/models'), + + // PUT /api/pricing/models — set override + setModelOverride: (data: SetModelPricingRequest): Promise<{ status: string }> => + USE_MOCK + ? Promise.resolve({ status: 'ok' }) + : apiClient.put('/api/pricing/models', data), + + // DELETE /api/pricing/models/:model — remove override + removeModelOverride: (model: string): Promise => + USE_MOCK + ? Promise.resolve() + : apiClient.delete(`/api/pricing/models/${encodeURIComponent(model)}`), + + // GET /api/pricing/groups + listGroups: (): Promise<{ items: PricingGroup[] }> => + USE_MOCK + ? Promise.resolve({ items: MOCK_GROUPS }) + : apiClient.get('/api/pricing/groups'), + + // POST /api/pricing/groups + createGroup: (data: UpsertPricingGroupRequest): Promise => + USE_MOCK + ? Promise.resolve({ ...data, id: Date.now(), description: data.description || '' }) + : apiClient.post('/api/pricing/groups', data), + + // PUT /api/pricing/groups/:id + updateGroup: (id: number, data: UpsertPricingGroupRequest): Promise => + USE_MOCK + ? Promise.resolve({ ...data, id, description: data.description || '' }) + : apiClient.put(`/api/pricing/groups/${id}`, data), + + // DELETE /api/pricing/groups/:id + deleteGroup: (id: number): Promise => + USE_MOCK + ? Promise.resolve() + : apiClient.delete(`/api/pricing/groups/${id}`), +} diff --git a/web/modules/pricing/components/group-ratio-table.tsx b/web/modules/pricing/components/group-ratio-table.tsx new file mode 100644 index 0000000..9cfdbb3 --- /dev/null +++ b/web/modules/pricing/components/group-ratio-table.tsx @@ -0,0 +1,154 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl' +import { usePricingGroups, useCreatePricingGroup, useDeletePricingGroup } from '../hooks/use-pricing' +import { useAuth } from '@/shared/providers/auth-provider' +import { Trash2, Plus, X } from 'lucide-react' + +export function GroupRatioTable() { + const { data, isLoading } = usePricingGroups() + const createMutation = useCreatePricingGroup() + const deleteMutation = useDeletePricingGroup() + const { hasPermission } = useAuth() + const t = useTranslations('pricing') + const tc = useTranslations('common') + + const [showForm, setShowForm] = useState(false) + const [name, setName] = useState('') + const [displayName, setDisplayName] = useState('') + const [ratio, setRatio] = useState('1.0') + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + await createMutation.mutateAsync({ name, display_name: displayName, ratio: parseFloat(ratio) }) + setName('') + setDisplayName('') + setRatio('1.0') + setShowForm(false) + } + + if (isLoading) { + return
{tc('loading')}
+ } + + const items = data?.items + + return ( +
+
+

{t('groupRatios')}

+ {hasPermission('pricing:update') && ( + + )} +
+ + {showForm && ( +
+
+
+ + setName(e.target.value)} + className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100" + placeholder="e.g. enterprise" + required + /> +
+
+ + setDisplayName(e.target.value)} + className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100" + placeholder="e.g. Enterprise" + required + /> +
+
+ + setRatio(e.target.value)} + className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100" + required + /> +
+ + +
+
+ )} + +
+ + + + + + + + {hasPermission('pricing:update') && ( + + )} + + + + {items?.map((group) => ( + + + + + + {hasPermission('pricing:update') && ( + + )} + + ))} + +
{t('groupName')}{t('displayName')}{t('ratio')}{t('priceEffect')}{tc('actions')}
+ + {group.name} + + {group.display_name}{group.ratio} + {group.ratio === 1 ? t('standardPrice') : group.ratio < 1 ? t('discount', { pct: Math.round((1 - group.ratio) * 100) }) : t('markup', { pct: Math.round((group.ratio - 1) * 100) })} + + {group.name !== 'default' && ( + + )} +
+
+
+ ) +} diff --git a/web/modules/pricing/components/model-pricing-table.tsx b/web/modules/pricing/components/model-pricing-table.tsx new file mode 100644 index 0000000..da9fa08 --- /dev/null +++ b/web/modules/pricing/components/model-pricing-table.tsx @@ -0,0 +1,109 @@ +'use client' + +import { useTranslations } from 'next-intl' +import { useModelPricing, useRemoveModelOverride } from '../hooks/use-pricing' +import { useAuth } from '@/shared/providers/auth-provider' +import { cn } from '@/shared/lib/utils' +import { Trash2 } from 'lucide-react' + +const TYPE_STYLES: Record = { + text: 'bg-blue-50 text-blue-700', + image: 'bg-purple-50 text-purple-700', + audio: 'bg-amber-50 text-amber-700', + video: 'bg-green-50 text-green-700', +} + +export function ModelPricingTable() { + const { data, isLoading } = useModelPricing() + const removeMutation = useRemoveModelOverride() + const { hasPermission } = useAuth() + const t = useTranslations('pricing') + const tc = useTranslations('common') + + if (isLoading) { + return
{tc('loading')}
+ } + + const items = data?.items + if (!items?.length) { + return ( +
+

{tc('noData')}

+
+ ) + } + + return ( +
+ + + + + + + + + + + {hasPermission('pricing:update') && ( + + )} + + + + {items.map((item) => { + const inputPer1m = (item.model_ratio * 2).toFixed(2) + const outputPer1m = (item.model_ratio * item.completion_ratio * 2).toFixed(2) + return ( + + + + + + + + + {hasPermission('pricing:update') && ( + + )} + + ) + })} + +
{t('model')}{t('modelType')}{t('modelRatio')}{t('completionRatio')}{t('inputCost')}{t('outputCost')}{tc('status')}{tc('actions')}
+
+ + {item.model} + + {item.is_override && ( + + {t('override')} + + )} +
+
+ + {item.model_type} + + {item.model_ratio}{item.completion_ratio}x${inputPer1m}/1M${outputPer1m}/1M + + {item.enabled ? tc('enable') : tc('disable')} + + + {item.is_override && ( + + )} +
+
+ ) +} diff --git a/web/modules/pricing/hooks/use-pricing.ts b/web/modules/pricing/hooks/use-pricing.ts new file mode 100644 index 0000000..b52a7ee --- /dev/null +++ b/web/modules/pricing/hooks/use-pricing.ts @@ -0,0 +1,57 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { pricingApi } from '../api/pricing-api' +import type { SetModelPricingRequest, UpsertPricingGroupRequest } from '../types/pricing.types' + +export function useModelPricing() { + return useQuery({ + queryKey: ['pricing', 'models'], + queryFn: () => pricingApi.listModels(), + }) +} + +export function useSetModelOverride() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (data: SetModelPricingRequest) => pricingApi.setModelOverride(data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['pricing', 'models'] }) + }, + }) +} + +export function useRemoveModelOverride() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (model: string) => pricingApi.removeModelOverride(model), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['pricing', 'models'] }) + }, + }) +} + +export function usePricingGroups() { + return useQuery({ + queryKey: ['pricing', 'groups'], + queryFn: () => pricingApi.listGroups(), + }) +} + +export function useCreatePricingGroup() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (data: UpsertPricingGroupRequest) => pricingApi.createGroup(data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['pricing', 'groups'] }) + }, + }) +} + +export function useDeletePricingGroup() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: number) => pricingApi.deleteGroup(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['pricing', 'groups'] }) + }, + }) +} diff --git a/web/modules/pricing/index.ts b/web/modules/pricing/index.ts new file mode 100644 index 0000000..4fc2c72 --- /dev/null +++ b/web/modules/pricing/index.ts @@ -0,0 +1,4 @@ +export { ModelPricingTable } from './components/model-pricing-table' +export { GroupRatioTable } from './components/group-ratio-table' +export { useModelPricing, usePricingGroups } from './hooks/use-pricing' +export type { ModelPricing, PricingGroup } from './types/pricing.types' diff --git a/web/modules/pricing/types/pricing.types.ts b/web/modules/pricing/types/pricing.types.ts new file mode 100644 index 0000000..9b69e50 --- /dev/null +++ b/web/modules/pricing/types/pricing.types.ts @@ -0,0 +1,33 @@ +/** 模型定价(全局 + 租户覆盖合并后) */ +export interface ModelPricing { + model: string + model_ratio: number + completion_ratio: number + model_type: 'text' | 'image' | 'audio' | 'video' + enabled: boolean + is_override: boolean +} + +/** 设置模型定价覆盖 */ +export interface SetModelPricingRequest { + model: string + model_ratio: number + completion_ratio: number +} + +/** 分组倍率 */ +export interface PricingGroup { + id: number + name: string + display_name: string + ratio: number + description: string +} + +/** 创建/更新分组 */ +export interface UpsertPricingGroupRequest { + name: string + display_name: string + ratio: number + description?: string +} diff --git a/web/modules/quota/api/quota-api.ts b/web/modules/quota/api/quota-api.ts new file mode 100644 index 0000000..91bd86f --- /dev/null +++ b/web/modules/quota/api/quota-api.ts @@ -0,0 +1,21 @@ +import { apiClient } from '@/shared/lib/api-client' +import type { QuotaInfo } from '../types/quota.types' + +// ── Mock data ──────────────────────────────────────────── +const MOCK_QUOTA: QuotaInfo = { + quota: 500000, + used_quota: 123456, + pricing_group: 'default', + group_ratio: 1.0, +} + +const USE_MOCK = true +// ── End mock ───────────────────────────────────────────── + +export const quotaApi = { + // GET /api/quota/me + getMyQuota: (): Promise => + USE_MOCK + ? Promise.resolve(MOCK_QUOTA) + : apiClient.get('/api/quota/me'), +} diff --git a/web/modules/quota/components/quota-bar.tsx b/web/modules/quota/components/quota-bar.tsx new file mode 100644 index 0000000..0a11c55 --- /dev/null +++ b/web/modules/quota/components/quota-bar.tsx @@ -0,0 +1,50 @@ +'use client' + +import { useMyQuota } from '../hooks/use-quota' +import { cn } from '@/shared/lib/utils' +import { Wallet } from 'lucide-react' + +function formatQuota(n: number): string { + if (n === -1) return '∞' + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M' + if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K' + return n.toLocaleString() +} + +export function QuotaBar() { + const { data } = useMyQuota() + + if (!data) return null + + const { quota, used_quota } = data + const isUnlimited = quota === -1 + const total = isUnlimited ? 0 : quota + used_quota + const pct = isUnlimited ? 0 : total > 0 ? (used_quota / total) * 100 : 0 + const isLow = !isUnlimited && quota < used_quota * 0.2 + + return ( +
+
+
+ + 额度 +
+ + {formatQuota(quota)} + +
+ {!isUnlimited && ( +
+
+
+ )} +
+ 已用 {formatQuota(used_quota)} + {!isUnlimited && 总 {formatQuota(total)}} +
+
+ ) +} diff --git a/web/modules/quota/hooks/use-quota.ts b/web/modules/quota/hooks/use-quota.ts new file mode 100644 index 0000000..46ee6b3 --- /dev/null +++ b/web/modules/quota/hooks/use-quota.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query' +import { quotaApi } from '../api/quota-api' + +export function useMyQuota() { + return useQuery({ + queryKey: ['quota', 'me'], + queryFn: () => quotaApi.getMyQuota(), + staleTime: 30 * 1000, + }) +} diff --git a/web/modules/quota/index.ts b/web/modules/quota/index.ts new file mode 100644 index 0000000..a3114a6 --- /dev/null +++ b/web/modules/quota/index.ts @@ -0,0 +1,3 @@ +export { QuotaBar } from './components/quota-bar' +export { useMyQuota } from './hooks/use-quota' +export type { QuotaInfo } from './types/quota.types' diff --git a/web/modules/quota/types/quota.types.ts b/web/modules/quota/types/quota.types.ts new file mode 100644 index 0000000..0d7ce05 --- /dev/null +++ b/web/modules/quota/types/quota.types.ts @@ -0,0 +1,7 @@ +/** GET /api/quota/me 返回 */ +export interface QuotaInfo { + quota: number + used_quota: number + pricing_group: string + group_ratio: number +} diff --git a/web/modules/settings/api/settings-api.ts b/web/modules/settings/api/settings-api.ts new file mode 100644 index 0000000..aabde45 --- /dev/null +++ b/web/modules/settings/api/settings-api.ts @@ -0,0 +1,47 @@ +import { apiClient } from '@/shared/lib/api-client' +import type { + AuthSettingsResponse, + AuthSettingsRequest, + TestSmtpRequest, + TestSmtpResponse, +} from '../types/settings.types' + +// ── Mock data ──────────────────────────────────────────── +const MOCK_AUTH_SETTINGS: AuthSettingsResponse = { + methods: ['password'], + smtp: { + host: '', + port: 587, + username: '', + password: '', + from: '', + from_name: '', + }, + oauth: { + google: { client_id: '', client_secret: '' }, + github: { client_id: '', client_secret: '' }, + }, +} + +const USE_MOCK = false +// ── End mock ───────────────────────────────────────────── + +export const settingsApi = { + // GET /api/settings/auth + getAuthSettings: (): Promise => + USE_MOCK + ? Promise.resolve(MOCK_AUTH_SETTINGS) + : apiClient.get('/api/settings/auth'), + + // PUT /api/settings/auth + updateAuthSettings: (data: AuthSettingsRequest): Promise<{ status: string }> => + USE_MOCK + ? Promise.resolve({ status: 'ok' }) + : apiClient.put('/api/settings/auth', data), + + // POST /api/settings/auth/test-smtp + testSmtp: (data: TestSmtpRequest): Promise => + USE_MOCK + ? Promise.resolve({ status: 'ok' }) + : apiClient.post('/api/settings/auth/test-smtp', data), +} diff --git a/web/modules/settings/components/auth-settings-form.tsx b/web/modules/settings/components/auth-settings-form.tsx new file mode 100644 index 0000000..26d8106 --- /dev/null +++ b/web/modules/settings/components/auth-settings-form.tsx @@ -0,0 +1,306 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useTranslations } from 'next-intl' +import { useAuthSettings, useUpdateAuthSettings, useTestSmtp } from '../hooks/use-settings' +import { useAuth } from '@/shared/providers/auth-provider' +import type { AuthMethod, SmtpConfig, OAuthProviderConfig } from '../types/settings.types' +import { cn } from '@/shared/lib/utils' +import { Check, X, Send, Loader2, ChevronDown, BookOpen } from 'lucide-react' + +const METHOD_KEYS: { method: AuthMethod; labelKey: string; descKey: string }[] = [ + { method: 'password', labelKey: 'methodPassword', descKey: 'methodPasswordDesc' }, + { method: 'email_code', labelKey: 'methodEmailCode', descKey: 'methodEmailCodeDesc' }, + { method: 'google', labelKey: 'methodGoogle', descKey: 'methodGoogleDesc' }, + { method: 'github', labelKey: 'methodGithub', descKey: 'methodGithubDesc' }, +] + +const EMPTY_SMTP: SmtpConfig = { host: '', port: 587, username: '', password: '', from: '', from_name: '' } +const EMPTY_OAUTH: OAuthProviderConfig = { client_id: '', client_secret: '' } + +export function AuthSettingsForm() { + const t = useTranslations('settings') + const tc = useTranslations('common') + const { hasPermission } = useAuth() + const { data, isLoading } = useAuthSettings() + const updateMutation = useUpdateAuthSettings() + const testSmtpMutation = useTestSmtp() + + const [methods, setMethods] = useState(['password']) + const [smtp, setSmtp] = useState(EMPTY_SMTP) + const [google, setGoogle] = useState(EMPTY_OAUTH) + const [github, setGithub] = useState(EMPTY_OAUTH) + const [testEmail, setTestEmail] = useState('') + const [validationError, setValidationError] = useState('') + const [saveMsg, setSaveMsg] = useState('') + const [showGuide, setShowGuide] = useState(false) + + // Load data + useEffect(() => { + if (data) { + setMethods(data.methods) + setSmtp(data.smtp) + setGoogle(data.oauth.google) + setGithub(data.oauth.github) + } + }, [data]) + + if (!hasPermission('settings:update')) return null + + if (isLoading) { + return
{tc('loading')}
+ } + + const toggleMethod = (method: AuthMethod) => { + setMethods((prev) => + prev.includes(method) ? prev.filter((m) => m !== method) : [...prev, method], + ) + setValidationError('') + } + + const validate = (): string | null => { + if (methods.length === 0) return t('validationAtLeastOneMethod') + if (methods.includes('email_code') && !smtp.host && !data?.smtp.host) return t('validationEmailCodeNeedsSmtp') + if (methods.includes('google') && !google.client_id && !data?.oauth.google.client_id) return t('validationGoogleNeedsCreds') + if (methods.includes('github') && !github.client_id && !data?.oauth.github.client_id) return t('validationGithubNeedsCreds') + return null + } + + const handleSave = async () => { + const err = validate() + if (err) { setValidationError(err); return } + setValidationError('') + setSaveMsg('') + + try { + await updateMutation.mutateAsync({ + methods, + smtp, + oauth: { google, github }, + }) + setSaveMsg(t('saveSuccess')) + setTimeout(() => setSaveMsg(''), 3000) + } catch { + setSaveMsg(t('saveError')) + } + } + + const handleTestSmtp = async () => { + if (!testEmail) return + await testSmtpMutation.mutateAsync({ to: testEmail }) + } + + const inputClass = 'w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm outline-none transition focus:border-blue-400 focus:bg-white focus:ring-2 focus:ring-blue-100' + + return ( +
+ {/* Login Methods */} +
+

{t('loginMethods')}

+

{t('loginMethodsDesc')}

+ +
+ {METHOD_KEYS.map(({ method, labelKey, descKey }) => ( +
+ + {/* SMTP Config — show when email_code enabled */} + {methods.includes('email_code') && ( +
+

{t('smtpConfig')}

+

{t('smtpConfigDesc')}

+ + {/* Collapsible Setup Guide */} +
+ + {showGuide && ( +
+
+

Gmail

+

{t('smtpGuideGmail')}

+ host: smtp.gmail.com · port: 587 +
+
+

AWS SES

+

{t('smtpGuideAWS')}

+ host: email-smtp.{region}.amazonaws.com · port: 587 +
+
+

Resend

+

{t('smtpGuideResend')}

+ host: smtp.resend.com · port: 587 +
+
+

{t('smtpGuideAliyunTitle')}

+

{t('smtpGuideAliyun')}

+ host: smtp.qiye.aliyun.com · port: 465 +
+
+ )} +
+ +
+
+ + setSmtp({ ...smtp, host: e.target.value })} placeholder={t('smtpHostPlaceholder')} /> +
+
+ + setSmtp({ ...smtp, port: parseInt(e.target.value) || 587 })} /> +
+
+ + setSmtp({ ...smtp, username: e.target.value })} placeholder={t('smtpUsernamePlaceholder')} /> +
+
+ + setSmtp({ ...smtp, password: e.target.value })} + onFocus={(e) => { if (e.target.value === '****') setSmtp({ ...smtp, password: '' }) }} + placeholder={t('smtpPasswordPlaceholder')} + /> +
+
+ + setSmtp({ ...smtp, from: e.target.value })} placeholder={t('smtpFromPlaceholder')} /> +
+
+ + setSmtp({ ...smtp, from_name: e.target.value })} placeholder={t('smtpFromNamePlaceholder')} /> +
+
+ + {/* Test SMTP */} +
+
+ + setTestEmail(e.target.value)} placeholder={t('testSmtpToPlaceholder')} /> +
+ +
+ {testSmtpMutation.isSuccess && ( +

{t('testSmtpSuccess')}

+ )} + {testSmtpMutation.isError && ( +

{t('testSmtpError', { error: 'connection failed' })}

+ )} +
+ )} + + {/* Google OAuth — show when google enabled */} + {methods.includes('google') && ( +
+

{t('googleOAuth')}

+
+
+ + setGoogle({ ...google, client_id: e.target.value })} placeholder={t('clientIdPlaceholder')} /> +
+
+ + setGoogle({ ...google, client_secret: e.target.value })} + onFocus={(e) => { if (e.target.value === '****') setGoogle({ ...google, client_secret: '' }) }} + placeholder={t('clientSecretPlaceholder')} + /> +
+
+
+ )} + + {/* GitHub OAuth — show when github enabled */} + {methods.includes('github') && ( +
+

{t('githubOAuth')}

+
+
+ + setGithub({ ...github, client_id: e.target.value })} placeholder={t('clientIdPlaceholder')} /> +
+
+ + setGithub({ ...github, client_secret: e.target.value })} + onFocus={(e) => { if (e.target.value === '****') setGithub({ ...github, client_secret: '' }) }} + placeholder={t('clientSecretPlaceholder')} + /> +
+
+
+ )} + + {/* Validation error + Save button */} +
+
+ {validationError &&

{validationError}

} + {saveMsg && ( +

+ {saveMsg} +

+ )} +
+ +
+
+ ) +} diff --git a/web/modules/settings/hooks/use-settings.ts b/web/modules/settings/hooks/use-settings.ts new file mode 100644 index 0000000..ae08650 --- /dev/null +++ b/web/modules/settings/hooks/use-settings.ts @@ -0,0 +1,26 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { settingsApi } from '../api/settings-api' +import type { AuthSettingsRequest, TestSmtpRequest } from '../types/settings.types' + +export function useAuthSettings() { + return useQuery({ + queryKey: ['settings', 'auth'], + queryFn: () => settingsApi.getAuthSettings(), + }) +} + +export function useUpdateAuthSettings() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (data: AuthSettingsRequest) => settingsApi.updateAuthSettings(data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['settings', 'auth'] }) + }, + }) +} + +export function useTestSmtp() { + return useMutation({ + mutationFn: (data: TestSmtpRequest) => settingsApi.testSmtp(data), + }) +} diff --git a/web/modules/settings/index.ts b/web/modules/settings/index.ts new file mode 100644 index 0000000..842876b --- /dev/null +++ b/web/modules/settings/index.ts @@ -0,0 +1,3 @@ +export { AuthSettingsForm } from './components/auth-settings-form' +export { useAuthSettings, useUpdateAuthSettings, useTestSmtp } from './hooks/use-settings' +export type { AuthSettingsResponse, AuthSettingsRequest } from './types/settings.types' diff --git a/web/modules/settings/types/settings.types.ts b/web/modules/settings/types/settings.types.ts new file mode 100644 index 0000000..101a116 --- /dev/null +++ b/web/modules/settings/types/settings.types.ts @@ -0,0 +1,10 @@ +// Re-export auth settings types from auth module +export type { + AuthSettingsResponse, + AuthSettingsRequest, + TestSmtpRequest, + TestSmtpResponse, + AuthMethod, + SmtpConfig, + OAuthProviderConfig, +} from '@/modules/auth/types/auth.types' diff --git a/web/modules/tokens/components/create-token-dialog.tsx b/web/modules/tokens/components/create-token-dialog.tsx index 17c39da..4272eee 100644 --- a/web/modules/tokens/components/create-token-dialog.tsx +++ b/web/modules/tokens/components/create-token-dialog.tsx @@ -21,7 +21,7 @@ export function CreateTokenDialog() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() const result = await createMutation.mutateAsync({ name }) - setCreatedKey(result.full_key) + setCreatedKey(result.full_key || '') setName('') } diff --git a/web/modules/tokens/types/tokens.types.ts b/web/modules/tokens/types/tokens.types.ts index b31a876..537c2f7 100644 --- a/web/modules/tokens/types/tokens.types.ts +++ b/web/modules/tokens/types/tokens.types.ts @@ -8,7 +8,7 @@ export interface Token { used_quota: number rate_limit: number expires_at: string | null - status: string + status: number created_at: string } diff --git a/web/modules/usage/api/usage-api.ts b/web/modules/usage/api/usage-api.ts new file mode 100644 index 0000000..0d98b60 --- /dev/null +++ b/web/modules/usage/api/usage-api.ts @@ -0,0 +1,71 @@ +import { apiClient } from '@/shared/lib/api-client' +import type { UsageResponse, UsageQueryParams, UsageBucket } from '../types/usage.types' + +// ── Mock data ──────────────────────────────────────────── +const MODELS = ['gpt-4o', 'gpt-4o-mini', 'claude-sonnet-4-6', 'deepseek-chat'] + +function mockUsageResponse(params: UsageQueryParams): UsageResponse { + const page = params.page || 1 + const pageSize = params.page_size || 20 + const total = 85 + + const items = Array.from({ length: Math.min(pageSize, total - (page - 1) * pageSize) }, (_, i) => ({ + id: `log-${(page - 1) * pageSize + i + 1}`, + model: MODELS[i % MODELS.length], + prompt_tokens: 500 + Math.floor(Math.random() * 2000), + completion_tokens: 200 + Math.floor(Math.random() * 800), + quota_cost: Math.floor(Math.random() * 50) + 5, + created_at: new Date(Date.now() - i * 3600000).toISOString(), + })) + + return { + items, + summary: { + total_requests: 21100, + total_tokens: 14770000, + total_cost: 26920, + }, + pagination: { page, page_size: pageSize, total }, + } +} + +function mockDailyBuckets(startDate: string, endDate: string): UsageBucket[] { + const buckets: UsageBucket[] = [] + const start = new Date(startDate) + const end = new Date(endDate) + for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { + const base = 200 + Math.floor(Math.random() * 800) + buckets.push({ + key: d.toISOString().split('T')[0], + requests: base, + prompt_tokens: base * 500 + Math.floor(Math.random() * 50000), + completion_tokens: base * 200 + Math.floor(Math.random() * 20000), + quota_cost: Math.floor(base * 1.5), + }) + } + return buckets +} + +const MOCK_MODEL_BUCKETS: UsageBucket[] = [ + { key: 'gpt-4o', requests: 4200, prompt_tokens: 2100000, completion_tokens: 840000, quota_cost: 12600 }, + { key: 'gpt-4o-mini', requests: 8500, prompt_tokens: 4250000, completion_tokens: 1700000, quota_cost: 3400 }, + { key: 'claude-sonnet-4-6', requests: 2100, prompt_tokens: 1050000, completion_tokens: 420000, quota_cost: 8400 }, + { key: 'deepseek-chat', requests: 6300, prompt_tokens: 3150000, completion_tokens: 1260000, quota_cost: 2520 }, +] + +const USE_MOCK = true +// ── End mock ───────────────────────────────────────────── + +export const usageApi = { + // GET /api/usage — matches contract response shape + getUsage: (params: UsageQueryParams): Promise => + USE_MOCK + ? Promise.resolve(mockUsageResponse(params)) + : apiClient.get('/api/usage', { params }), + + // Aggregated buckets (前端扩展,后端可后续添加) + getBuckets: (params: UsageQueryParams & { group_by: 'day' | 'model' }): Promise => + USE_MOCK + ? Promise.resolve(params.group_by === 'model' ? MOCK_MODEL_BUCKETS : mockDailyBuckets(params.start_date, params.end_date)) + : apiClient.get('/api/usage/buckets', { params }), +} diff --git a/web/modules/usage/components/usage-chart.tsx b/web/modules/usage/components/usage-chart.tsx new file mode 100644 index 0000000..85b4d55 --- /dev/null +++ b/web/modules/usage/components/usage-chart.tsx @@ -0,0 +1,68 @@ +'use client' + +import { useTranslations } from 'next-intl' +import { useUsageBuckets } from '../hooks/use-usage' +import type { UsageQueryParams } from '../types/usage.types' + +interface Props { + params: UsageQueryParams & { group_by: 'day' | 'model' } +} + +function formatNumber(n: number): string { + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M' + if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K' + return n.toString() +} + +export function UsageChart({ params }: Props) { + const { data, isLoading } = useUsageBuckets(params) + const t = useTranslations('usage') + const tc = useTranslations('common') + + if (isLoading) { + return
{tc('loading')}
+ } + + if (!data?.length) { + return ( +
+

{tc('noData')}

+
+ ) + } + + const maxRequests = Math.max(...data.map((b) => b.requests)) + + return ( +
+

+ {params.group_by === 'model' ? t('byModel') : t('byDay')} +

+ +
+ {data.map((bucket) => { + const pct = maxRequests > 0 ? (bucket.requests / maxRequests) * 100 : 0 + return ( +
+ + {params.group_by === 'day' ? bucket.key.slice(5) : bucket.key} + +
+
+ + {formatNumber(bucket.requests)} {t('requests')} + +
+ + {formatNumber(bucket.prompt_tokens + bucket.completion_tokens)} tok + +
+ ) + })} +
+
+ ) +} diff --git a/web/modules/usage/components/usage-logs-table.tsx b/web/modules/usage/components/usage-logs-table.tsx new file mode 100644 index 0000000..2db3158 --- /dev/null +++ b/web/modules/usage/components/usage-logs-table.tsx @@ -0,0 +1,68 @@ +'use client' + +import { useTranslations } from 'next-intl' +import { useUsage } from '../hooks/use-usage' +import type { UsageQueryParams } from '../types/usage.types' + +interface Props { + params: UsageQueryParams +} + +export function UsageLogsTable({ params }: Props) { + const { data, isLoading } = useUsage({ ...params, page: 1, page_size: 20 }) + const t = useTranslations('usage') + const tc = useTranslations('common') + + if (isLoading) { + return
{tc('loading')}
+ } + + if (!data?.items?.length) { + return ( +
+

{tc('noData')}

+
+ ) + } + + return ( +
+
+

{t('recentLogs')}

+
+
+ + + + + + + + + + + + {data.items.map((log) => ( + + + + + + + + ))} + +
{t('time')}{t('model')}{t('promptTokensShort')}{t('completionTokensShort')}{t('cost')}
+ {new Date(log.created_at).toLocaleString()} + + {log.model} + {log.prompt_tokens.toLocaleString()}{log.completion_tokens.toLocaleString()}{log.quota_cost}
+
+ {data.pagination && ( +
+ {tc('total', { count: data.pagination.total })} +
+ )} +
+ ) +} diff --git a/web/modules/usage/components/usage-summary.tsx b/web/modules/usage/components/usage-summary.tsx new file mode 100644 index 0000000..929712d --- /dev/null +++ b/web/modules/usage/components/usage-summary.tsx @@ -0,0 +1,51 @@ +'use client' + +import { useTranslations } from 'next-intl' +import { useUsage } from '../hooks/use-usage' +import { Activity, Zap, DollarSign } from 'lucide-react' +import type { UsageQueryParams } from '../types/usage.types' + +function formatNumber(n: number): string { + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M' + if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K' + return n.toLocaleString() +} + +interface Props { + params: UsageQueryParams +} + +export function UsageSummaryCards({ params }: Props) { + const { data, isLoading } = useUsage(params) + const t = useTranslations('usage') + const tc = useTranslations('common') + + if (isLoading) { + return
{tc('loading')}
+ } + + const summary = data?.summary + if (!summary) return null + + const cards = [ + { label: t('totalRequests'), value: formatNumber(summary.total_requests), icon: Activity, color: 'text-blue-500 bg-blue-50' }, + { label: t('totalTokens'), value: formatNumber(summary.total_tokens), icon: Zap, color: 'text-amber-500 bg-amber-50' }, + { label: t('quotaCost'), value: formatNumber(summary.total_cost), icon: DollarSign, color: 'text-purple-500 bg-purple-50' }, + ] + + return ( +
+ {cards.map((card) => ( +
+
+ {card.label} + + + +
+

{card.value}

+
+ ))} +
+ ) +} diff --git a/web/modules/usage/hooks/use-usage.ts b/web/modules/usage/hooks/use-usage.ts new file mode 100644 index 0000000..0578c26 --- /dev/null +++ b/web/modules/usage/hooks/use-usage.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query' +import { usageApi } from '../api/usage-api' +import type { UsageQueryParams } from '../types/usage.types' + +export function useUsage(params: UsageQueryParams) { + return useQuery({ + queryKey: ['usage', params], + queryFn: () => usageApi.getUsage(params), + staleTime: 60 * 1000, + }) +} + +export function useUsageBuckets(params: UsageQueryParams & { group_by: 'day' | 'model' }) { + return useQuery({ + queryKey: ['usage', 'buckets', params], + queryFn: () => usageApi.getBuckets(params), + staleTime: 60 * 1000, + }) +} diff --git a/web/modules/usage/index.ts b/web/modules/usage/index.ts new file mode 100644 index 0000000..d0945d3 --- /dev/null +++ b/web/modules/usage/index.ts @@ -0,0 +1,5 @@ +export { UsageSummaryCards } from './components/usage-summary' +export { UsageChart } from './components/usage-chart' +export { UsageLogsTable } from './components/usage-logs-table' +export { useUsage, useUsageBuckets } from './hooks/use-usage' +export type { UsageSummary, UsageLog, UsageQueryParams, UsageResponse, UsageBucket } from './types/usage.types' diff --git a/web/modules/usage/types/usage.types.ts b/web/modules/usage/types/usage.types.ts new file mode 100644 index 0000000..be6b65e --- /dev/null +++ b/web/modules/usage/types/usage.types.ts @@ -0,0 +1,42 @@ +export interface UsageSummary { + total_requests: number + total_tokens: number + total_cost: number +} + +export interface UsageLog { + id: string + model: string + prompt_tokens: number + completion_tokens: number + quota_cost: number + created_at: string +} + +export interface UsageQueryParams { + start_date: string + end_date: string + model?: string + page?: number + page_size?: number +} + +/** GET /api/usage 的完整响应 */ +export interface UsageResponse { + items: UsageLog[] + summary: UsageSummary + pagination: { + page: number + page_size: number + total: number + } +} + +/** 用于图表的聚合数据(前端本地计算或后端扩展接口) */ +export interface UsageBucket { + key: string + requests: number + prompt_tokens: number + completion_tokens: number + quota_cost: number +} diff --git a/web/shared/components/layout/sidebar.tsx b/web/shared/components/layout/sidebar.tsx index 2eba44a..c73e691 100644 --- a/web/shared/components/layout/sidebar.tsx +++ b/web/shared/components/layout/sidebar.tsx @@ -3,6 +3,7 @@ import { Link, usePathname } from '@/core/i18n/navigation' import { useMenus } from '@/modules/menu' import type { MenuItem } from '@/modules/menu' +import { QuotaBar } from '@/modules/quota' import { useAuth } from '@/shared/providers/auth-provider' import { cn } from '@/shared/lib/utils' import { @@ -48,15 +49,16 @@ export function Sidebar() {
+
-
-

{user?.username}

+ +

{user?.username}

{user?.tenant_name}

-
-
diff --git a/web/shared/lib/api-client.ts b/web/shared/lib/api-client.ts index dddd6dc..d73f9ee 100644 --- a/web/shared/lib/api-client.ts +++ b/web/shared/lib/api-client.ts @@ -1,10 +1,18 @@ -import axios from 'axios' +import axios, { type AxiosInstance, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios' + +// 扩展 axios 类型,让 response interceptor 返回 data 后类型正确 +interface ApiClient extends AxiosInstance { + get(url: string, config?: AxiosRequestConfig): Promise + post(url: string, data?: any, config?: AxiosRequestConfig): Promise + put(url: string, data?: any, config?: AxiosRequestConfig): Promise + delete(url: string, config?: AxiosRequestConfig): Promise +} export const apiClient = axios.create({ baseURL: '', timeout: 15000, headers: { 'Content-Type': 'application/json' }, -}) +}) as ApiClient apiClient.interceptors.request.use((config) => { if (typeof window !== 'undefined') { @@ -12,23 +20,89 @@ apiClient.interceptors.request.use((config) => { if (token) { config.headers.Authorization = `Bearer ${token}` } - // 把浏览器当前域名传给后端,用于识别租户 - // 开发环境: Next.js rewrite 会丢失 Host 头,靠这个 header 补回来 - // 生产环境: Higress 直接透传 Host,这个 header 作为备用 config.headers['X-Forwarded-Host'] = window.location.host } return config }) +// Token refresh state — shared across concurrent 401s +let isRefreshing = false +let refreshSubscribers: Array<(token: string) => void> = [] + +function onTokenRefreshed(token: string) { + refreshSubscribers.forEach((cb) => cb(token)) + refreshSubscribers = [] +} + +function addRefreshSubscriber(cb: (token: string) => void) { + refreshSubscribers.push(cb) +} + +function clearAuthAndRedirect() { + localStorage.removeItem('access_token') + localStorage.removeItem('user_info') + document.cookie = 'access_token=;path=/;max-age=0' + window.location.href = '/login' +} + apiClient.interceptors.response.use( (response) => response.data, - (error) => { - if (error.response?.status === 401 && typeof window !== 'undefined') { - localStorage.removeItem('access_token') - localStorage.removeItem('user_info') - document.cookie = 'access_token=;path=/;max-age=0' - window.location.href = '/login' + async (error) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean } + + if (error.response?.status !== 401 || typeof window === 'undefined') { + return Promise.reject(error) + } + + // Don't retry if this is already a retry or if it's the refresh call itself + if (originalRequest._retry || originalRequest.url === '/api/auth/refresh') { + clearAuthAndRedirect() + return Promise.reject(error) + } + + if (isRefreshing) { + // Another request is already refreshing — wait for it + return new Promise((resolve) => { + addRefreshSubscriber((newToken: string) => { + originalRequest.headers.Authorization = `Bearer ${newToken}` + originalRequest._retry = true + resolve(apiClient(originalRequest)) + }) + }) + } + + isRefreshing = true + originalRequest._retry = true + + try { + const token = localStorage.getItem('access_token') + const res = await axios.post('/api/auth/refresh', null, { + headers: { + Authorization: `Bearer ${token}`, + 'X-Forwarded-Host': window.location.host, + }, + }) + + const newToken = res.data.access_token + localStorage.setItem('access_token', newToken) + document.cookie = `access_token=${newToken};path=/;max-age=${60 * 60 * 24 * 7};samesite=lax` + + // Update user info if returned + if (res.data.user) { + localStorage.setItem('user_info', JSON.stringify(res.data.user)) + } + + onTokenRefreshed(newToken) + + // Retry original request with new token + originalRequest.headers.Authorization = `Bearer ${newToken}` + return apiClient(originalRequest) + } catch { + refreshSubscribers = [] + clearAuthAndRedirect() + return Promise.reject(error) + } finally { + isRefreshing = false } - return Promise.reject(error) }, )