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(`
`, 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(`
+
+
+
+
+
+
+
+
+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 = () => (
+
+ )
+
+ const renderOAuthButtons = () => (
+
+ {hasGoogle && (
+
+ )}
+ {hasGithub && (
+
+ )}
+
+ )
+
+ const renderPasswordForm = () => (
+
+ )
+
+ const renderEmailCodeFlow = () => {
+ if (emailStep === 'code') {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+ }
+
+ const renderSetPasswordView = () => (
+
+
+
+
+
+
{t('setPasswordTitle')}
+
{t('setPasswordDesc')}
+
+
+
+
+
+ )
+
+ // ── Main render ──────────────────────────────────────
+
return (
+ {/* Header */}
-
-
{t('title')}
-
{t('subtitle')}
+
+ {brand.logo ? (
+

+ ) : (
+
+ {brand.name[0]}
+
+ )}
+
+
{brand.name}
+
{t('subtitle')}
+
- {/* SSO OAuth buttons */}
- {providers.length > 0 && (
-
- {providers.includes('google') && (
-
- )}
-
- {providers.includes('github') && (
-
- )}
-
- {/* Divider */}
-
+ {/* Welcome toast */}
+ {welcomeMsg && (
+
+ {welcomeMsg}
)}
- {/* Email/password form */}
-
+ {/* 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')}
-
+
+
)
}
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' ? (
+
+ ) : (
+
+ )}
+
+ )
+}
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')}
+ )}
+
+
+
+ )
+}
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}
+ ))}
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+ )}
+ >
+ )
+}
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() })}
+
+
+ ) : (
+
+ )}
+
+ )
+}
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 (
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ | {t('codeName')} |
+ {t('code')} |
+ {t('quota')} |
+ {tc('status')} |
+ {t('createdAt')} |
+
+
+
+ {data.items.map((item) => {
+ const statusInfo = STATUS_MAP[item.status]
+ return (
+
+ | {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 && (
+
+ )}
+
+
+
+
+
+ | {t('groupName')} |
+ {t('displayName')} |
+ {t('ratio')} |
+ {t('priceEffect')} |
+ {hasPermission('pricing:update') && (
+ {tc('actions')} |
+ )}
+
+
+
+ {items?.map((group) => (
+
+
+
+ {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) })}
+ |
+ {hasPermission('pricing:update') && (
+
+ {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 (
+
+ )
+ }
+
+ return (
+
+
+
+
+ | {t('model')} |
+ {t('modelType')} |
+ {t('modelRatio')} |
+ {t('completionRatio')} |
+ {t('inputCost')} |
+ {t('outputCost')} |
+ {tc('status')} |
+ {hasPermission('pricing:update') && (
+ {tc('actions')} |
+ )}
+
+
+
+ {items.map((item) => {
+ const inputPer1m = (item.model_ratio * 2).toFixed(2)
+ const outputPer1m = (item.model_ratio * item.completion_ratio * 2).toFixed(2)
+ return (
+
+ |
+
+
+ {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')}
+
+ |
+ {hasPermission('pricing:update') && (
+
+ {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
+
+
+ )}
+
+
+
+
+ {/* 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') && (
+
+ )}
+
+ {/* GitHub OAuth — show when github enabled */}
+ {methods.includes('github') && (
+
+ )}
+
+ {/* 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 (
+
+ )
+ }
+
+ 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 (
+
+ )
+ }
+
+ return (
+
+
+
{t('recentLogs')}
+
+
+
+
+
+ | {t('time')} |
+ {t('model')} |
+ {t('promptTokensShort')} |
+ {t('completionTokensShort')} |
+ {t('cost')} |
+
+
+
+ {data.items.map((log) => (
+
+ |
+ {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)
},
)