Bladeren bron

feat: DoTouch.AI platform initial commit

- 品牌: B1 网络枢纽 Logo 定稿 + SVG/质感稿/动态视频
- 官网: 6 页多语言站(vi/en/zh-CN/zh-TW)+ 模型大全 236 定价子页
- 后端: NestJS monorepo(认证/验证码/Key安全/OpenAI兼容网关/计费/用量/Quota限额/Admin)
- 模型: 阿里云百炼 236 在售模型真实扫描入库 + 定价体系
- 控制台: 用户控制台 + 平台管理后台双套设计
- 合规: top3 中转站 + 阿里云百炼条款调研(转售需书面许可)
- 测试: 端到端用例 40/40 通过 + 测试账号
DoTouch Dev 2 weken geleden
commit
e0331c4ba3
100 gewijzigde bestanden met toevoegingen van 4968 en 0 verwijderingen
  1. 34 0
      .env.example
  2. 30 0
      .gitignore
  3. 5 0
      .npmrc
  4. 10 0
      .omo/run-continuation/ses_0205e2a66ffeqhUqRG4CI5wqld.json
  5. 10 0
      .omo/run-continuation/ses_0206d7935ffeDtXZXL1imuzTWZ.json
  6. 10 0
      .omo/run-continuation/ses_02253b6cfffefGEi15Hv3GWKaQ.json
  7. 10 0
      .omo/run-continuation/ses_0226c9f74ffeKs0d0hX5mSqWhv.json
  8. 10 0
      .omo/run-continuation/ses_02270421bffeH8lLxHPLgFGaX3.json
  9. 10 0
      .omo/run-continuation/ses_0227c368fffeMgymaHtskGFMmW.json
  10. 10 0
      .omo/run-continuation/ses_02281a9e0ffeO802eMtxCgCZiK.json
  11. 10 0
      .omo/run-continuation/ses_02281c035ffecBlrHdV5VARzgx.json
  12. 10 0
      .omo/run-continuation/ses_0228909c3ffe9SxSsgEQstw7k9.json
  13. 10 0
      .omo/run-continuation/ses_02289280cffd4N07DXPeSUvajc.json
  14. 10 0
      .omo/run-continuation/ses_0229980c7ffeNT3qzbjKsfvkzT.json
  15. 10 0
      .omo/run-continuation/ses_0229f653bffedHZYFR3ILxjlpg.json
  16. 67 0
      README.md
  17. 1 0
      VERSION
  18. 29 0
      apps/api/.env.example
  19. 8 0
      apps/api/nest-cli.json
  20. 42 0
      apps/api/package.json
  21. 89 0
      apps/api/prisma/migrations/20260807183204_foundation/migration.sql
  22. 45 0
      apps/api/prisma/migrations/20260807192242_model_catalog_and_role/migration.sql
  23. 33 0
      apps/api/prisma/migrations/20260808043250_quota/migration.sql
  24. 3 0
      apps/api/prisma/migrations/migration_lock.toml
  25. 167 0
      apps/api/prisma/schema.prisma
  26. 37 0
      apps/api/scripts/dev-admin.mjs
  27. 62 0
      apps/api/scripts/dev-credit.mjs
  28. 162 0
      apps/api/scripts/mock-upstream.mjs
  29. 152 0
      apps/api/scripts/seed-models.mjs
  30. 141 0
      apps/api/src/admin/admin.controller.ts
  31. 18 0
      apps/api/src/admin/admin.guard.ts
  32. 15 0
      apps/api/src/admin/admin.module.ts
  33. 174 0
      apps/api/src/admin/admin.service.ts
  34. 34 0
      apps/api/src/app.module.ts
  35. 55 0
      apps/api/src/auth/auth.controller.ts
  36. 31 0
      apps/api/src/auth/auth.module.ts
  37. 189 0
      apps/api/src/auth/auth.service.ts
  38. 22 0
      apps/api/src/auth/current-user.decorator.ts
  39. 9 0
      apps/api/src/auth/dto/forgot.dto.ts
  40. 12 0
      apps/api/src/auth/dto/login.dto.ts
  41. 20 0
      apps/api/src/auth/dto/register.dto.ts
  42. 20 0
      apps/api/src/auth/dto/reset.dto.ts
  43. 13 0
      apps/api/src/auth/dto/send-code.dto.ts
  44. 8 0
      apps/api/src/auth/jwt-auth.guard.ts
  45. 23 0
      apps/api/src/auth/jwt.strategy.ts
  46. 108 0
      apps/api/src/email/email-verification.service.ts
  47. 12 0
      apps/api/src/email/email.module.ts
  48. 31 0
      apps/api/src/email/email.service.ts
  49. 185 0
      apps/api/src/gateway/gateway.controller.ts
  50. 18 0
      apps/api/src/gateway/gateway.module.ts
  51. 378 0
      apps/api/src/gateway/gateway.service.ts
  52. 43 0
      apps/api/src/gateway/rate-limiter.service.ts
  53. 16 0
      apps/api/src/health/health.controller.ts
  54. 10 0
      apps/api/src/health/health.module.ts
  55. 15 0
      apps/api/src/keys/dto/create-key.dto.ts
  56. 17 0
      apps/api/src/keys/dto/update-key.dto.ts
  57. 54 0
      apps/api/src/keys/keys.controller.ts
  58. 15 0
      apps/api/src/keys/keys.module.ts
  59. 233 0
      apps/api/src/keys/keys.service.ts
  60. 31 0
      apps/api/src/main.ts
  61. 35 0
      apps/api/src/models/models.controller.ts
  62. 13 0
      apps/api/src/models/models.module.ts
  63. 85 0
      apps/api/src/models/models.service.ts
  64. 30 0
      apps/api/src/models/pricing.ts
  65. 12 0
      apps/api/src/prisma/prisma.module.ts
  66. 20 0
      apps/api/src/prisma/prisma.service.ts
  67. 50 0
      apps/api/src/quota/dto/set-limits.dto.ts
  68. 114 0
      apps/api/src/quota/quota.controller.ts
  69. 15 0
      apps/api/src/quota/quota.module.ts
  70. 738 0
      apps/api/src/quota/quota.service.ts
  71. 12 0
      apps/api/src/redis/redis.module.ts
  72. 35 0
      apps/api/src/redis/redis.service.ts
  73. 34 0
      apps/api/src/usage/usage.controller.ts
  74. 14 0
      apps/api/src/usage/usage.module.ts
  75. 106 0
      apps/api/src/usage/usage.service.ts
  76. 17 0
      apps/api/src/wallet/exceptions/insufficient-balance.exception.ts
  77. 33 0
      apps/api/src/wallet/wallet.controller.ts
  78. 15 0
      apps/api/src/wallet/wallet.module.ts
  79. 139 0
      apps/api/src/wallet/wallet.service.ts
  80. 4 0
      apps/api/tsconfig.build.json
  81. 27 0
      apps/api/tsconfig.json
  82. 17 0
      apps/web/messages/en.json
  83. 17 0
      apps/web/messages/vi.json
  84. 17 0
      apps/web/messages/zh-CN.json
  85. 17 0
      apps/web/messages/zh-TW.json
  86. 5 0
      apps/web/next-env.d.ts
  87. 12 0
      apps/web/next.config.js
  88. 28 0
      apps/web/package.json
  89. 6 0
      apps/web/postcss.config.js
  90. 38 0
      apps/web/src/app/[locale]/layout.tsx
  91. 19 0
      apps/web/src/app/[locale]/page.tsx
  92. 13 0
      apps/web/src/app/globals.css
  93. 16 0
      apps/web/src/components/Footer.tsx
  94. 32 0
      apps/web/src/components/Header.tsx
  95. 29 0
      apps/web/src/components/Hero.tsx
  96. 47 0
      apps/web/src/components/LocaleSwitcher.tsx
  97. 20 0
      apps/web/src/i18n/request.ts
  98. 9 0
      apps/web/src/middleware.ts
  99. 22 0
      apps/web/tailwind.config.js
  100. 20 0
      apps/web/tsconfig.json

+ 34 - 0
.env.example

@@ -0,0 +1,34 @@
+# DoTouch.AI - Environment Variables (root)
+# Copy this file to .env and fill in real values
+
+# Database (PostgreSQL)
+DATABASE_URL=postgresql://dotouch:dotouch@localhost:5432/dotouch?schema=public
+
+# Redis
+REDIS_URL=redis://localhost:6379
+
+# JWT
+JWT_SECRET=change-me-in-production
+JWT_EXPIRES_IN=7d
+
+# API Server
+PORT=8080
+
+# Email (SMTP) - 开发环境仅打印到控制台,生产环境填写真实配置
+# SMTP_HOST=smtp.example.com
+# SMTP_PORT=587
+# SMTP_USER=your-email@example.com
+# SMTP_PASS=your-password
+# SMTP_FROM=noreply@dotouch.ai
+
+# Next.js Web
+NEXT_PUBLIC_API_URL=http://localhost:8080/v1
+
+# DashScope 上游 API Key(从阿里云 DashScope 控制台获取)
+DASHSCOPE_API_KEY=sk-placeholder-change-me
+
+# DashScope 上游 API 地址
+DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
+
+# 网关频率限制(每 API Key 每分钟请求数)
+GATEWAY_RATE_LIMIT_PER_MIN=60

+ 30 - 0
.gitignore

@@ -0,0 +1,30 @@
+# Dependencies
+node_modules/
+.pnpm-store/
+
+# Build outputs
+dist/
+.next/
+.turbo/
+
+# Environment files
+.env
+.env.local
+.env.*.local
+
+# OS files
+.DS_Store
+Thumbs.db
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+
+# Logs
+*.log
+logs/
+
+# Generated
+prisma/**/*.prisma.generated.*

+ 5 - 0
.npmrc

@@ -0,0 +1,5 @@
+auto-install-peers=true
+strict-peer-dependencies=false
+shamefully-hoist=true
+engine-strict=true
+onlyBuiltDependencies=@prisma/client @prisma/engines prisma

+ 10 - 0
.omo/run-continuation/ses_0205e2a66ffeqhUqRG4CI5wqld.json

@@ -0,0 +1,10 @@
+{
+  "sessionID": "ses_0205e2a66ffeqhUqRG4CI5wqld",
+  "updatedAt": "2026-08-08T04:58:21.755Z",
+  "sources": {
+    "background-task": {
+      "state": "idle",
+      "updatedAt": "2026-08-08T04:58:21.755Z"
+    }
+  }
+}

+ 10 - 0
.omo/run-continuation/ses_0206d7935ffeDtXZXL1imuzTWZ.json

@@ -0,0 +1,10 @@
+{
+  "sessionID": "ses_0206d7935ffeDtXZXL1imuzTWZ",
+  "updatedAt": "2026-08-08T04:19:51.533Z",
+  "sources": {
+    "background-task": {
+      "state": "idle",
+      "updatedAt": "2026-08-08T04:19:51.533Z"
+    }
+  }
+}

+ 10 - 0
.omo/run-continuation/ses_02253b6cfffefGEi15Hv3GWKaQ.json

@@ -0,0 +1,10 @@
+{
+  "sessionID": "ses_02253b6cfffefGEi15Hv3GWKaQ",
+  "updatedAt": "2026-08-07T19:31:04.847Z",
+  "sources": {
+    "background-task": {
+      "state": "idle",
+      "updatedAt": "2026-08-07T19:31:04.847Z"
+    }
+  }
+}

+ 10 - 0
.omo/run-continuation/ses_0226c9f74ffeKs0d0hX5mSqWhv.json

@@ -0,0 +1,10 @@
+{
+  "sessionID": "ses_0226c9f74ffeKs0d0hX5mSqWhv",
+  "updatedAt": "2026-08-07T18:54:12.871Z",
+  "sources": {
+    "background-task": {
+      "state": "idle",
+      "updatedAt": "2026-08-07T18:54:12.871Z"
+    }
+  }
+}

+ 10 - 0
.omo/run-continuation/ses_02270421bffeH8lLxHPLgFGaX3.json

@@ -0,0 +1,10 @@
+{
+  "sessionID": "ses_02270421bffeH8lLxHPLgFGaX3",
+  "updatedAt": "2026-08-07T19:08:44.345Z",
+  "sources": {
+    "background-task": {
+      "state": "idle",
+      "updatedAt": "2026-08-07T19:08:44.345Z"
+    }
+  }
+}

+ 10 - 0
.omo/run-continuation/ses_0227c368fffeMgymaHtskGFMmW.json

@@ -0,0 +1,10 @@
+{
+  "sessionID": "ses_0227c368fffeMgymaHtskGFMmW",
+  "updatedAt": "2026-08-07T18:46:53.571Z",
+  "sources": {
+    "background-task": {
+      "state": "idle",
+      "updatedAt": "2026-08-07T18:46:53.571Z"
+    }
+  }
+}

+ 10 - 0
.omo/run-continuation/ses_02281a9e0ffeO802eMtxCgCZiK.json

@@ -0,0 +1,10 @@
+{
+  "sessionID": "ses_02281a9e0ffeO802eMtxCgCZiK",
+  "updatedAt": "2026-08-07T18:31:14.699Z",
+  "sources": {
+    "background-task": {
+      "state": "idle",
+      "updatedAt": "2026-08-07T18:31:14.699Z"
+    }
+  }
+}

+ 10 - 0
.omo/run-continuation/ses_02281c035ffecBlrHdV5VARzgx.json

@@ -0,0 +1,10 @@
+{
+  "sessionID": "ses_02281c035ffecBlrHdV5VARzgx",
+  "updatedAt": "2026-08-07T18:31:09.087Z",
+  "sources": {
+    "background-task": {
+      "state": "idle",
+      "updatedAt": "2026-08-07T18:31:09.087Z"
+    }
+  }
+}

+ 10 - 0
.omo/run-continuation/ses_0228909c3ffe9SxSsgEQstw7k9.json

@@ -0,0 +1,10 @@
+{
+  "sessionID": "ses_0228909c3ffe9SxSsgEQstw7k9",
+  "updatedAt": "2026-08-07T18:23:08.871Z",
+  "sources": {
+    "background-task": {
+      "state": "idle",
+      "updatedAt": "2026-08-07T18:23:08.871Z"
+    }
+  }
+}

+ 10 - 0
.omo/run-continuation/ses_02289280cffd4N07DXPeSUvajc.json

@@ -0,0 +1,10 @@
+{
+  "sessionID": "ses_02289280cffd4N07DXPeSUvajc",
+  "updatedAt": "2026-08-07T18:34:30.012Z",
+  "sources": {
+    "background-task": {
+      "state": "idle",
+      "updatedAt": "2026-08-07T18:34:30.012Z"
+    }
+  }
+}

+ 10 - 0
.omo/run-continuation/ses_0229980c7ffeNT3qzbjKsfvkzT.json

@@ -0,0 +1,10 @@
+{
+  "sessionID": "ses_0229980c7ffeNT3qzbjKsfvkzT",
+  "updatedAt": "2026-08-07T18:21:19.342Z",
+  "sources": {
+    "background-task": {
+      "state": "idle",
+      "updatedAt": "2026-08-07T18:21:19.342Z"
+    }
+  }
+}

+ 10 - 0
.omo/run-continuation/ses_0229f653bffedHZYFR3ILxjlpg.json

@@ -0,0 +1,10 @@
+{
+  "sessionID": "ses_0229f653bffedHZYFR3ILxjlpg",
+  "updatedAt": "2026-08-08T06:07:12.791Z",
+  "sources": {
+    "background-task": {
+      "state": "idle",
+      "updatedAt": "2026-08-08T06:07:12.791Z"
+    }
+  }
+}

+ 67 - 0
README.md

@@ -0,0 +1,67 @@
+# DoTouch.AI - AI API 算力中转站
+
+AI 大模型 API 转售平台,基于 OpenAI 兼容格式,向越南及海外用户转售国产大模型能力(阿里云 DashScope: GLM 5.2, DeepSeek V4 系列)。
+
+## 架构概览
+
+```
+dotouch-ai/
+├── apps/
+│   ├── web/          # Next.js 14 (App Router) - 落地页 + 未来控制台
+│   └── api/          # NestJS v11 - API 网关服务
+├── packages/
+│   └── shared/       # 共享类型 & 常量
+├── docker-compose.yml  # PostgreSQL 16 + Redis 7
+└── deploy/           # Docker 部署配置 (后续)
+```
+
+## 技术栈
+
+| 层 | 技术 |
+|---|------|
+| 前端 | Next.js 14 + TypeScript + Tailwind CSS + next-intl |
+| 后端 | NestJS v11 + Prisma (PostgreSQL) + ioredis (Redis) |
+| 数据库 | PostgreSQL 16 |
+| 缓存 | Redis 7 |
+| 包管理 | pnpm (monorepo workspace) |
+| 部署 | Docker → 香港节点 |
+
+## 国际化
+
+支持 4 种语言:`vi` (越南语), `en` (英语), `zh-CN` (简体中文), `zh-TW` (繁体中文)
+
+## 快速开始
+
+```bash
+# 安装依赖
+pnpm install
+
+# 启动开发数据库
+docker compose up -d
+
+# 生成 Prisma 客户端
+pnpm db:generate
+
+# 启动开发服务器
+pnpm dev
+
+# 构建
+pnpm build
+
+# 类型检查
+pnpm typecheck
+```
+
+## 环境变量
+
+复制 `.env.example` 到 `.env` 并填写真实值。
+
+## API 端点
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | /v1/health | 健康检查 |
+
+## 许可
+
+UNLICENSED - All rights reserved.

+ 1 - 0
VERSION

@@ -0,0 +1 @@
+v1.0.0 (website-home)

+ 29 - 0
apps/api/.env.example

@@ -0,0 +1,29 @@
+# DoTouch.AI API - Environment Variables
+# Copy to .env and fill in real values
+
+# Database
+DATABASE_URL=postgresql://dotouch:dotouch@localhost:5432/dotouch?schema=public
+
+# Redis
+REDIS_URL=redis://localhost:6379
+
+# JWT
+JWT_SECRET=change-me-in-production
+JWT_EXPIRES_IN=7d
+
+# Server
+PORT=8080
+
+# Email (SMTP) - 开发环境仅打印到控制台,生产环境填写真实配置
+# SMTP_HOST=smtp.example.com
+# SMTP_PORT=587
+# SMTP_USER=your-email@example.com
+# SMTP_PASS=your-password
+# SMTP_FROM=noreply@dotouch.ai
+
+# DashScope 上游 API
+DASHSCOPE_API_KEY=sk-placeholder-change-me
+DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
+
+# 网关频率限制(每 API Key 每分钟请求数)
+GATEWAY_RATE_LIMIT_PER_MIN=60

+ 8 - 0
apps/api/nest-cli.json

@@ -0,0 +1,8 @@
+{
+  "$schema": "https://json.schemastore.org/nest-cli",
+  "collection": "@nestjs/schematics",
+  "sourceRoot": "src",
+  "compilerOptions": {
+    "deleteOutDir": true
+  }
+}

+ 42 - 0
apps/api/package.json

@@ -0,0 +1,42 @@
+{
+  "name": "@dotouch/api",
+  "version": "0.0.1",
+  "private": true,
+  "description": "DoTouch.AI API Gateway (NestJS)",
+  "scripts": {
+    "build": "nest build",
+    "dev": "nest start --watch",
+    "start": "node dist/main",
+    "typecheck": "tsc --noEmit",
+    "db:generate": "prisma generate"
+  },
+  "dependencies": {
+    "@dotouch/shared": "workspace:*",
+    "@nestjs/common": "^11.0.0",
+    "@nestjs/config": "^4.0.0",
+    "@nestjs/core": "^11.0.0",
+    "@nestjs/jwt": "^11.0.2",
+    "@nestjs/passport": "^11.0.5",
+    "@nestjs/platform-express": "^11.0.0",
+    "@prisma/client": "^6.0.0",
+    "bcryptjs": "^3.0.3",
+    "class-transformer": "^0.5.1",
+    "class-validator": "^0.15.1",
+    "ioredis": "^5.4.0",
+    "passport": "^0.7.0",
+    "passport-jwt": "^4.0.1",
+    "reflect-metadata": "^0.2.0",
+    "rxjs": "^7.8.0"
+  },
+  "devDependencies": {
+    "@nestjs/cli": "^11.0.0",
+    "@nestjs/schematics": "^11.0.0",
+    "@types/bcryptjs": "^3.0.0",
+    "@types/express": "^5.0.6",
+    "@types/node": "^22.0.0",
+    "@types/passport-jwt": "^4.0.1",
+    "prisma": "^6.0.0",
+    "ts-node": "^10.9.0",
+    "typescript": "^5.7.0"
+  }
+}

+ 89 - 0
apps/api/prisma/migrations/20260807183204_foundation/migration.sql

@@ -0,0 +1,89 @@
+-- CreateTable
+CREATE TABLE "users" (
+    "id" TEXT NOT NULL,
+    "email" TEXT NOT NULL,
+    "password_hash" TEXT NOT NULL,
+    "currency" TEXT NOT NULL DEFAULT 'USD',
+    "balance_cents" BIGINT NOT NULL DEFAULT 0,
+    "status" TEXT NOT NULL DEFAULT 'active',
+    "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updated_at" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "users_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "api_keys" (
+    "id" TEXT NOT NULL,
+    "user_id" TEXT NOT NULL,
+    "name" TEXT NOT NULL,
+    "key_hash" TEXT NOT NULL,
+    "prefix" TEXT NOT NULL,
+    "last_used_at" TIMESTAMP(3),
+    "status" TEXT NOT NULL DEFAULT 'active',
+    "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updated_at" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "api_keys_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "balance_transactions" (
+    "id" TEXT NOT NULL,
+    "user_id" TEXT NOT NULL,
+    "amount_cents" BIGINT NOT NULL,
+    "type" TEXT NOT NULL,
+    "ref_id" TEXT,
+    "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+    CONSTRAINT "balance_transactions_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "usage_records" (
+    "id" TEXT NOT NULL,
+    "user_id" TEXT NOT NULL,
+    "api_key_id" TEXT NOT NULL,
+    "model" TEXT NOT NULL,
+    "prompt_tokens" INTEGER NOT NULL,
+    "completion_tokens" INTEGER NOT NULL,
+    "cost_cents" INTEGER NOT NULL,
+    "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+    CONSTRAINT "usage_records_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "orders" (
+    "id" TEXT NOT NULL,
+    "user_id" TEXT NOT NULL,
+    "provider" TEXT NOT NULL,
+    "provider_order_id" TEXT,
+    "amount_cents" BIGINT NOT NULL,
+    "status" TEXT NOT NULL DEFAULT 'pending',
+    "currency" TEXT NOT NULL DEFAULT 'USD',
+    "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+    CONSTRAINT "orders_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "api_keys_key_hash_key" ON "api_keys"("key_hash");
+
+-- AddForeignKey
+ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "balance_transactions" ADD CONSTRAINT "balance_transactions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "usage_records" ADD CONSTRAINT "usage_records_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "usage_records" ADD CONSTRAINT "usage_records_api_key_id_fkey" FOREIGN KEY ("api_key_id") REFERENCES "api_keys"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "orders" ADD CONSTRAINT "orders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

+ 45 - 0
apps/api/prisma/migrations/20260807192242_model_catalog_and_role/migration.sql

@@ -0,0 +1,45 @@
+-- AlterTable
+ALTER TABLE "api_keys" ADD COLUMN     "expires_at" TIMESTAMP(3);
+
+-- AlterTable
+ALTER TABLE "users" ADD COLUMN     "role" TEXT NOT NULL DEFAULT 'user';
+
+-- CreateTable
+CREATE TABLE "api_key_events" (
+    "id" TEXT NOT NULL,
+    "api_key_id" TEXT NOT NULL,
+    "user_id" TEXT NOT NULL,
+    "action" TEXT NOT NULL,
+    "ip" TEXT,
+    "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+    CONSTRAINT "api_key_events_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "models" (
+    "id" TEXT NOT NULL,
+    "public_id" TEXT NOT NULL,
+    "upstream_id" TEXT NOT NULL,
+    "name" TEXT NOT NULL,
+    "provider" TEXT NOT NULL,
+    "category" TEXT NOT NULL,
+    "description" TEXT,
+    "context_window" INTEGER,
+    "max_output" INTEGER,
+    "input_price_cents" INTEGER NOT NULL DEFAULT 0,
+    "output_price_cents" INTEGER NOT NULL DEFAULT 0,
+    "is_recommended" BOOLEAN NOT NULL DEFAULT false,
+    "status" TEXT NOT NULL DEFAULT 'active',
+    "sort_order" INTEGER NOT NULL DEFAULT 0,
+    "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updated_at" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "models_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "models_public_id_key" ON "models"("public_id");
+
+-- AddForeignKey
+ALTER TABLE "api_key_events" ADD CONSTRAINT "api_key_events_api_key_id_fkey" FOREIGN KEY ("api_key_id") REFERENCES "api_keys"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

+ 33 - 0
apps/api/prisma/migrations/20260808043250_quota/migration.sql

@@ -0,0 +1,33 @@
+-- AlterTable
+ALTER TABLE "api_keys" ADD COLUMN     "alert_pct" INTEGER NOT NULL DEFAULT 80,
+ADD COLUMN     "daily_cost_cents_limit" INTEGER,
+ADD COLUMN     "daily_request_limit" INTEGER,
+ADD COLUMN     "daily_token_limit" BIGINT,
+ADD COLUMN     "total_cost_cents_limit" INTEGER,
+ADD COLUMN     "total_request_limit" INTEGER,
+ADD COLUMN     "total_token_limit" BIGINT;
+
+-- AlterTable
+ALTER TABLE "users" ADD COLUMN     "alert_pct" INTEGER NOT NULL DEFAULT 80,
+ADD COLUMN     "daily_cost_cents_limit" INTEGER,
+ADD COLUMN     "daily_request_limit" INTEGER,
+ADD COLUMN     "daily_token_limit" BIGINT,
+ADD COLUMN     "total_cost_cents_limit" INTEGER,
+ADD COLUMN     "total_request_limit" INTEGER,
+ADD COLUMN     "total_token_limit" BIGINT;
+
+-- CreateTable
+CREATE TABLE "quota_events" (
+    "id" TEXT NOT NULL,
+    "user_id" TEXT NOT NULL,
+    "api_key_id" TEXT,
+    "dimension" TEXT NOT NULL,
+    "window_type" TEXT NOT NULL,
+    "scope_type" TEXT NOT NULL,
+    "threshold" INTEGER NOT NULL,
+    "current" BIGINT NOT NULL,
+    "action" TEXT NOT NULL,
+    "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+    CONSTRAINT "quota_events_pkey" PRIMARY KEY ("id")
+);

+ 3 - 0
apps/api/prisma/migrations/migration_lock.toml

@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (e.g., Git)
+provider = "postgresql"

+ 167 - 0
apps/api/prisma/schema.prisma

@@ -0,0 +1,167 @@
+// DoTouch.AI - Prisma Schema
+// PostgreSQL 16 datasource, client generated via `pnpm db:generate`
+generator client {
+  provider = "prisma-client-js"
+}
+
+datasource db {
+  provider = "postgresql"
+  url      = env("DATABASE_URL")
+}
+
+// ---- 用户 ----
+model User {
+  id           String   @id @default(cuid())
+  email        String   @unique
+  passwordHash String   @map("password_hash")
+  role         String   @default("user") // user | admin
+  currency     String   @default("USD")
+  balanceCents BigInt   @default(0) @map("balance_cents")
+  status       String   @default("active") // active | suspended | banned
+  createdAt    DateTime @default(now()) @map("created_at")
+  updatedAt    DateTime @updatedAt @map("updated_at")
+
+  // 用量配额(null = 不限)
+  dailyRequestLimit     Int?    @map("daily_request_limit")
+  dailyTokenLimit       BigInt? @map("daily_token_limit")
+  dailyCostCentsLimit   Int?    @map("daily_cost_cents_limit")
+  totalRequestLimit     Int?    @map("total_request_limit")
+  totalTokenLimit       BigInt? @map("total_token_limit")
+  totalCostCentsLimit   Int?    @map("total_cost_cents_limit")
+  alertPct              Int     @default(80) @map("alert_pct")
+
+  apiKeys             ApiKey[]
+  balanceTransactions BalanceTransaction[]
+  usageRecords        UsageRecord[]
+  orders              Order[]
+
+  @@map("users")
+}
+
+// ---- API 密钥 ----
+model ApiKey {
+  id         String    @id @default(cuid())
+  userId     String    @map("user_id")
+  name       String
+  keyHash    String    @unique @map("key_hash")
+  prefix     String    // 前 12 位用于展示(dt_ + 32 hex = 36 chars)
+  expiresAt  DateTime? @map("expires_at")
+  lastUsedAt DateTime? @map("last_used_at")
+  status     String    @default("active") // active | revoked
+  createdAt  DateTime  @default(now()) @map("created_at")
+  updatedAt  DateTime  @updatedAt @map("updated_at")
+
+  // 用量配额(null = 不限)
+  dailyRequestLimit     Int?    @map("daily_request_limit")
+  dailyTokenLimit       BigInt? @map("daily_token_limit")
+  dailyCostCentsLimit   Int?    @map("daily_cost_cents_limit")
+  totalRequestLimit     Int?    @map("total_request_limit")
+  totalTokenLimit       BigInt? @map("total_token_limit")
+  totalCostCentsLimit   Int?    @map("total_cost_cents_limit")
+  alertPct              Int     @default(80) @map("alert_pct")
+
+  user         User           @relation(fields: [userId], references: [id])
+  usageRecords UsageRecord[]
+  events       ApiKeyEvent[]
+
+  @@map("api_keys")
+}
+
+// ---- API Key 事件审计日志 ----
+model ApiKeyEvent {
+  id        String   @id @default(cuid())
+  apiKeyId  String   @map("api_key_id")
+  userId    String   @map("user_id")
+  action    String   // created | rotated | revoked | used
+  ip        String?
+  createdAt DateTime @default(now()) @map("created_at")
+
+  apiKey ApiKey @relation(fields: [apiKeyId], references: [id])
+
+  @@map("api_key_events")
+}
+
+// ---- 模型目录(DB-backed) ----
+model ModelCatalog {
+  id             String   @id @default(cuid())
+  publicId       String   @unique @map("public_id")
+  upstreamId     String   @map("upstream_id")
+  name           String
+  provider       String   // Zhipu | DeepSeek | Alibaba | MiniMax | Kimi | SiliconFlow | Other
+  category       String   // chat | vision | image | speech
+  description    String?
+  contextWindow  Int?     @map("context_window")
+  maxOutput      Int?     @map("max_output")
+  inputPriceCents  Int    @default(0) @map("input_price_cents")
+  outputPriceCents Int   @default(0) @map("output_price_cents")
+  isRecommended  Boolean  @default(false) @map("is_recommended")
+  status         String   @default("active") // active | hidden | disabled
+  sortOrder      Int      @default(0) @map("sort_order")
+  createdAt      DateTime @default(now()) @map("created_at")
+  updatedAt      DateTime @updatedAt @map("updated_at")
+
+  @@map("models")
+}
+
+// ---- 余额变动记录 ----
+model BalanceTransaction {
+  id         String   @id @default(cuid())
+  userId     String   @map("user_id")
+  amountCents BigInt  @map("amount_cents")
+  type       String   // topup | charge | refund | usage | refund
+  refId      String?  @map("ref_id")
+  createdAt  DateTime @default(now()) @map("created_at")
+
+  user User @relation(fields: [userId], references: [id])
+
+  @@map("balance_transactions")
+}
+
+// ---- 用量记录 ----
+model UsageRecord {
+  id               String   @id @default(cuid())
+  userId           String   @map("user_id")
+  apiKeyId         String   @map("api_key_id")
+  model            String
+  promptTokens     Int      @map("prompt_tokens")
+  completionTokens Int      @map("completion_tokens")
+  costCents        Int      @map("cost_cents")
+  createdAt        DateTime @default(now()) @map("created_at")
+
+  user   User   @relation(fields: [userId], references: [id])
+  apiKey ApiKey @relation(fields: [apiKeyId], references: [id])
+
+  @@map("usage_records")
+}
+
+// ---- 配额事件(预警 + 超限审计) ----
+model QuotaEvent {
+  id         String   @id @default(cuid())
+  userId     String   @map("user_id")
+  apiKeyId   String?  @map("api_key_id")
+  dimension  String   // request | token | cost
+  windowType String   @map("window_type") // daily | total
+  scopeType  String   @map("scope_type") // account | key
+  threshold  Int
+  current    BigInt
+  action     String   // alert | block
+  createdAt  DateTime @default(now()) @map("created_at")
+
+  @@map("quota_events")
+}
+
+// ---- 订单 ----
+model Order {
+  id              String   @id @default(cuid())
+  userId          String   @map("user_id")
+  provider        String   // alipay | stripe | ...
+  providerOrderId String?  @map("provider_order_id")
+  amountCents     BigInt   @map("amount_cents")
+  status          String   @default("pending") // pending | paid | failed | refunded
+  currency        String   @default("USD")
+  createdAt       DateTime @default(now()) @map("created_at")
+
+  user User @relation(fields: [userId], references: [id])
+
+  @@map("orders")
+}

+ 37 - 0
apps/api/scripts/dev-admin.mjs

@@ -0,0 +1,37 @@
+/**
+ * DEV-ONLY: 将指定邮箱的用户提升为管理员
+ * 用法:node scripts/dev-admin.mjs <email>
+ * 示例:node scripts/dev-admin.mjs admin@dotouch.ai
+ */
+import { PrismaClient } from '@prisma/client';
+
+async function main() {
+  const email = process.argv[2];
+  if (!email) {
+    console.error('Usage: node scripts/dev-admin.mjs <email>');
+    process.exit(1);
+  }
+
+  const prisma = new PrismaClient();
+
+  try {
+    const user = await prisma.user.findUnique({ where: { email } });
+    if (!user) {
+      console.error(`User not found: ${email}`);
+      process.exit(1);
+    }
+
+    await prisma.user.update({
+      where: { email },
+      data: { role: 'admin' },
+    });
+
+    console.log(`✅ User ${email} promoted to admin (DEV-ONLY)`);
+    await prisma.$disconnect();
+  } catch (err) {
+    console.error('Failed:', err);
+    process.exit(1);
+  }
+}
+
+main();

+ 62 - 0
apps/api/scripts/dev-credit.mjs

@@ -0,0 +1,62 @@
+/**
+ * dev-credit.mjs — 开发环境用户余额充值脚本(仅限开发/测试使用,⚠️ 不可用于生产)
+ *
+ * 用法:
+ *   node apps/api/scripts/dev-credit.mjs <email> <amount_cents>
+ *
+ * 示例:
+ *   node apps/api/scripts/dev-credit.mjs test@dotouch.ai 100000
+ *   # 为用户 test@dotouch.ai 充值 100000 美分($1000.00)
+ */
+
+import { PrismaClient } from '@prisma/client';
+
+const email = process.argv[2];
+const amountCents = parseInt(process.argv[3], 10);
+
+if (!email || isNaN(amountCents) || amountCents <= 0) {
+  console.error('用法: node dev-credit.mjs <email> <amount_cents>');
+  console.error('示例: node dev-credit.mjs test@dotouch.ai 100000');
+  process.exit(1);
+}
+
+const prisma = new PrismaClient();
+
+async function main() {
+  const user = await prisma.user.findUnique({ where: { email } });
+  if (!user) {
+    console.error(`错误: 未找到用户 ${email}`);
+    await prisma.$disconnect();
+    process.exit(1);
+  }
+
+  const result = await prisma.$transaction(async (tx) => {
+    const updated = await tx.user.update({
+      where: { id: user.id },
+      data: { balanceCents: { increment: BigInt(amountCents) } },
+      select: { balanceCents: true },
+    });
+
+    await tx.balanceTransaction.create({
+      data: {
+        userId: user.id,
+        amountCents: BigInt(amountCents),
+        type: 'topup',
+        refId: `dev_credit_${Date.now()}`,
+      },
+    });
+
+    return updated;
+  });
+
+  console.log(
+    `✅ 已为用户 ${email} 充值 ${amountCents} 美分。当前余额: ${result.balanceCents} 美分`,
+  );
+}
+
+main()
+  .catch((err) => {
+    console.error('充值失败:', err);
+    process.exit(1);
+  })
+  .finally(() => prisma.$disconnect());

+ 162 - 0
apps/api/scripts/mock-upstream.mjs

@@ -0,0 +1,162 @@
+/**
+ * mock-upstream.mjs — 本地模拟 DashScope 上游端点(开发测试用,保留以供后续测试)
+ *
+ * 用途:在无真实 DashScope API Key 的情况下验证网关全链路。
+ *
+ * 启动方式:
+ *   node apps/api/scripts/mock-upstream.mjs
+ *   监听端口: 19090
+ *
+ * 端点:
+ *   POST /v1/chat/completions
+ *     - 非流式:返回 OpenAI 格式 JSON,含 usage {prompt_tokens: 50000, completion_tokens: 80000}
+ *     - 流式 (stream: true):返回 SSE 格式,最后一条包含 usage
+ */
+
+import { createServer } from 'node:http';
+
+const PORT = 19090;
+
+function randomId(prefix) {
+  const rand = Math.random().toString(36).slice(2, 10);
+  return `${prefix}_${rand}`;
+}
+
+function nowUnix() {
+  return Math.floor(Date.now() / 1000);
+}
+
+function handleChatCompletions(body, res) {
+  const isStream = body && body.stream === true;
+  const model = (body && body.model) || 'unknown';
+  const id = randomId('chatcmpl');
+  const created = nowUnix();
+
+  if (isStream) {
+    // SSE 流式响应
+    res.writeHead(200, {
+      'Content-Type': 'text/event-stream',
+      'Cache-Control': 'no-cache',
+      Connection: 'keep-alive',
+    });
+
+    const chunks = [
+      {
+        id,
+        object: 'chat.completion.chunk',
+        created,
+        model,
+        choices: [
+          {
+            index: 0,
+            delta: { role: 'assistant', content: '' },
+            finish_reason: null,
+          },
+        ],
+        usage: null,
+      },
+      {
+        id,
+        object: 'chat.completion.chunk',
+        created,
+        model,
+        choices: [
+          {
+            index: 0,
+            delta: {
+              content: '你好!我是由 DoTouch.AI 提供的 AI 助手,请问有什么可以帮助你的?',
+            },
+            finish_reason: null,
+          },
+        ],
+        usage: null,
+      },
+      {
+        id,
+        object: 'chat.completion.chunk',
+        created,
+        model,
+        choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
+        usage: {
+          prompt_tokens: 50000,
+          completion_tokens: 80000,
+          total_tokens: 130,
+        },
+      },
+    ];
+
+    for (const chunk of chunks) {
+      res.write(`data: ${JSON.stringify(chunk)}\n\n`);
+    }
+    res.write('data: [DONE]\n\n');
+    res.end();
+  } else {
+    // 非流式 JSON 响应
+    const response = {
+      id,
+      object: 'chat.completion',
+      created,
+      model,
+      choices: [
+        {
+          index: 0,
+          message: {
+            role: 'assistant',
+            content:
+              '你好!我是由 DoTouch.AI 提供的 AI 助手,请问有什么可以帮助你的?',
+          },
+          finish_reason: 'stop',
+        },
+      ],
+      usage: {
+        prompt_tokens: 50000,
+        completion_tokens: 80000,
+        total_tokens: 130,
+      },
+    };
+
+    res.writeHead(200, { 'Content-Type': 'application/json' });
+    res.end(JSON.stringify(response));
+  }
+}
+
+function parseBody(req) {
+  return new Promise((resolve) => {
+    let data = '';
+    req.on('data', (chunk) => {
+      data += chunk;
+    });
+    req.on('end', () => {
+      try {
+        resolve(JSON.parse(data));
+      } catch {
+        resolve(null);
+      }
+    });
+  });
+}
+
+const server = createServer(async (req, res) => {
+  const parsedUrl = new URL(req.url || '/', `http://localhost:${PORT}`);
+
+  if (
+    req.method === 'POST' &&
+    parsedUrl.pathname === '/v1/chat/completions'
+  ) {
+    const body = await parseBody(req);
+    handleChatCompletions(body, res);
+  } else if (req.method === 'GET' && parsedUrl.pathname === '/health') {
+    res.writeHead(200, { 'Content-Type': 'application/json' });
+    res.end(JSON.stringify({ status: 'ok' }));
+  } else {
+    res.writeHead(404, { 'Content-Type': 'application/json' });
+    res.end(
+      JSON.stringify({ error: { message: 'Not found', type: '404', code: '404' } }),
+    );
+  }
+});
+
+server.listen(PORT, () => {
+  console.log(`🔧 Mock DashScope upstream running on http://localhost:${PORT}`);
+  console.log(`   POST /v1/chat/completions`);
+});

+ 152 - 0
apps/api/scripts/seed-models.mjs

@@ -0,0 +1,152 @@
+/**
+ * 模型目录种子脚本 - 从 dashscope-models-20260808.json 导入 236 个模型到 DB
+ * 幂等:使用 upsert,可安全重复执行
+ * 用法:node scripts/seed-models.mjs
+ */
+import { PrismaClient } from '@prisma/client';
+import { readFileSync } from 'node:fs';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const DATA_PATH = join(__dirname, '..', '..', '..', 'docs', 'models', 'dashscope-models-20260808.json');
+
+// ---- 分类逻辑:按优先级 speech > image > vision > chat ----
+function classifyCategory(publicId) {
+  const lower = publicId.toLowerCase();
+  // speech: TTS / ASR / audio / realtime / livetranslate / s2s
+  if (/tts|asr|audio|speech|realtime|livetranslate|s2s/.test(lower)) return 'speech';
+  // image
+  if (/image|wan2/i.test(lower)) return 'image';
+  // vision: vl / omni / ocr
+  if (/vl|omni|ocr/.test(lower)) return 'vision';
+  return 'chat';
+}
+
+function classifyProvider(publicId) {
+  const lower = publicId.toLowerCase();
+  if (lower.startsWith('glm') || lower.includes('zhipu/')) return 'Zhipu';
+  if (lower.includes('deepseek')) return 'DeepSeek';
+  if (lower.includes('minimax')) return 'MiniMax';
+  if (lower.includes('kimi')) return 'Kimi';
+  if (lower.includes('siliconflow')) return 'SiliconFlow';
+  if (lower.includes('vanchin')) return 'Vanchin';
+  if (lower.includes('xiaomi')) return 'Xiaomi';
+  // Alibaba: qwen, qvq, qwq, tongyi, codeqwen, fun-, sre, test, wan, z-image, gui
+  if (
+    lower.startsWith('qwen') || lower.startsWith('qvq') || lower.startsWith('qwq') ||
+    lower.startsWith('tongyi') || lower.startsWith('codeqwen') ||
+    lower.startsWith('fun-') || lower.startsWith('sre-') || lower.startsWith('test-') ||
+    lower.startsWith('wan') || lower.startsWith('z-image') || lower.startsWith('gui')
+  ) {
+    return 'Alibaba';
+  }
+  return 'Other';
+}
+
+function makeName(publicId) {
+  // 尝试提取一个适合展示的名字
+  const parts = publicId.split('/');
+  return parts[parts.length - 1] ?? publicId;
+}
+
+function makeDescription(category, provider, publicId) {
+  switch (category) {
+    case 'speech': return `${provider} 语音模型: ${publicId}`;
+    case 'image': return `${provider} 图像模型: ${publicId}`;
+    case 'vision': return `${provider} 视觉模型: ${publicId}`;
+    default: return `${provider} 对话模型: ${publicId}`;
+  }
+}
+
+const RECOMMENDED = new Set([
+  'glm-5.2',
+  'glm-5.2-fast-preview',
+  'deepseek-v4-flash',
+  'deepseek-v4-pro',
+  'qwen-max',
+  'qwen-plus',
+  'qwen-turbo',
+  'qwen-flash',
+  'qwen3-max',
+  'qwen3-coder-plus',
+  'kimi-k2.6',
+  'MiniMax-M3',
+]);
+
+async function main() {
+  const prisma = new PrismaClient();
+
+  try {
+    const raw = readFileSync(DATA_PATH, 'utf-8');
+    const data = JSON.parse(raw);
+    const models = /** @type {string[]} */ (data.models);
+    if (!models || models.length === 0) {
+      console.error('No models found in JSON file');
+      process.exit(1);
+    }
+
+    console.log(`Seeding ${models.length} models...`);
+
+    let upserted = 0;
+    for (let i = 0; i < models.length; i++) {
+      const publicId = models[i];
+      const category = classifyCategory(publicId);
+      const provider = classifyProvider(publicId);
+      const name = makeName(publicId);
+      const description = makeDescription(category, provider, publicId);
+      const isRecommended = RECOMMENDED.has(publicId);
+
+      try {
+        await prisma.modelCatalog.upsert({
+          where: { publicId },
+          update: {
+            upstreamId: publicId,
+            name,
+            provider,
+            category,
+            description,
+            isRecommended,
+            status: 'active',
+            sortOrder: i,
+          },
+          create: {
+            publicId,
+            upstreamId: publicId,
+            name,
+            provider,
+            category,
+            description,
+            // TODO: 价格待定,后续通过管理后台设置
+            // 定价规则:DashScope 价格 × 0.7
+            inputPriceCents: 0,
+            outputPriceCents: 0,
+            isRecommended,
+            status: 'active',
+            sortOrder: i,
+          },
+        });
+        upserted++;
+      } catch (err) {
+        console.error(`Failed to upsert ${publicId}: ${err.message}`);
+      }
+    }
+
+    console.log(`Done. Upserted ${upserted}/${models.length} models.`);
+
+    // 打印分类统计
+    const counts = {};
+    for (const m of models) {
+      const cat = classifyCategory(m);
+      counts[cat] = (counts[cat] || 0) + 1;
+    }
+    console.log('Category counts:', counts);
+
+    await prisma.$disconnect();
+  } catch (err) {
+    console.error('Seed failed:', err);
+    process.exit(1);
+  }
+}
+
+main();

+ 141 - 0
apps/api/src/admin/admin.controller.ts

@@ -0,0 +1,141 @@
+/**
+ * 管理后台控制器 - 统计 / 用户管理 / 模型定价(需 JWT + AdminGuard)
+ */
+import {
+  Controller,
+  Get,
+  Patch,
+  Post,
+  Put,
+  Param,
+  Body,
+  Query,
+  UseGuards,
+  HttpCode,
+} from '@nestjs/common';
+import { AdminService } from './admin.service';
+import { QuotaService } from '../quota/quota.service';
+import { SetLimitsDto } from '../quota/dto/set-limits.dto';
+import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { AdminGuard } from './admin.guard';
+
+@Controller('admin')
+@UseGuards(JwtAuthGuard, AdminGuard)
+export class AdminController {
+  constructor(
+    private readonly adminService: AdminService,
+    private readonly quotaService: QuotaService,
+  ) {}
+
+  /** 平台统计 */
+  @Get('stats')
+  getStats() {
+    return this.adminService.getStats();
+  }
+
+  /** 用户列表 */
+  @Get('users')
+  listUsers(
+    @Query('page') page?: string,
+    @Query('pageSize') pageSize?: string,
+    @Query('q') q?: string,
+  ) {
+    return this.adminService.listUsers({
+      page: Math.max(1, Number(page) || 1),
+      pageSize: Math.min(100, Math.max(1, Number(pageSize) || 20)),
+      q,
+    });
+  }
+
+  /** 更新用户 */
+  @Patch('users/:id')
+  updateUser(
+    @Param('id') id: string,
+    @Body() body: { status?: string; role?: string },
+  ) {
+    return this.adminService.updateUser(id, body);
+  }
+
+  /** 模型列表(管理后台完整视图) */
+  @Get('models')
+  listModels(
+    @Query('category') category?: string,
+    @Query('status') status?: string,
+    @Query('q') q?: string,
+  ) {
+    return this.adminService.listModels({ category, status, q });
+  }
+
+  /** 更新模型定价 / 状态 / 推荐 */
+  @Put('models/:publicId')
+  updateModel(
+    @Param('publicId') publicId: string,
+    @Body()
+    body: {
+      inputPriceCents?: number;
+      outputPriceCents?: number;
+      status?: string;
+      isRecommended?: boolean;
+      description?: string;
+    },
+  ) {
+    return this.adminService.updateModel(publicId, body);
+  }
+
+  /** 从上游同步模型列表(TODO: 未来实现) */
+  @Post('models/sync')
+  @HttpCode(501)
+  syncModels() {
+    return {
+      message: 'TODO: 从 DashScope /v1/models 同步模型列表(暂未实现)',
+    };
+  }
+
+  // ========== 配额管理(管理员) ==========
+
+  /** 全局限额视图 */
+  @Get('quotas')
+  listQuotas(
+    @Query('page') page?: string,
+    @Query('pageSize') pageSize?: string,
+    @Query('q') q?: string,
+    @Query('scope') scope?: string,
+  ) {
+    return this.quotaService.listQuotas({
+      page: Math.max(1, Number(page) || 1),
+      pageSize: Math.min(100, Math.max(1, Number(pageSize) || 20)),
+      q,
+      scope,
+    });
+  }
+
+  /** 管理员覆盖用户限额 */
+  @Put('users/:id/limits')
+  setUserLimits(
+    @Param('id') id: string,
+    @Body() dto: SetLimitsDto,
+  ) {
+    return this.quotaService.adminSetUserLimits(id, dto as Record<string, unknown>);
+  }
+
+  /** 管理员覆盖 Key 限额 */
+  @Put('keys/:id/limits')
+  setKeyLimits(
+    @Param('id') id: string,
+    @Body() dto: SetLimitsDto,
+  ) {
+    return this.quotaService.adminSetKeyLimits(id, dto as Record<string, unknown>);
+  }
+
+  /** 配额事件列表 */
+  @Get('quota-events')
+  listQuotaEvents(
+    @Query('action') action?: string,
+    @Query('limit') limit?: string,
+  ) {
+    return this.quotaService.listQuotaEvents({
+      action,
+      limit: Math.min(200, Math.max(1, Number(limit) || 50)),
+    });
+  }
+}

+ 18 - 0
apps/api/src/admin/admin.guard.ts

@@ -0,0 +1,18 @@
+/**
+ * 管理员守卫 - 验证当前用户 role === 'admin'
+ */
+import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
+import type { Request } from 'express';
+import type { JwtPayload } from '../auth/current-user.decorator';
+
+@Injectable()
+export class AdminGuard implements CanActivate {
+  canActivate(context: ExecutionContext): boolean {
+    const request = context.switchToHttp().getRequest<Request>();
+    const user = request['user'] as JwtPayload | undefined;
+    if (!user || user.role !== 'admin') {
+      throw new ForbiddenException('需要管理员权限');
+    }
+    return true;
+  }
+}

+ 15 - 0
apps/api/src/admin/admin.module.ts

@@ -0,0 +1,15 @@
+/**
+ * 管理后台模块 - 统计 / 用户管理 / 模型定价 / 配额管理
+ */
+import { Module } from '@nestjs/common';
+import { AdminService } from './admin.service';
+import { AdminController } from './admin.controller';
+import { AuthModule } from '../auth/auth.module';
+import { QuotaModule } from '../quota/quota.module';
+
+@Module({
+  imports: [AuthModule, QuotaModule],
+  controllers: [AdminController],
+  providers: [AdminService],
+})
+export class AdminModule {}

+ 174 - 0
apps/api/src/admin/admin.service.ts

@@ -0,0 +1,174 @@
+/**
+ * 管理后台服务 - 统计、用户管理、模型定价管理
+ */
+import { Injectable, NotFoundException } from '@nestjs/common';
+import { PrismaService } from '../prisma/prisma.service';
+
+@Injectable()
+export class AdminService {
+  constructor(private readonly prisma: PrismaService) {}
+
+  /** 平台统计概览 */
+  async getStats() {
+    const [
+      totalUsers,
+      activeUsers7d,
+      totalTokens,
+      todayRequests,
+      totalRevenueCents,
+    ] = await Promise.all([
+      this.prisma.user.count(),
+      this.prisma.user.count({
+        where: {
+          createdAt: { gte: new Date(Date.now() - 7 * 86400_000) },
+        },
+      }),
+      this.prisma.usageRecord.aggregate({
+        _sum: {
+          promptTokens: true,
+          completionTokens: true,
+        },
+      }),
+      this.prisma.usageRecord.count({
+        where: {
+          createdAt: { gte: new Date(new Date().toDateString()) },
+        },
+      }),
+      this.prisma.balanceTransaction.aggregate({
+        _sum: {
+          amountCents: true,
+        },
+        where: {
+          amountCents: { gt: 0 },
+        },
+      }),
+    ]);
+
+    return {
+      users: totalUsers,
+      activeUsers7d,
+      totalRevenueCents: Number(totalRevenueCents._sum.amountCents ?? 0),
+      totalTokens:
+        (totalTokens._sum.promptTokens ?? 0) +
+        (totalTokens._sum.completionTokens ?? 0),
+      todayRequests,
+    };
+  }
+
+  /** 用户列表(分页 + 搜索) */
+  async listUsers(params: { page: number; pageSize: number; q?: string }) {
+    const { page, pageSize, q } = params;
+    const where = q
+      ? { email: { contains: q, mode: 'insensitive' as const } }
+      : {};
+
+    const [users, total] = await Promise.all([
+      this.prisma.user.findMany({
+        where,
+        skip: (page - 1) * pageSize,
+        take: pageSize,
+        orderBy: { createdAt: 'desc' },
+        select: {
+          id: true,
+          email: true,
+          role: true,
+          status: true,
+          balanceCents: true,
+          createdAt: true,
+        },
+      }),
+      this.prisma.user.count({ where }),
+    ]);
+
+    return {
+      total,
+      page,
+      pageSize,
+      data: users.map((u) => ({
+        ...u,
+        balanceCents: Number(u.balanceCents),
+        createdAt: u.createdAt.toISOString(),
+      })),
+    };
+  }
+
+  /** 更新用户状态或角色 */
+  async updateUser(id: string, data: { status?: string; role?: string }) {
+    const user = await this.prisma.user.findUnique({ where: { id } });
+    if (!user) {
+      throw new NotFoundException('用户不存在');
+    }
+    const updated = await this.prisma.user.update({
+      where: { id },
+      data,
+      select: {
+        id: true,
+        email: true,
+        role: true,
+        status: true,
+        balanceCents: true,
+        createdAt: true,
+      },
+    });
+    return {
+      ...updated,
+      balanceCents: Number(updated.balanceCents),
+      createdAt: updated.createdAt.toISOString(),
+    };
+  }
+
+  /** 模型列表(管理后台,含隐藏/禁用) */
+  async listModels(params: { category?: string; status?: string; q?: string }) {
+    const { category, status, q } = params;
+    const where: Record<string, unknown> = {};
+    if (category) where['category'] = category;
+    if (status) where['status'] = status;
+    if (q) {
+      where['OR'] = [
+        { publicId: { contains: q, mode: 'insensitive' } },
+        { name: { contains: q, mode: 'insensitive' } },
+      ];
+    }
+
+    const models = await this.prisma.modelCatalog.findMany({
+      where,
+      orderBy: [{ sortOrder: 'asc' }, { publicId: 'asc' }],
+    });
+
+    return {
+      total: models.length,
+      data: models,
+    };
+  }
+
+  /** 更新模型定价 / 状态 / 推荐 */
+  async updateModel(
+    publicId: string,
+    data: {
+      inputPriceCents?: number;
+      outputPriceCents?: number;
+      status?: string;
+      isRecommended?: boolean;
+      description?: string;
+    },
+  ) {
+    const model = await this.prisma.modelCatalog.findUnique({
+      where: { publicId },
+    });
+    if (!model) {
+      throw new NotFoundException(`模型 ${publicId} 不存在`);
+    }
+
+    const updateData: Record<string, unknown> = {};
+    if (data.inputPriceCents !== undefined) updateData['inputPriceCents'] = data.inputPriceCents;
+    if (data.outputPriceCents !== undefined) updateData['outputPriceCents'] = data.outputPriceCents;
+    if (data.status !== undefined) updateData['status'] = data.status;
+    if (data.isRecommended !== undefined) updateData['isRecommended'] = data.isRecommended;
+    if (data.description !== undefined) updateData['description'] = data.description;
+
+    return this.prisma.modelCatalog.update({
+      where: { publicId },
+      data: updateData,
+    });
+  }
+}

+ 34 - 0
apps/api/src/app.module.ts

@@ -0,0 +1,34 @@
+/**
+ * 根模块 - 组装所有子模块 (Config / Health / Prisma / Redis / Models / Gateway / Usage / Admin / Quota)
+ */
+import { Module } from '@nestjs/common';
+import { ConfigModule } from '@nestjs/config';
+import { HealthModule } from './health/health.module';
+import { PrismaModule } from './prisma/prisma.module';
+import { RedisModule } from './redis/redis.module';
+import { AuthModule } from './auth/auth.module';
+import { KeysModule } from './keys/keys.module';
+import { WalletModule } from './wallet/wallet.module';
+import { ModelsModule } from './models/models.module';
+import { GatewayModule } from './gateway/gateway.module';
+import { UsageModule } from './usage/usage.module';
+import { AdminModule } from './admin/admin.module';
+import { QuotaModule } from './quota/quota.module';
+
+@Module({
+  imports: [
+    ConfigModule.forRoot({ isGlobal: true }),
+    HealthModule,
+    PrismaModule,
+    RedisModule,
+    AuthModule,
+    KeysModule,
+    WalletModule,
+    ModelsModule,
+    GatewayModule,
+    UsageModule,
+    AdminModule,
+    QuotaModule,
+  ],
+})
+export class AppModule {}

+ 55 - 0
apps/api/src/auth/auth.controller.ts

@@ -0,0 +1,55 @@
+/**
+ * 认证控制器 - 注册 / 登录 / 获取当前用户 / 验证码 / 密码重置
+ */
+import { Controller, Post, Get, Body, UseGuards } from '@nestjs/common';
+import { AuthService } from './auth.service';
+import { RegisterDto } from './dto/register.dto';
+import { LoginDto } from './dto/login.dto';
+import { SendCodeDto } from './dto/send-code.dto';
+import { ForgotDto } from './dto/forgot.dto';
+import { ResetDto } from './dto/reset.dto';
+import { JwtAuthGuard } from './jwt-auth.guard';
+import { CurrentUser } from './current-user.decorator';
+import type { JwtPayload } from './current-user.decorator';
+
+@Controller('auth')
+export class AuthController {
+  constructor(private readonly authService: AuthService) {}
+
+  /** 发送验证码(注册 / 重置密码) */
+  @Post('send-code')
+  sendCode(@Body() dto: SendCodeDto) {
+    return this.authService.sendCode(dto.email, dto.purpose);
+  }
+
+  /** 注册(需验证码) */
+  @Post('register')
+  register(@Body() dto: RegisterDto) {
+    return this.authService.register(dto);
+  }
+
+  /** 登录 */
+  @Post('login')
+  login(@Body() dto: LoginDto) {
+    return this.authService.login(dto);
+  }
+
+  /** 忘记密码 - 发送重置验证码 */
+  @Post('forgot')
+  forgot(@Body() dto: ForgotDto) {
+    return this.authService.forgotPassword(dto.email);
+  }
+
+  /** 重置密码 */
+  @Post('reset')
+  reset(@Body() dto: ResetDto) {
+    return this.authService.resetPassword(dto.email, dto.code, dto.newPassword);
+  }
+
+  /** 获取当前用户信息 */
+  @UseGuards(JwtAuthGuard)
+  @Get('me')
+  getProfile(@CurrentUser() user: JwtPayload) {
+    return this.authService.getProfile(user.sub);
+  }
+}

+ 31 - 0
apps/api/src/auth/auth.module.ts

@@ -0,0 +1,31 @@
+/**
+ * 认证模块 - JWT + bcrypt 用户注册与登录,含邮件验证码 + 登录频率限制
+ */
+import { Module } from '@nestjs/common';
+import { JwtModule } from '@nestjs/jwt';
+import { ConfigService } from '@nestjs/config';
+import { PassportModule } from '@nestjs/passport';
+import { AuthService } from './auth.service';
+import { AuthController } from './auth.controller';
+import { JwtStrategy } from './jwt.strategy';
+import { EmailModule } from '../email/email.module';
+
+@Module({
+  imports: [
+    EmailModule,
+    PassportModule.register({ defaultStrategy: 'jwt' }),
+    JwtModule.registerAsync({
+      inject: [ConfigService],
+      useFactory: (config: ConfigService) => ({
+        secret: config.getOrThrow<string>('JWT_SECRET'),
+        signOptions: {
+          expiresIn: (config.get<string>('JWT_EXPIRES_IN') || '7d') as unknown as number,
+        },
+      }),
+    }),
+  ],
+  controllers: [AuthController],
+  providers: [AuthService, JwtStrategy],
+  exports: [JwtModule, PassportModule],
+})
+export class AuthModule {}

+ 189 - 0
apps/api/src/auth/auth.service.ts

@@ -0,0 +1,189 @@
+/**
+ * 认证服务 - 注册(含验证码)、登录(含频率限制)、JWT 签发、密码重置
+ */
+import {
+  Injectable,
+  ConflictException,
+  UnauthorizedException,
+  HttpException,
+  HttpStatus,
+} from '@nestjs/common';
+import { JwtService } from '@nestjs/jwt';
+import { PrismaService } from '../prisma/prisma.service';
+import { RedisService } from '../redis/redis.service';
+import { EmailVerificationService } from '../email/email-verification.service';
+import { RegisterDto } from './dto/register.dto';
+import { LoginDto } from './dto/login.dto';
+import type { JwtPayload } from './current-user.decorator';
+import * as bcrypt from 'bcryptjs';
+
+const LOGIN_FAIL_LIMIT = 5;
+const LOGIN_FAIL_WINDOW = 900; // 15 分钟
+
+@Injectable()
+export class AuthService {
+  constructor(
+    private readonly prisma: PrismaService,
+    private readonly jwt: JwtService,
+    private readonly redis: RedisService,
+    private readonly verifyService: EmailVerificationService,
+  ) {}
+
+  /** 发送验证码(公开端点,幂等) */
+  async sendCode(email: string, purpose: 'register' | 'reset') {
+    await this.verifyService.send(email, purpose);
+    return { message: '验证码已发送' };
+  }
+
+  /** 注册(需先验证邮箱验证码) */
+  async register(dto: RegisterDto) {
+    // 1. 验证码校验
+    const codeValid = await this.verifyService.verify(
+      dto.email,
+      'register',
+      dto.code,
+    );
+    if (!codeValid) {
+      throw new HttpException(
+        '验证码错误或已过期',
+        HttpStatus.BAD_REQUEST,
+      );
+    }
+
+    // 2. 检查重复邮箱
+    const existing = await this.prisma.user.findUnique({
+      where: { email: dto.email },
+    });
+    if (existing) {
+      throw new ConflictException('该邮箱已注册');
+    }
+
+    const passwordHash = await bcrypt.hash(dto.password, 10);
+    const user = await this.prisma.user.create({
+      data: { email: dto.email, passwordHash },
+    });
+
+    return {
+      id: user.id,
+      email: user.email,
+      message: '注册成功',
+    };
+  }
+
+  /** 登录(含频率限制) */
+  async login(dto: LoginDto) {
+    const failKey = `login:fail:${dto.email.toLowerCase().trim()}`;
+
+    // 检查失败次数
+    const failCount = await this.redis.client.get(failKey);
+    if (failCount && Number(failCount) >= LOGIN_FAIL_LIMIT) {
+      const ttl = await this.redis.client.ttl(failKey);
+      throw new HttpException(
+        `登录尝试过于频繁,请 ${ttl > 0 ? ttl + ' 秒' : '稍'}后重试`,
+        HttpStatus.TOO_MANY_REQUESTS,
+      );
+    }
+
+    const user = await this.prisma.user.findUnique({
+      where: { email: dto.email },
+    });
+    if (!user) {
+      await this.recordLoginFail(failKey);
+      throw new UnauthorizedException('邮箱或密码错误');
+    }
+
+    const valid = await bcrypt.compare(dto.password, user.passwordHash);
+    if (!valid) {
+      await this.recordLoginFail(failKey);
+      throw new UnauthorizedException('邮箱或密码错误');
+    }
+
+    if (user.status !== 'active') {
+      throw new UnauthorizedException('账号已被禁用,请联系客服');
+    }
+
+    // 登录成功,清除失败计数
+    await this.redis.client.del(failKey);
+
+    const payload: JwtPayload = {
+      sub: user.id,
+      email: user.email,
+      role: user.role,
+    };
+    const accessToken = this.jwt.sign(payload);
+
+    return {
+      accessToken,
+      user: {
+        id: user.id,
+        email: user.email,
+        role: user.role,
+        balanceCents: Number(user.balanceCents),
+        currency: user.currency,
+      },
+    };
+  }
+
+  /** 记录登录失败 */
+  private async recordLoginFail(failKey: string): Promise<void> {
+    const count = await this.redis.client.incr(failKey);
+    if (count === 1) {
+      await this.redis.client.expire(failKey, LOGIN_FAIL_WINDOW);
+    }
+  }
+
+  /** 忘记密码 - 发送重置验证码(幂等,不泄露邮箱是否存在) */
+  async forgotPassword(email: string) {
+    await this.verifyService.send(email, 'reset');
+    // 始终返回 200,不泄露邮箱是否已注册
+    return { message: '如果邮箱已注册,验证码已发送' };
+  }
+
+  /** 重置密码 */
+  async resetPassword(email: string, code: string, newPassword: string) {
+    const codeValid = await this.verifyService.verify(email, 'reset', code);
+    if (!codeValid) {
+      throw new HttpException(
+        '验证码错误或已过期',
+        HttpStatus.BAD_REQUEST,
+      );
+    }
+
+    const user = await this.prisma.user.findUnique({
+      where: { email },
+    });
+    if (!user) {
+      throw new HttpException(
+        '用户不存在',
+        HttpStatus.BAD_REQUEST,
+      );
+    }
+
+    const passwordHash = await bcrypt.hash(newPassword, 10);
+    await this.prisma.user.update({
+      where: { email },
+      data: { passwordHash },
+    });
+
+    return { message: '密码重置成功' };
+  }
+
+  /** 获取当前用户信息 */
+  async getProfile(userId: string) {
+    const user = await this.prisma.user.findUnique({
+      where: { id: userId },
+    });
+    if (!user) {
+      throw new UnauthorizedException('用户不存在');
+    }
+    return {
+      id: user.id,
+      email: user.email,
+      role: user.role,
+      balanceCents: Number(user.balanceCents),
+      currency: user.currency,
+      status: user.status,
+      createdAt: user.createdAt.toISOString(),
+    };
+  }
+}

+ 22 - 0
apps/api/src/auth/current-user.decorator.ts

@@ -0,0 +1,22 @@
+/**
+ * @CurrentUser 参数装饰器 - 从 request 中提取已认证用户信息
+ */
+import { createParamDecorator, ExecutionContext } from '@nestjs/common';
+import type { Request } from 'express';
+
+export interface JwtPayload {
+  sub: string; // userId
+  email: string;
+  role: string; // user | admin
+}
+
+export const CurrentUser = createParamDecorator(
+  (_data: unknown, ctx: ExecutionContext): JwtPayload => {
+    const request = ctx.switchToHttp().getRequest<Request>();
+    const user = request['user'];
+    if (!user) {
+      throw new Error('CurrentUser decorator used without JWT guard');
+    }
+    return user as JwtPayload;
+  },
+);

+ 9 - 0
apps/api/src/auth/dto/forgot.dto.ts

@@ -0,0 +1,9 @@
+/**
+ * 忘记密码请求 DTO
+ */
+import { IsEmail } from 'class-validator';
+
+export class ForgotDto {
+  @IsEmail({}, { message: '邮箱格式不正确' })
+  email!: string;
+}

+ 12 - 0
apps/api/src/auth/dto/login.dto.ts

@@ -0,0 +1,12 @@
+/**
+ * 登录请求 DTO
+ */
+import { IsEmail, IsString } from 'class-validator';
+
+export class LoginDto {
+  @IsEmail({}, { message: '邮箱格式不正确' })
+  email!: string;
+
+  @IsString()
+  password!: string;
+}

+ 20 - 0
apps/api/src/auth/dto/register.dto.ts

@@ -0,0 +1,20 @@
+/**
+ * 注册请求 DTO - 邮箱 + 密码 + 验证码
+ */
+import { IsEmail, IsString, MinLength, MaxLength, Matches } from 'class-validator';
+
+export class RegisterDto {
+  @IsEmail({}, { message: '邮箱格式不正确' })
+  email!: string;
+
+  @IsString()
+  @MinLength(8, { message: '密码至少 8 位' })
+  @MaxLength(128, { message: '密码最长 128 位' })
+  @Matches(/(?=.*[a-zA-Z])(?=.*[0-9])/, {
+    message: '密码必须包含字母和数字',
+  })
+  password!: string;
+
+  @IsString()
+  code!: string;
+}

+ 20 - 0
apps/api/src/auth/dto/reset.dto.ts

@@ -0,0 +1,20 @@
+/**
+ * 密码重置请求 DTO - 邮箱 + 验证码 + 新密码
+ */
+import { IsEmail, IsString, MinLength, MaxLength, Matches } from 'class-validator';
+
+export class ResetDto {
+  @IsEmail({}, { message: '邮箱格式不正确' })
+  email!: string;
+
+  @IsString()
+  code!: string;
+
+  @IsString()
+  @MinLength(8, { message: '密码至少 8 位' })
+  @MaxLength(128, { message: '密码最长 128 位' })
+  @Matches(/(?=.*[a-zA-Z])(?=.*[0-9])/, {
+    message: '密码必须包含字母和数字',
+  })
+  newPassword!: string;
+}

+ 13 - 0
apps/api/src/auth/dto/send-code.dto.ts

@@ -0,0 +1,13 @@
+/**
+ * 发送验证码请求 DTO
+ */
+import { IsEmail, IsString, IsIn } from 'class-validator';
+
+export class SendCodeDto {
+  @IsEmail({}, { message: '邮箱格式不正确' })
+  email!: string;
+
+  @IsString()
+  @IsIn(['register', 'reset'], { message: 'purpose 只能是 register 或 reset' })
+  purpose!: 'register' | 'reset';
+}

+ 8 - 0
apps/api/src/auth/jwt-auth.guard.ts

@@ -0,0 +1,8 @@
+/**
+ * JWT 认证守卫 - 保护需要登录的路由
+ */
+import { Injectable } from '@nestjs/common';
+import { AuthGuard } from '@nestjs/passport';
+
+@Injectable()
+export class JwtAuthGuard extends AuthGuard('jwt') {}

+ 23 - 0
apps/api/src/auth/jwt.strategy.ts

@@ -0,0 +1,23 @@
+/**
+ * JWT 验证策略 - 从 Authorization Bearer token 中提取用户信息
+ */
+import { Injectable } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { PassportStrategy } from '@nestjs/passport';
+import { ExtractJwt, Strategy } from 'passport-jwt';
+import type { JwtPayload } from './current-user.decorator';
+
+@Injectable()
+export class JwtStrategy extends PassportStrategy(Strategy) {
+  constructor(config: ConfigService) {
+    super({
+      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
+      ignoreExpiration: false,
+      secretOrKey: config.getOrThrow<string>('JWT_SECRET'),
+    });
+  }
+
+  validate(payload: JwtPayload): JwtPayload {
+    return { sub: payload.sub, email: payload.email, role: payload.role };
+  }
+}

+ 108 - 0
apps/api/src/email/email-verification.service.ts

@@ -0,0 +1,108 @@
+/**
+ * 邮箱验证码服务 - Redis 存储,15 分钟有效期,60 秒发送冷却
+ * 验证码 8 位大写字母数字,不含歧义字符
+ */
+import {
+  Injectable,
+  HttpException,
+  HttpStatus,
+} from '@nestjs/common';
+import { RedisService } from '../redis/redis.service';
+import { EmailService } from './email.service';
+import { randomBytes } from 'node:crypto';
+import { timingSafeEqual } from 'node:crypto';
+
+const CODE_TTL = 900; // 15 分钟
+const COOLDOWN_TTL = 60; // 60 秒发送冷却
+
+// 字符集(排除 I/O/0/1 避免歧义)
+const CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
+const CODE_LENGTH = 8;
+
+@Injectable()
+export class EmailVerificationService {
+  constructor(
+    private readonly redis: RedisService,
+    private readonly email: EmailService,
+  ) {}
+
+  /** 生成 8 位验证码 */
+  generateCode(): string {
+    const bytes = randomBytes(CODE_LENGTH);
+    let code = '';
+    for (let i = 0; i < CODE_LENGTH; i++) {
+      code += CHARS[bytes[i]! % CHARS.length];
+    }
+    return code;
+  }
+
+  /** 规范化邮箱(转大写,忽略大小写) */
+  private normalize(email: string): string {
+    return email.trim().toLowerCase();
+  }
+
+  /** 发送验证码(带冷却检查) */
+  async send(email: string, purpose: 'register' | 'reset'): Promise<void> {
+    const normalized = this.normalize(email);
+    const cooldownKey = `verify:cd:${purpose}:${normalized}`;
+    const codeKey = `verify:${purpose}:${normalized}`;
+
+    // 冷却检查
+    const cooldown = await this.redis.client.get(cooldownKey);
+    if (cooldown) {
+      throw new HttpException(
+        '验证码发送过于频繁,请 60 秒后重试',
+        HttpStatus.TOO_MANY_REQUESTS,
+      );
+    }
+
+    const code = this.generateCode();
+    const purposeLabel = purpose === 'register' ? '注册' : '密码重置';
+
+    // 存储验证码
+    await this.redis.client.setex(codeKey, CODE_TTL, code);
+    // 设置冷却
+    await this.redis.client.setex(cooldownKey, COOLDOWN_TTL, '1');
+
+    // 发送邮件
+    await this.email.send({
+      to: email,
+      subject: `[DoTouch.AI] ${purposeLabel}验证码`,
+      body: `您的${purposeLabel}验证码是:${code},15 分钟内有效。`,
+    });
+  }
+
+  /** 验证验证码(常量时间比较) */
+  async verify(
+    email: string,
+    purpose: 'register' | 'reset',
+    code: string,
+  ): Promise<boolean> {
+    const normalized = this.normalize(email);
+    const codeKey = `verify:${purpose}:${normalized}`;
+
+    const stored = await this.redis.client.get(codeKey);
+    if (!stored) {
+      return false;
+    }
+
+    // 常量时间比较防止时序攻击
+    const inputBuf = Buffer.from(code.toUpperCase(), 'utf-8');
+    const storedBuf = Buffer.from(stored, 'utf-8');
+    if (inputBuf.length !== storedBuf.length) {
+      return false;
+    }
+
+    try {
+      const equal = timingSafeEqual(inputBuf, storedBuf);
+      if (equal) {
+        // 验证成功,删除验证码(一次性使用)
+        await this.redis.client.del(codeKey);
+        return true;
+      }
+      return false;
+    } catch {
+      return false;
+    }
+  }
+}

+ 12 - 0
apps/api/src/email/email.module.ts

@@ -0,0 +1,12 @@
+/**
+ * 邮件模块 - EmailService + EmailVerificationService,供 Auth 模块使用
+ */
+import { Module } from '@nestjs/common';
+import { EmailService } from './email.service';
+import { EmailVerificationService } from './email-verification.service';
+
+@Module({
+  providers: [EmailService, EmailVerificationService],
+  exports: [EmailService, EmailVerificationService],
+})
+export class EmailModule {}

+ 31 - 0
apps/api/src/email/email.service.ts

@@ -0,0 +1,31 @@
+/**
+ * 邮件服务桩 - 开发环境将验证码打印到控制台,生产环境对接 SMTP
+ * TODO: 集成第三方邮件服务(SMTP / SendGrid / Resend 等)
+ * 通过环境变量 SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS 配置
+ */
+import { Injectable, Logger } from '@nestjs/common';
+
+export interface SendEmailOptions {
+  to: string;
+  subject: string;
+  body: string;
+}
+
+@Injectable()
+export class EmailService {
+  private readonly logger = new Logger(EmailService.name);
+
+  async send(options: SendEmailOptions): Promise<void> {
+    const smtpHost = process.env['SMTP_HOST'];
+    if (smtpHost) {
+      // TODO: 对接真实 SMTP / 第三方邮件 API
+      // const transporter = nodemailer.createTransport({ host: smtpHost, port: ... });
+      // await transporter.sendMail({ from: ..., to: options.to, subject: options.subject, text: options.body });
+    }
+
+    // 开发环境:打印到控制台
+    this.logger.log(
+      `[EMAIL] To: ${options.to} | Subject: ${options.subject} | Body: ${options.body}`,
+    );
+  }
+}

+ 185 - 0
apps/api/src/gateway/gateway.controller.ts

@@ -0,0 +1,185 @@
+/**
+ * 网关控制器 - POST /v1/chat/completions(API Key 认证,OpenAI 兼容)
+ */
+import {
+  Controller,
+  Post,
+  Req,
+  Res,
+  HttpCode,
+  HttpException,
+} from '@nestjs/common';
+import type { Request, Response } from 'express';
+import { GatewayService, createOpenAiError } from './gateway.service';
+import { ModelsService } from '../models/models.service';
+import { QuotaService } from '../quota/quota.service';
+import { InsufficientBalanceException } from '../wallet/exceptions/insufficient-balance.exception';
+
+@Controller()
+export class GatewayController {
+  constructor(
+    private readonly gatewayService: GatewayService,
+    private readonly modelsService: ModelsService,
+    private readonly quotaService: QuotaService,
+  ) {}
+
+  @Post('chat/completions')
+  @HttpCode(200)
+  async chatCompletions(
+    @Req() req: Request,
+    @Res() res: Response,
+  ): Promise<void> {
+    try {
+      // 1. 认证
+      const authHeader = req.headers.authorization;
+      const auth = await this.gatewayService.authenticate(authHeader);
+      if (!auth) {
+        throw createOpenAiError(
+          401,
+          '无效的 API Key',
+          'invalid_api_key',
+          'invalid_api_key',
+        );
+      }
+
+      // 2. 频率限制
+      await this.gatewayService.checkRateLimit(auth.apiKey.id);
+
+      // 2.5 用量配额检查(检查账号级 + Key 级限额)
+      const quota = await this.quotaService.checkQuota(
+        auth.user.id,
+        auth.apiKey.id,
+      );
+      if (quota.blocked.length > 0) {
+        const b = quota.blocked[0]!;
+        throw createOpenAiError(
+          429,
+          `用量超限:${b.dimension} ${b.window} 已达上限 (${b.current}/${b.limit})`,
+          'quota_exceeded',
+          'quota_exceeded',
+        );
+      }
+
+      // 3. 解析模型
+      const body = req.body as Record<string, unknown>;
+      const modelId = typeof body['model'] === 'string' ? body['model'] : '';
+      const model = await this.modelsService.getModel(modelId);
+
+      // 4. 用户状态
+      if (auth.user.status !== 'active') {
+        throw createOpenAiError(
+          403,
+          '账号已被禁用',
+          'account_suspended',
+          'account_suspended',
+        );
+      }
+
+      const refId = this.gatewayService.genRefId();
+      const isStream = body['stream'] === true;
+
+      if (isStream) {
+        // ----- 流式处理 -----
+        // 预估扣费
+        const estimateCents = this.gatewayService.estimateStreamCost(
+          model,
+          body,
+        );
+        await this.gatewayService.preDeduct(
+          auth.user.id,
+          estimateCents,
+          refId,
+        );
+
+        // 设置 SSE 响应头
+        res.setHeader('Content-Type', 'text/event-stream');
+        res.setHeader('Cache-Control', 'no-cache');
+        res.setHeader('Connection', 'keep-alive');
+        res.flushHeaders();
+
+        // 流式调用上游
+        const usage = await this.gatewayService.streamUpstream(
+          modelId,
+          body,
+          (chunk: string) => res.write(chunk),
+        );
+
+        // 结算
+        await this.gatewayService.settleAndRecord(
+          auth.user.id,
+          auth.apiKey.id,
+          model,
+          usage,
+          refId,
+          estimateCents,
+        );
+
+        res.end();
+      } else {
+        // ----- 非流式处理 -----
+        const { body: upstreamBody, usage, status } =
+          await this.gatewayService.callUpstream(modelId, body);
+
+        // 扣费 + 记录
+        await this.gatewayService.settleAndRecord(
+          auth.user.id,
+          auth.apiKey.id,
+          model,
+          usage,
+          refId,
+        );
+
+        res.status(status).json(upstreamBody);
+      }
+    } catch (err: unknown) {
+      // 余额不足 → 402
+      if (err instanceof InsufficientBalanceException) {
+        res.status(402).json({
+          error: {
+            message: 'Insufficient balance',
+            type: 'insufficient_balance',
+            code: 'insufficient_balance',
+            param: null,
+          },
+        });
+        return;
+      }
+
+      // HttpException → 透传
+      if (err instanceof HttpException) {
+        const status = err.getStatus();
+        const response = err.getResponse();
+        res.status(status).json({
+          error: {
+            message:
+              typeof response === 'object' && response !== null
+                ? (response as Record<string, unknown>)['message'] ??
+                  String(response)
+                : String(response),
+            type: 'api_error',
+            code: 'api_error',
+            param: null,
+          },
+        });
+        return;
+      }
+
+      // 结构化 OpenAI 错误
+      if (err && typeof err === 'object' && 'status' in err && 'body' in err) {
+        const oe = err as { status: number; body: object };
+        res.status(oe.status).json(oe.body);
+        return;
+      }
+
+      // 其他未知错误 → 500
+      res.status(500).json({
+        error: {
+          message: err instanceof Error ? err.message : 'Internal server error',
+          type: 'internal_error',
+          code: 'internal_error',
+          param: null,
+        },
+      });
+    }
+  }
+}

+ 18 - 0
apps/api/src/gateway/gateway.module.ts

@@ -0,0 +1,18 @@
+/**
+ * 网关模块 - 核心转发路由,依赖 Keys / Models / Wallet / Quota 模块
+ */
+import { Module } from '@nestjs/common';
+import { GatewayController } from './gateway.controller';
+import { GatewayService } from './gateway.service';
+import { RateLimiterService } from './rate-limiter.service';
+import { KeysModule } from '../keys/keys.module';
+import { ModelsModule } from '../models/models.module';
+import { WalletModule } from '../wallet/wallet.module';
+import { QuotaModule } from '../quota/quota.module';
+
+@Module({
+  imports: [KeysModule, ModelsModule, WalletModule, QuotaModule],
+  controllers: [GatewayController],
+  providers: [GatewayService, RateLimiterService],
+})
+export class GatewayModule {}

+ 378 - 0
apps/api/src/gateway/gateway.service.ts

@@ -0,0 +1,378 @@
+/**
+ * 网关服务 - API Key 认证、模型解析、上游调用、计费扣费、用量记录
+ */
+import { Injectable, Logger } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { KeysService } from '../keys/keys.service';
+import { ModelsService, type ModelInfo } from '../models/models.service';
+import { WalletService } from '../wallet/wallet.service';
+import { PrismaService } from '../prisma/prisma.service';
+import { RateLimiterService } from './rate-limiter.service';
+
+/** 上游返回的 usage 结构 */
+interface UpstreamUsage {
+  prompt_tokens: number;
+  completion_tokens: number;
+  total_tokens: number;
+}
+
+/** 上游非流式响应 */
+interface UpstreamResponse {
+  body: unknown;
+  usage: UpstreamUsage;
+  status: number;
+}
+
+@Injectable()
+export class GatewayService {
+  private readonly logger = new Logger(GatewayService.name);
+  private readonly dashscopeBaseUrl: string;
+  private readonly dashscopeApiKey: string;
+
+  constructor(
+    private readonly keysService: KeysService,
+    private readonly modelsService: ModelsService,
+    private readonly walletService: WalletService,
+    private readonly prisma: PrismaService,
+    private readonly rateLimiter: RateLimiterService,
+    config: ConfigService,
+  ) {
+    this.dashscopeBaseUrl =
+      config.get<string>('DASHSCOPE_BASE_URL') ??
+      'https://dashscope.aliyuncs.com/compatible-mode/v1';
+    this.dashscopeApiKey = config.get<string>('DASHSCOPE_API_KEY') ?? '';
+  }
+
+  /** 从 Bearer token 提取原始 API Key 并认证,失败返回 null */
+  async authenticate(
+    authHeader: string | undefined,
+  ): Promise<{
+    user: { id: string; email: string; status: string };
+    apiKey: { id: string; prefix: string };
+  } | null> {
+    if (!authHeader?.startsWith('Bearer ')) {
+      return null;
+    }
+    const rawKey = authHeader.slice(7);
+    return this.keysService.authenticate(rawKey);
+  }
+
+  /** 检查频率限制,超限抛出 429 错误 */
+  async checkRateLimit(apiKeyId: string): Promise<void> {
+    const allowed = await this.rateLimiter.check(apiKeyId);
+    if (!allowed) {
+      throw createOpenAiError(
+        429,
+        '请求频率超限,请稍后再试',
+        'rate_limit_exceeded',
+        'rate_limit_exceeded',
+      );
+    }
+  }
+
+  /** 调用上游 DashScope(非流式),返回 body + usage + status */
+  async callUpstream(
+    modelId: string,
+    requestBody: Record<string, unknown>,
+  ): Promise<UpstreamResponse> {
+    const upstreamId = await this.modelsService.resolveUpstreamId(modelId);
+    const upstreamBody = { ...requestBody, model: upstreamId, stream: false };
+
+    const url = `${this.dashscopeBaseUrl}/chat/completions`;
+    const controller = new AbortController();
+    const timeout = setTimeout(() => controller.abort(), 120_000);
+
+    try {
+      const response = await fetch(url, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          Authorization: `Bearer ${this.dashscopeApiKey}`,
+        },
+        body: JSON.stringify(upstreamBody),
+        signal: controller.signal,
+      });
+
+      const body = await parseJsonBody(response);
+      const status = response.status;
+
+      if (!response.ok) {
+        this.logger.warn(
+          `Upstream error status=${status} for model=${upstreamId}`,
+        );
+        // 透传上游错误
+        throw createOpenAiError(
+          status,
+          extractErrorMessage(body, '上游服务返回错误'),
+          'upstream_error',
+          'upstream_error',
+        );
+      }
+
+      const usage: UpstreamUsage = extractUsage(body) ?? {
+        prompt_tokens: 0,
+        completion_tokens: 0,
+        total_tokens: 0,
+      };
+
+      return { body, usage, status };
+    } finally {
+      clearTimeout(timeout);
+    }
+  }
+
+  /** 流式调用上游 DashScope,直写客户端 SSE,返回累计 usage */
+  async streamUpstream(
+    modelId: string,
+    requestBody: Record<string, unknown>,
+    writeChunk: (chunk: string) => void,
+  ): Promise<UpstreamUsage> {
+    const upstreamId = await this.modelsService.resolveUpstreamId(modelId);
+    const upstreamBody = { ...requestBody, model: upstreamId, stream: true };
+
+    const url = `${this.dashscopeBaseUrl}/chat/completions`;
+    const controller = new AbortController();
+    const timeout = setTimeout(() => controller.abort(), 300_000);
+
+    try {
+      const response = await fetch(url, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          Authorization: `Bearer ${this.dashscopeApiKey}`,
+        },
+        body: JSON.stringify(upstreamBody),
+        signal: controller.signal,
+      });
+
+      if (!response.ok) {
+        const body = await parseJsonBody(response);
+        throw createOpenAiError(
+          response.status,
+          extractErrorMessage(body, '上游服务流式错误'),
+          'upstream_error',
+          'upstream_error',
+        );
+      }
+
+      const reader = response.body?.getReader();
+      if (!reader) {
+        throw createOpenAiError(
+          502,
+          '上游未返回响应体',
+          'upstream_error',
+          'upstream_error',
+        );
+      }
+
+      const decoder = new TextDecoder();
+      let buffer = '';
+
+      // 从最终 SSE 事件提取 usage 的结构
+      let usage: UpstreamUsage = {
+        prompt_tokens: 0,
+        completion_tokens: 0,
+        total_tokens: 0,
+      };
+
+      while (true) {
+        const { done, value } = await reader.read();
+        if (done) break;
+
+        const text = decoder.decode(value, { stream: true });
+        buffer += text;
+        writeChunk(text);
+      }
+
+      // 从累积的 SSE 文本中提取最后一个 usage
+      usage = extractUsageFromSse(buffer) ?? usage;
+      return usage;
+    } finally {
+      clearTimeout(timeout);
+    }
+  }
+
+  /** 估算流式请求成本(用于预扣费) */
+  estimateStreamCost(
+    model: ModelInfo,
+    requestBody: Record<string, unknown>,
+  ): bigint {
+    const maxTokens =
+      typeof requestBody['max_tokens'] === 'number'
+        ? requestBody['max_tokens']
+        : 1024;
+
+    const promptTokens =
+      typeof requestBody['best_of'] === 'number'
+        ? 500
+        : 200; // rough prompt estimate
+
+    const estimatedCost = this.modelsService.computeCost(
+      model,
+      promptTokens,
+      maxTokens,
+    );
+    return BigInt(Math.max(1, estimatedCost)); // 至少预扣 1 美分
+  }
+
+  /** 预扣费(流式),余额不足时抛出 402 */
+  async preDeduct(
+    userId: string,
+    estimatedCents: bigint,
+    refId: string,
+  ): Promise<void> {
+    await this.walletService.deduct(userId, estimatedCents, refId);
+  }
+
+  /** 计算实际费用、扣费/退款差额、创建用量记录 */
+  async settleAndRecord(
+    userId: string,
+    apiKeyId: string,
+    model: ModelInfo,
+    usage: UpstreamUsage,
+    refId: string,
+    estimatedCents?: bigint,
+  ): Promise<void> {
+    const actualCost = this.modelsService.computeCost(
+      model,
+      usage.prompt_tokens,
+      usage.completion_tokens,
+    );
+    const actualCents = BigInt(actualCost);
+
+    if (estimatedCents !== undefined) {
+      // 流式结算:退多扣的部分
+      if (estimatedCents > actualCents) {
+        const refundCents = estimatedCents - actualCents;
+        await this.walletService.credit(
+          userId,
+          refundCents,
+          `${refId}_refund`,
+          'refund',
+        );
+      } else if (actualCents > estimatedCents) {
+        // 预扣不足,补扣差额
+        const extraCents = actualCents - estimatedCents;
+        await this.walletService.deduct(
+          userId,
+          extraCents,
+          `${refId}_extra`,
+        );
+      }
+    } else {
+      // 非流式:直接扣费
+      if (actualCents > 0n) {
+        await this.walletService.deduct(userId, actualCents, refId);
+      }
+    }
+
+    // 创建用量记录
+    if (usage.prompt_tokens > 0 || usage.completion_tokens > 0) {
+      await this.prisma.usageRecord.create({
+        data: {
+          userId,
+          apiKeyId,
+          model: model.id,
+          promptTokens: usage.prompt_tokens,
+          completionTokens: usage.completion_tokens,
+          costCents: actualCost,
+        },
+      });
+    }
+  }
+
+  /** 生成唯一请求引用 ID */
+  genRefId(): string {
+    const ts = Date.now().toString(36);
+    const rand = Math.random().toString(36).slice(2, 8);
+    return `req_${ts}_${rand}`;
+  }
+}
+
+// ---------- 纯函数工具 ----------
+
+/** 构造 OpenAI 兼容错误 */
+export function createOpenAiError(
+  status: number,
+  message: string,
+  type: string,
+  code: string,
+): Error & { status: number; body: object } {
+  const err = new Error(message) as Error & { status: number; body: object };
+  err.status = status;
+  err.body = {
+    error: { message, type, code, param: null },
+  };
+  return err;
+}
+
+/** 尝试从 fetch response 解析 JSON,失败返回 null */
+async function parseJsonBody(response: Response): Promise<unknown> {
+  try {
+    return await response.json();
+  } catch {
+    const text = await response.text().catch(() => '');
+    return { _raw: text };
+  }
+}
+
+/** 从响应体提取 usage */
+function extractUsage(body: unknown): UpstreamUsage | null {
+  if (typeof body === 'object' && body !== null) {
+    const usage = (body as Record<string, unknown>)['usage'];
+    if (usage && typeof usage === 'object' && usage !== null) {
+      const u = usage as Record<string, number>;
+      if (typeof u['prompt_tokens'] === 'number') {
+        return {
+          prompt_tokens: u['prompt_tokens'],
+          completion_tokens: u['completion_tokens'] ?? 0,
+          total_tokens:
+            u['total_tokens'] ?? u['prompt_tokens'] + (u['completion_tokens'] ?? 0),
+        };
+      }
+    }
+  }
+  return null;
+}
+
+/** 从累积的 SSE 文本中提取最后一个 usage 对象 */
+function extractUsageFromSse(sseText: string): UpstreamUsage | null {
+  // 按双换行分割 SSE 事件
+  const events = sseText.split('\n\n');
+  // 从后往前找第一个包含 usage 的事件
+  for (let i = events.length - 1; i >= 0; i--) {
+    const event = events[i];
+    if (!event?.trim() || event.trim() === 'data: [DONE]') continue;
+    const dataLines = event
+      .split('\n')
+      .filter((l) => l.startsWith('data: '))
+      .map((l) => l.slice(6));
+    for (const line of dataLines) {
+      const usage = extractUsage(parseSseData(line));
+      if (usage) return usage;
+    }
+  }
+  return null;
+}
+
+function parseSseData(data: string): unknown {
+  try {
+    return JSON.parse(data);
+  } catch {
+    return null;
+  }
+}
+
+/** 从响应体中提取错误消息 */
+function extractErrorMessage(body: unknown, fallback: string): string {
+  if (typeof body === 'object' && body !== null) {
+    const err = (body as Record<string, unknown>)['error'];
+    if (err && typeof err === 'object' && err !== null) {
+      const msg = (err as Record<string, unknown>)['message'];
+      if (typeof msg === 'string') return msg;
+    }
+    const msg = (body as Record<string, unknown>)['message'];
+    if (typeof msg === 'string') return msg;
+  }
+  return fallback;
+}

+ 43 - 0
apps/api/src/gateway/rate-limiter.service.ts

@@ -0,0 +1,43 @@
+/**
+ * 频率限制服务 - 基于 Redis 的固定窗口限流(每个 API Key 每分钟 N 次)
+ */
+import { Injectable, Logger } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { RedisService } from '../redis/redis.service';
+
+@Injectable()
+export class RateLimiterService {
+  private readonly logger = new Logger(RateLimiterService.name);
+  private readonly limitPerMin: number;
+
+  constructor(
+    private readonly redis: RedisService,
+    config: ConfigService,
+  ) {
+    this.limitPerMin = Number(
+      config.get<string>('GATEWAY_RATE_LIMIT_PER_MIN') ?? 60,
+    );
+  }
+
+  /**
+   * 检查 API Key 是否超过频率限制
+   * @returns true = 允许放行, false = 已超限
+   */
+  async check(keyId: string): Promise<boolean> {
+    const windowKey = Math.floor(Date.now() / 60000);
+    const redisKey = `rate_limit:${keyId}:${windowKey}`;
+
+    try {
+      const count = await this.redis.client.incr(redisKey);
+      if (count === 1) {
+        await this.redis.client.expire(redisKey, 90); // 保留 90s 避免边缘情况
+      }
+      return count <= this.limitPerMin;
+    } catch (err) {
+      this.logger.warn(
+        `Rate limiter Redis error, allowing request: ${String(err)}`,
+      );
+      return true; // Redis 不可用时允许放行
+    }
+  }
+}

+ 16 - 0
apps/api/src/health/health.controller.ts

@@ -0,0 +1,16 @@
+/**
+ * 健康检查控制器 - GET /v1/health 返回服务状态
+ */
+import { Controller, Get } from '@nestjs/common';
+
+@Controller('health')
+export class HealthController {
+  @Get()
+  check() {
+    return {
+      status: 'ok' as const,
+      version: '0.0.1',
+      timestamp: new Date().toISOString(),
+    };
+  }
+}

+ 10 - 0
apps/api/src/health/health.module.ts

@@ -0,0 +1,10 @@
+/**
+ * 健康检查模块
+ */
+import { Module } from '@nestjs/common';
+import { HealthController } from './health.controller';
+
+@Module({
+  controllers: [HealthController],
+})
+export class HealthModule {}

+ 15 - 0
apps/api/src/keys/dto/create-key.dto.ts

@@ -0,0 +1,15 @@
+/**
+ * 创建 API Key 请求 DTO
+ */
+import { IsString, IsOptional, MinLength, MaxLength, IsISO8601 } from 'class-validator';
+
+export class CreateKeyDto {
+  @IsString()
+  @MinLength(1, { message: '名称不能为空' })
+  @MaxLength(128, { message: '名称最长 128 位' })
+  name!: string;
+
+  @IsOptional()
+  @IsISO8601({}, { message: 'expiresAt 格式应为 ISO 8601 日期' })
+  expiresAt?: string;
+}

+ 17 - 0
apps/api/src/keys/dto/update-key.dto.ts

@@ -0,0 +1,17 @@
+/**
+ * 更新 API Key 请求 DTO - name 或 status 可选更新
+ */
+import { IsOptional, IsString, IsIn, MinLength, MaxLength } from 'class-validator';
+
+export class UpdateKeyDto {
+  @IsOptional()
+  @IsString()
+  @MinLength(1)
+  @MaxLength(128)
+  name?: string;
+
+  @IsOptional()
+  @IsString()
+  @IsIn(['active', 'revoked'], { message: 'status 只能是 active 或 revoked' })
+  status?: string;
+}

+ 54 - 0
apps/api/src/keys/keys.controller.ts

@@ -0,0 +1,54 @@
+/**
+ * API Key 控制器 - CRUD 操作 + 轮换(需 JWT 认证)
+ */
+import {
+  Controller,
+  Post,
+  Get,
+  Patch,
+  Delete,
+  Body,
+  Param,
+  UseGuards,
+} from '@nestjs/common';
+import { KeysService } from './keys.service';
+import { CreateKeyDto } from './dto/create-key.dto';
+import { UpdateKeyDto } from './dto/update-key.dto';
+import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { CurrentUser } from '../auth/current-user.decorator';
+import type { JwtPayload } from '../auth/current-user.decorator';
+
+@Controller('keys')
+@UseGuards(JwtAuthGuard)
+export class KeysController {
+  constructor(private readonly keysService: KeysService) {}
+
+  @Post()
+  create(@CurrentUser() user: JwtPayload, @Body() dto: CreateKeyDto) {
+    return this.keysService.create(user.sub, dto);
+  }
+
+  @Get()
+  list(@CurrentUser() user: JwtPayload) {
+    return this.keysService.list(user.sub);
+  }
+
+  @Patch(':id')
+  update(
+    @CurrentUser() user: JwtPayload,
+    @Param('id') id: string,
+    @Body() dto: UpdateKeyDto,
+  ) {
+    return this.keysService.update(user.sub, id, dto);
+  }
+
+  @Delete(':id')
+  revoke(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
+    return this.keysService.revoke(user.sub, id);
+  }
+
+  @Post(':id/rotate')
+  rotate(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
+    return this.keysService.rotate(user.sub, id);
+  }
+}

+ 15 - 0
apps/api/src/keys/keys.module.ts

@@ -0,0 +1,15 @@
+/**
+ * API Key 模块
+ */
+import { Module } from '@nestjs/common';
+import { KeysService } from './keys.service';
+import { KeysController } from './keys.controller';
+import { AuthModule } from '../auth/auth.module';
+
+@Module({
+  imports: [AuthModule],
+  controllers: [KeysController],
+  providers: [KeysService],
+  exports: [KeysService],
+})
+export class KeysModule {}

+ 233 - 0
apps/api/src/keys/keys.service.ts

@@ -0,0 +1,233 @@
+/**
+ * API Key 服务 - 创建、列表、更新、删除、轮换、认证(供网关使用),含审计日志
+ */
+import {
+  Injectable,
+  NotFoundException,
+  ForbiddenException,
+} from '@nestjs/common';
+import { PrismaService } from '../prisma/prisma.service';
+import { CreateKeyDto } from './dto/create-key.dto';
+import { UpdateKeyDto } from './dto/update-key.dto';
+import { createHash, randomBytes } from 'node:crypto';
+
+@Injectable()
+export class KeysService {
+  constructor(private readonly prisma: PrismaService) {}
+
+  /** 生成 dt_ + 32 位 hex 随机密钥 */
+  private generateKey(): string {
+    return 'dt_' + randomBytes(16).toString('hex'); // 16 bytes = 32 hex chars
+  }
+
+  /** 对原始密钥做 sha256 */
+  private hashKey(key: string): string {
+    return createHash('sha256').update(key).digest('hex');
+  }
+
+  /** 记录 API Key 审计事件(fire-and-forget) */
+  private logEvent(
+    apiKeyId: string,
+    userId: string,
+    action: string,
+    ip?: string,
+  ): void {
+    this.prisma.apiKeyEvent
+      .create({
+        data: { apiKeyId, userId, action, ip },
+      })
+      .catch(() => {
+        // 忽略审计日志写入失败
+      });
+  }
+
+  async create(userId: string, dto: CreateKeyDto) {
+    const rawKey = this.generateKey();
+    const keyHash = this.hashKey(rawKey);
+    const prefix = rawKey.slice(0, 12);
+
+    const apiKey = await this.prisma.apiKey.create({
+      data: {
+        userId,
+        name: dto.name,
+        keyHash,
+        prefix,
+        expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null,
+      },
+    });
+
+    // 审计日志
+    this.logEvent(apiKey.id, userId, 'created');
+
+    return {
+      id: apiKey.id,
+      name: apiKey.name,
+      key: rawKey, // 仅此一次返回完整 key
+      prefix: apiKey.prefix,
+      expiresAt: apiKey.expiresAt?.toISOString() ?? null,
+      createdAt: apiKey.createdAt.toISOString(),
+    };
+  }
+
+  /** 轮换 API Key:撤销旧 key,创建新 key */
+  async rotate(userId: string, keyId: string) {
+    const key = await this.prisma.apiKey.findUnique({ where: { id: keyId } });
+    if (!key) {
+      throw new NotFoundException('API Key 不存在');
+    }
+    if (key.userId !== userId) {
+      throw new ForbiddenException('无权操作此 API Key');
+    }
+
+    // 撤销旧 key
+    await this.prisma.apiKey.update({
+      where: { id: keyId },
+      data: { status: 'revoked' },
+    });
+    this.logEvent(keyId, userId, 'rotated');
+
+    // 创建新 key(保持原 name 和 expiresAt)
+    const rawKey = this.generateKey();
+    const keyHash = this.hashKey(rawKey);
+    const prefix = rawKey.slice(0, 12);
+
+    const newKey = await this.prisma.apiKey.create({
+      data: {
+        userId,
+        name: key.name,
+        keyHash,
+        prefix,
+        expiresAt: key.expiresAt,
+      },
+    });
+    this.logEvent(newKey.id, userId, 'created');
+
+    return {
+      id: newKey.id,
+      name: newKey.name,
+      key: rawKey,
+      prefix: newKey.prefix,
+      expiresAt: newKey.expiresAt?.toISOString() ?? null,
+      createdAt: newKey.createdAt.toISOString(),
+    };
+  }
+
+  async list(userId: string) {
+    const keys = await this.prisma.apiKey.findMany({
+      where: { userId },
+      orderBy: { createdAt: 'desc' },
+      select: {
+        id: true,
+        name: true,
+        prefix: true,
+        expiresAt: true,
+        lastUsedAt: true,
+        status: true,
+        createdAt: true,
+      },
+    });
+    return keys.map((k) => ({
+      ...k,
+      expiresAt: k.expiresAt?.toISOString() ?? null,
+      lastUsedAt: k.lastUsedAt?.toISOString() ?? null,
+      createdAt: k.createdAt.toISOString(),
+    }));
+  }
+
+  async update(userId: string, keyId: string, dto: UpdateKeyDto) {
+    const key = await this.prisma.apiKey.findUnique({ where: { id: keyId } });
+    if (!key) {
+      throw new NotFoundException('API Key 不存在');
+    }
+    if (key.userId !== userId) {
+      throw new ForbiddenException('无权操作此 API Key');
+    }
+
+    const updated = await this.prisma.apiKey.update({
+      where: { id: keyId },
+      data: dto,
+      select: {
+        id: true,
+        name: true,
+        prefix: true,
+        expiresAt: true,
+        lastUsedAt: true,
+        status: true,
+        createdAt: true,
+      },
+    });
+
+    return {
+      ...updated,
+      expiresAt: updated.expiresAt?.toISOString() ?? null,
+      lastUsedAt: updated.lastUsedAt?.toISOString() ?? null,
+      createdAt: updated.createdAt.toISOString(),
+    };
+  }
+
+  async revoke(userId: string, keyId: string) {
+    const key = await this.prisma.apiKey.findUnique({ where: { id: keyId } });
+    if (!key) {
+      throw new NotFoundException('API Key 不存在');
+    }
+    if (key.userId !== userId) {
+      throw new ForbiddenException('无权操作此 API Key');
+    }
+
+    await this.prisma.apiKey.update({
+      where: { id: keyId },
+      data: { status: 'revoked' },
+    });
+    this.logEvent(keyId, userId, 'revoked');
+
+    return { success: true };
+  }
+
+  /** 供网关使用:通过原始 key 认证,返回 user + apiKey 或 null */
+  async authenticate(
+    rawKey: string,
+  ): Promise<{
+    user: { id: string; email: string; status: string };
+    apiKey: { id: string; prefix: string };
+  } | null> {
+    const keyHash = this.hashKey(rawKey);
+
+    const apiKey = await this.prisma.apiKey.findUnique({
+      where: { keyHash },
+      include: { user: true },
+    });
+
+    if (!apiKey || apiKey.status !== 'active') {
+      return null;
+    }
+    if (apiKey.user.status !== 'active') {
+      return null;
+    }
+    // 检查是否过期
+    if (apiKey.expiresAt && apiKey.expiresAt < new Date()) {
+      return null;
+    }
+
+    // 更新最后使用时间(fire-and-forget)
+    this.prisma.apiKey
+      .update({
+        where: { id: apiKey.id },
+        data: { lastUsedAt: new Date() },
+      })
+      .catch(() => {
+        // 忽略更新失败
+      });
+
+    // 审计日志(fire-and-forget)
+    this.logEvent(apiKey.id, apiKey.user.id, 'used');
+
+    return {
+      user: {
+        id: apiKey.user.id,
+        email: apiKey.user.email,
+        status: apiKey.user.status,
+      },
+      apiKey: { id: apiKey.id, prefix: apiKey.prefix },
+    };
+  }
+}

+ 31 - 0
apps/api/src/main.ts

@@ -0,0 +1,31 @@
+/**
+ * 应用入口 - NestJS 引导,全局 /v1 前缀,端口从环境变量读取
+ */
+import { NestFactory } from '@nestjs/core';
+import { ValidationPipe } from '@nestjs/common';
+import { AppModule } from './app.module';
+
+async function bootstrap() {
+  const app = await NestFactory.create(AppModule);
+
+  // 全局参数校验
+  app.useGlobalPipes(
+    new ValidationPipe({
+      whitelist: true,
+      forbidNonWhitelisted: true,
+      transform: true,
+    }),
+  );
+
+  // 全局 API 前缀
+  app.setGlobalPrefix('v1');
+
+  // CORS 开启(后续收紧)
+  app.enableCors();
+
+  const port = process.env['PORT'] ?? 8080;
+  await app.listen(port);
+  console.log(`🚀 DoTouch.AI API running on http://localhost:${port}/v1`);
+}
+
+void bootstrap();

+ 35 - 0
apps/api/src/models/models.controller.ts

@@ -0,0 +1,35 @@
+/**
+ * 模型控制器 - GET /v1/models(公开端点,无需认证)
+ */
+import { Controller, Get } from '@nestjs/common';
+import { ModelsService } from './models.service';
+
+@Controller('models')
+export class ModelsController {
+  constructor(private readonly modelsService: ModelsService) {}
+
+  /** OpenAI 兼容模型列表,包含定价元数据 */
+  @Get()
+  async listModels() {
+    const models = await this.modelsService.listModels();
+    return {
+      object: 'list',
+      data: models.map((m) => ({
+        id: m.id,
+        object: 'model' as const,
+        created: 0,
+        owned_by: m.provider.toLowerCase(),
+        name: m.name,
+        description: m.description,
+        category: m.category,
+        is_recommended: m.isRecommended,
+        context_window: m.contextWindow,
+        max_output: m.maxOutput,
+        pricing: {
+          input_price_cents_per_1m: m.inputPriceCents,
+          output_price_cents_per_1m: m.outputPriceCents,
+        },
+      })),
+    };
+  }
+}

+ 13 - 0
apps/api/src/models/models.module.ts

@@ -0,0 +1,13 @@
+/**
+ * 模型模块 - 导出 ModelsService 供网关使用
+ */
+import { Module } from '@nestjs/common';
+import { ModelsService } from './models.service';
+import { ModelsController } from './models.controller';
+
+@Module({
+  controllers: [ModelsController],
+  providers: [ModelsService],
+  exports: [ModelsService],
+})
+export class ModelsModule {}

+ 85 - 0
apps/api/src/models/models.service.ts

@@ -0,0 +1,85 @@
+/**
+ * 模型服务 - 从 DB(modelCatalog 表)查询模型列表、按 ID 查找、解析上游模型 ID
+ */
+import { Injectable, NotFoundException } from '@nestjs/common';
+import { PrismaService } from '../prisma/prisma.service';
+import type { ModelCatalog } from '@prisma/client';
+
+/** 网关 / 控制器使用的模型信息类型(从 DB 行派生) */
+export interface ModelInfo {
+  id: string;
+  upstreamId: string;
+  name: string;
+  provider: string;
+  category: string;
+  description: string | null;
+  contextWindow: number | null;
+  maxOutput: number | null;
+  inputPriceCents: number;
+  outputPriceCents: number;
+  isRecommended: boolean;
+  status: string;
+  sortOrder: number;
+}
+
+function toModelInfo(row: ModelCatalog): ModelInfo {
+  return {
+    id: row.publicId,
+    upstreamId: row.upstreamId,
+    name: row.name,
+    provider: row.provider,
+    category: row.category,
+    description: row.description,
+    contextWindow: row.contextWindow,
+    maxOutput: row.maxOutput,
+    inputPriceCents: row.inputPriceCents,
+    outputPriceCents: row.outputPriceCents,
+    isRecommended: row.isRecommended,
+    status: row.status,
+    sortOrder: row.sortOrder,
+  };
+}
+
+@Injectable()
+export class ModelsService {
+  constructor(private readonly prisma: PrismaService) {}
+
+  /** 列出全部有效(status=active)模型 */
+  async listModels(): Promise<ModelInfo[]> {
+    const rows = await this.prisma.modelCatalog.findMany({
+      where: { status: 'active' },
+      orderBy: [{ sortOrder: 'asc' }, { publicId: 'asc' }],
+    });
+    return rows.map(toModelInfo);
+  }
+
+  /** 按公开 ID 查找模型,不存在抛出 404 */
+  async getModel(publicId: string): Promise<ModelInfo> {
+    const row = await this.prisma.modelCatalog.findUnique({
+      where: { publicId },
+    });
+    if (!row) {
+      throw new NotFoundException(`未知模型: ${publicId}`);
+    }
+    return toModelInfo(row);
+  }
+
+  /** 将公开 ID 解析为上游模型 ID */
+  async resolveUpstreamId(publicId: string): Promise<string> {
+    const model = await this.getModel(publicId);
+    return model.upstreamId;
+  }
+
+  /** 计算请求成本(USD 美分),最小为 0 */
+  computeCost(
+    model: ModelInfo,
+    promptTokens: number,
+    completionTokens: number,
+  ): number {
+    const rawCost =
+      (promptTokens * model.inputPriceCents +
+        completionTokens * model.outputPriceCents) /
+      1_000_000;
+    return Math.max(0, Math.round(rawCost));
+  }
+}

+ 30 - 0
apps/api/src/models/pricing.ts

@@ -0,0 +1,30 @@
+/**
+ * [已废弃] 模型定价表 - 2026-08-08 起数据迁移至 DB modelCatalog 表
+ * 原定价常量仅供历史参考,实际价格从 PostgreSQL 读取。
+ * 新代码请使用 ModelsService(从 Prisma 查询),不要导入此文件。
+ *
+ * @deprecated Use ModelsService.listModels() / getModel() from DB instead.
+ * @see ../prisma/schema.prisma ModelCatalog
+ */
+
+export interface ModelPricing {
+  /** 对外公开的模型 ID(用户请求中使用的标识) */
+  id: string;
+  /** 上游提供商 */
+  provider: 'aliyun-dashscope';
+  /** 上游 DashScope 模型 ID,替换后发给上游 */
+  upstreamId: string;
+  /** 展示名称 */
+  displayName: string;
+  /** 简要描述 */
+  description: string;
+  /** 输入价格(USD 美分 / 1M tokens) */
+  inputPricePer1M: number;
+  /** 输出价格(USD 美分 / 1M tokens) */
+  outputPricePer1M: number;
+  /** 上下文窗口大小(tokens) */
+  contextWindow: number;
+}
+
+/** 所有可用模型的历史定价表(仅供迁移前参考) */
+export const PRICING_TABLE: readonly ModelPricing[] = [];

+ 12 - 0
apps/api/src/prisma/prisma.module.ts

@@ -0,0 +1,12 @@
+/**
+ * Prisma 模块 - 全局导出 PrismaService 供其他模块注入
+ */
+import { Global, Module } from '@nestjs/common';
+import { PrismaService } from './prisma.service';
+
+@Global()
+@Module({
+  providers: [PrismaService],
+  exports: [PrismaService],
+})
+export class PrismaModule {}

+ 20 - 0
apps/api/src/prisma/prisma.service.ts

@@ -0,0 +1,20 @@
+/**
+ * Prisma 服务 - PrismaClient 单例封装
+ * 管理数据库连接生命周期
+ */
+import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
+import { PrismaClient } from '@prisma/client';
+
+@Injectable()
+export class PrismaService
+  extends PrismaClient
+  implements OnModuleInit, OnModuleDestroy
+{
+  async onModuleInit() {
+    await this.$connect();
+  }
+
+  async onModuleDestroy() {
+    await this.$disconnect();
+  }
+}

+ 50 - 0
apps/api/src/quota/dto/set-limits.dto.ts

@@ -0,0 +1,50 @@
+/**
+ * 用量限额配置 DTO - 所有字段可选,null 表示清除限制
+ */
+import { IsOptional, IsInt, Min, Max } from 'class-validator';
+import { Type } from 'class-transformer';
+
+export class SetLimitsDto {
+  @IsOptional()
+  @Type(() => Number)
+  @IsInt({ message: 'dailyRequestLimit 必须为整数' })
+  @Min(0, { message: 'dailyRequestLimit 不能为负数' })
+  dailyRequestLimit?: number | null;
+
+  @IsOptional()
+  @Type(() => Number)
+  @IsInt({ message: 'dailyTokenLimit 必须为整数' })
+  @Min(0, { message: 'dailyTokenLimit 不能为负数' })
+  dailyTokenLimit?: number | null;
+
+  @IsOptional()
+  @Type(() => Number)
+  @IsInt({ message: 'dailyCostCentsLimit 必须为整数' })
+  @Min(0, { message: 'dailyCostCentsLimit 不能为负数' })
+  dailyCostCentsLimit?: number | null;
+
+  @IsOptional()
+  @Type(() => Number)
+  @IsInt({ message: 'totalRequestLimit 必须为整数' })
+  @Min(0, { message: 'totalRequestLimit 不能为负数' })
+  totalRequestLimit?: number | null;
+
+  @IsOptional()
+  @Type(() => Number)
+  @IsInt({ message: 'totalTokenLimit 必须为整数' })
+  @Min(0, { message: 'totalTokenLimit 不能为负数' })
+  totalTokenLimit?: number | null;
+
+  @IsOptional()
+  @Type(() => Number)
+  @IsInt({ message: 'totalCostCentsLimit 必须为整数' })
+  @Min(0, { message: 'totalCostCentsLimit 不能为负数' })
+  totalCostCentsLimit?: number | null;
+
+  @IsOptional()
+  @Type(() => Number)
+  @IsInt({ message: 'alertPct 必须为整数' })
+  @Min(0, { message: 'alertPct 最小为 0' })
+  @Max(100, { message: 'alertPct 最大为 100' })
+  alertPct?: number;
+}

+ 114 - 0
apps/api/src/quota/quota.controller.ts

@@ -0,0 +1,114 @@
+/**
+ * 用量配额控制器 - 用户限额设置 + 用量进度查询(需 JWT 认证)
+ */
+import {
+  Controller,
+  Put,
+  Get,
+  Body,
+  Param,
+  UseGuards,
+  NotFoundException,
+  ForbiddenException,
+} from '@nestjs/common';
+import { QuotaService } from './quota.service';
+import { SetLimitsDto } from './dto/set-limits.dto';
+import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { CurrentUser } from '../auth/current-user.decorator';
+import type { JwtPayload } from '../auth/current-user.decorator';
+
+@Controller()
+@UseGuards(JwtAuthGuard)
+export class QuotaController {
+  constructor(private readonly quotaService: QuotaService) {}
+
+  /** 设置自己的 API Key 限额 */
+  @Put('keys/:id/limits')
+  async setKeyLimits(
+    @CurrentUser() user: JwtPayload,
+    @Param('id') keyId: string,
+    @Body() dto: SetLimitsDto,
+  ) {
+    try {
+      const updated = await this.quotaService.setKeyLimits(
+        user.sub,
+        keyId,
+        {
+          ...dto,
+          dailyTokenLimit:
+            dto.dailyTokenLimit !== undefined && dto.dailyTokenLimit !== null
+              ? dto.dailyTokenLimit
+              : dto.dailyTokenLimit,
+          totalTokenLimit:
+            dto.totalTokenLimit !== undefined && dto.totalTokenLimit !== null
+              ? dto.totalTokenLimit
+              : dto.totalTokenLimit,
+        },
+      );
+
+      return {
+        id: updated.id,
+        name: updated.name,
+        limits: {
+          dailyRequestLimit: updated.dailyRequestLimit,
+          dailyTokenLimit: typeof updated.dailyTokenLimit === 'bigint' ? Number(updated.dailyTokenLimit) : updated.dailyTokenLimit,
+          dailyCostCentsLimit: updated.dailyCostCentsLimit,
+          totalRequestLimit: updated.totalRequestLimit,
+          totalTokenLimit: typeof updated.totalTokenLimit === 'bigint' ? Number(updated.totalTokenLimit) : updated.totalTokenLimit,
+          totalCostCentsLimit: updated.totalCostCentsLimit,
+          alertPct: updated.alertPct,
+        },
+      };
+    } catch (err: unknown) {
+      if (
+        err instanceof Error &&
+        err.message === 'API Key 不存在或无权操作'
+      ) {
+        throw new NotFoundException('API Key 不存在');
+      }
+      throw err;
+    }
+  }
+
+  /** 设置自己的账号级限额 */
+  @Put('account/limits')
+  async setAccountLimits(
+    @CurrentUser() user: JwtPayload,
+    @Body() dto: SetLimitsDto,
+  ) {
+    const updated = await this.quotaService.setAccountLimits(
+      user.sub,
+      {
+        ...dto,
+        dailyTokenLimit:
+          dto.dailyTokenLimit !== undefined && dto.dailyTokenLimit !== null
+            ? dto.dailyTokenLimit
+            : dto.dailyTokenLimit,
+        totalTokenLimit:
+          dto.totalTokenLimit !== undefined && dto.totalTokenLimit !== null
+            ? dto.totalTokenLimit
+            : dto.totalTokenLimit,
+      },
+    );
+
+    return {
+      id: updated.id,
+      email: updated.email,
+      limits: {
+        dailyRequestLimit: updated.dailyRequestLimit,
+        dailyTokenLimit: typeof updated.dailyTokenLimit === 'bigint' ? Number(updated.dailyTokenLimit) : updated.dailyTokenLimit,
+        dailyCostCentsLimit: updated.dailyCostCentsLimit,
+        totalRequestLimit: updated.totalRequestLimit,
+        totalTokenLimit: typeof updated.totalTokenLimit === 'bigint' ? Number(updated.totalTokenLimit) : updated.totalTokenLimit,
+        totalCostCentsLimit: updated.totalCostCentsLimit,
+        alertPct: updated.alertPct,
+      },
+    };
+  }
+
+  /** 查询用量配额状态(进度条数据) */
+  @Get('usage/quota')
+  getQuotaStatus(@CurrentUser() user: JwtPayload) {
+    return this.quotaService.getQuotaStatus(user.sub);
+  }
+}

+ 15 - 0
apps/api/src/quota/quota.module.ts

@@ -0,0 +1,15 @@
+/**
+ * 用量配额模块 - 限额设置、用量检查、预警/超限事件
+ */
+import { Module } from '@nestjs/common';
+import { QuotaService } from './quota.service';
+import { QuotaController } from './quota.controller';
+import { AuthModule } from '../auth/auth.module';
+
+@Module({
+  imports: [AuthModule],
+  controllers: [QuotaController],
+  providers: [QuotaService],
+  exports: [QuotaService],
+})
+export class QuotaModule {}

+ 738 - 0
apps/api/src/quota/quota.service.ts

@@ -0,0 +1,738 @@
+/**
+ * 用量配额服务 - 用量聚合、限额检查、预警/超限事件
+ * 每日窗口按 UTC 自然日;总量窗口终身累计
+ * 三维度(请求/token/费用)× 两窗口(每日/总量)× 两层级(账号/Key)
+ */
+import { Injectable, Logger } from '@nestjs/common';
+import { PrismaService } from '../prisma/prisma.service';
+import { RedisService } from '../redis/redis.service';
+import type { User, ApiKey } from '@prisma/client';
+
+// ---- 类型定义 ----
+
+/** 超限信息 */
+export interface BlockedInfo {
+  scope: 'account' | 'key';
+  window: 'daily' | 'total';
+  dimension: 'request' | 'token' | 'cost';
+  limit: number;
+  current: number;
+}
+
+/** 预警信息 */
+export interface AlertInfo {
+  scope: 'account' | 'key';
+  window: 'daily' | 'total';
+  dimension: 'request' | 'token' | 'cost';
+  limit: number;
+  current: number;
+  pct: number; // 实际使用百分比
+}
+
+/** 配额状态(供前端进度条) */
+export interface QuotaUsageItem {
+  dimension: string;
+  window: string;
+  limit: number | null; // null = 不限
+  current: number;
+  pct: number; // 0-100,不限时 = 0
+}
+
+export interface QuotaStatusEntry {
+  scopeId: string;
+  scopeName: string;
+  limits: {
+    dailyRequestLimit: number | null;
+    dailyTokenLimit: number | null;
+    dailyCostCentsLimit: number | null;
+    totalRequestLimit: number | null;
+    totalTokenLimit: number | null;
+    totalCostCentsLimit: number | null;
+    alertPct: number;
+  };
+  usage: QuotaUsageItem[];
+}
+
+// ---- 内部辅助 ----
+
+const DIMENSIONS = ['request', 'token', 'cost'] as const;
+const WINDOWS = ['daily', 'total'] as const;
+
+type Dimension = (typeof DIMENSIONS)[number];
+type WindowType = (typeof WINDOWS)[number];
+type ScopeType = 'account' | 'key';
+
+/** 提取实体中的限额字段值 */
+function getLimitValue(
+  entity: User | ApiKey,
+  dimension: Dimension,
+  windowType: WindowType,
+): number | null {
+  // 按维度+窗口直接映射,避免索引访问问题
+  if (dimension === 'request') {
+    if (windowType === 'daily') return entity.dailyRequestLimit;
+    return entity.totalRequestLimit;
+  }
+  if (dimension === 'token') {
+    if (windowType === 'daily') {
+      const v = entity.dailyTokenLimit;
+      return v !== null && v !== undefined ? Number(v) : null;
+    }
+    const v = entity.totalTokenLimit;
+    return v !== null && v !== undefined ? Number(v) : null;
+  }
+  if (dimension === 'cost') {
+    if (windowType === 'daily') return entity.dailyCostCentsLimit;
+    return entity.totalCostCentsLimit;
+  }
+  return null;
+}
+
+/** 获取 alertPct,默认 80 */
+function getAlertPct(entity: User | ApiKey): number {
+  return entity.alertPct ?? 80;
+}
+
+/** 获取 UTC 当日 00:00:00 */
+function utcStartOfToday(): Date {
+  const d = new Date();
+  d.setUTCHours(0, 0, 0, 0);
+  return d;
+}
+
+@Injectable()
+export class QuotaService {
+  private readonly logger = new Logger(QuotaService.name);
+
+  constructor(
+    private readonly prisma: PrismaService,
+    private readonly redis: RedisService,
+  ) {}
+
+  // ========== 用量聚合 ==========
+
+  /**
+   * 聚合指定维度和窗口的用量
+   * @returns BigInt 表示的用量(请求数、token 数、费用美分)
+   */
+  async computeUsage(
+    scopeType: ScopeType,
+    scopeId: string,
+    dimension: Dimension,
+    windowType: WindowType,
+  ): Promise<bigint> {
+    const scopeField = scopeType === 'account' ? 'user_id' : 'api_key_id';
+
+    const params: unknown[] = [scopeId];
+
+    let windowCondition = '';
+    if (windowType === 'daily') {
+      windowCondition = 'AND created_at >= $2';
+      params.push(utcStartOfToday());
+    }
+
+    let selectExpr: string;
+    switch (dimension) {
+      case 'request':
+        selectExpr = 'COUNT(*)';
+        break;
+      case 'token':
+        selectExpr = 'COALESCE(SUM(prompt_tokens + completion_tokens), 0)';
+        break;
+      case 'cost':
+        selectExpr = 'COALESCE(SUM(cost_cents), 0)';
+        break;
+    }
+
+    const sql = `SELECT ${selectExpr}::bigint AS val FROM usage_records WHERE ${scopeField} = $1 ${windowCondition}`;
+
+    try {
+      const rows = await this.prisma.$queryRawUnsafe<
+        Array<{ val: bigint }>
+      >(sql, ...params);
+      return rows[0]?.val ?? 0n;
+    } catch (err) {
+      this.logger.warn(`computeUsage failed: ${String(err)}`);
+      return 0n;
+    }
+  }
+
+  // ========== 核心配额检查 ==========
+
+  /**
+   * 检查请求是否被配额限制
+   * 同时检查账号级和 Key 级限额,任一维度超限即拒绝
+   */
+  async checkQuota(
+    userId: string,
+    apiKeyId: string,
+  ): Promise<{ blocked: BlockedInfo[]; alerts: AlertInfo[] }> {
+    const [user, apiKey] = await Promise.all([
+      this.prisma.user.findUnique({ where: { id: userId } }),
+      this.prisma.apiKey.findUnique({ where: { id: apiKeyId } }),
+    ]);
+
+    if (!user || !apiKey) {
+      return { blocked: [], alerts: [] };
+    }
+
+    const blocked: BlockedInfo[] = [];
+    const alerts: AlertInfo[] = [];
+
+    const scopes: {
+      type: ScopeType;
+      entity: User | ApiKey;
+      scopeId: string;
+    }[] = [
+      { type: 'account', entity: user, scopeId: userId },
+      { type: 'key', entity: apiKey, scopeId: apiKeyId },
+    ];
+
+    for (const scope of scopes) {
+      for (const dim of DIMENSIONS) {
+        for (const win of WINDOWS) {
+          const limitVal = getLimitValue(scope.entity, dim, win);
+          if (limitVal === null || limitVal === undefined) continue;
+          if (limitVal <= 0) continue; // 0 = 不限
+
+          const limit = BigInt(limitVal);
+          const usage = await this.computeUsage(scope.type, scope.scopeId, dim, win);
+
+          if (usage >= limit) {
+            // ---- 硬限:用量 >= 限额 ----
+            blocked.push({
+              scope: scope.type,
+              window: win,
+              dimension: dim,
+              limit: limitVal,
+              current: Number(usage),
+            });
+
+            // 记录超限事件(Redis 节流 5 分钟内不重复审计)
+            void this.recordBlockEvent(
+              userId,
+              apiKeyId,
+              dim,
+              win,
+              scope.type,
+              limitVal,
+              usage,
+            );
+          } else {
+            // ---- 预警检查 ----
+            const alertPct = getAlertPct(scope.entity);
+            const threshold = BigInt(Math.floor((limitVal * alertPct) / 100));
+
+            if (usage >= threshold) {
+              const alreadyAlerted = await this.isAlertedToday(
+                scope.type,
+                scope.scopeId,
+                dim,
+                win,
+              );
+
+              if (!alreadyAlerted) {
+                alerts.push({
+                  scope: scope.type,
+                  window: win,
+                  dimension: dim,
+                  limit: limitVal,
+                  current: Number(usage),
+                  pct: Math.min(
+                    99,
+                    Math.floor((Number(usage) * 100) / limitVal),
+                  ),
+                });
+
+                // 记录预警事件
+                void this.recordAlertEvent(
+                  userId,
+                  apiKeyId,
+                  dim,
+                  win,
+                  scope.type,
+                  Number(threshold),
+                  usage,
+                );
+              }
+            }
+          }
+        }
+      }
+    }
+
+    return { blocked, alerts };
+  }
+
+  // ========== 事件记录 ==========
+
+  /** 检查当日是否已有同维度+窗口+层级的预警 */
+  private async isAlertedToday(
+    scopeType: ScopeType,
+    scopeId: string,
+    dimension: Dimension,
+    windowType: WindowType,
+  ): Promise<boolean> {
+    try {
+      const count = await this.prisma.quotaEvent.count({
+        where: {
+          action: 'alert',
+          dimension,
+          windowType,
+          scopeType,
+          createdAt: { gte: utcStartOfToday() },
+          ...(scopeType === 'account'
+            ? { userId: scopeId, apiKeyId: null }
+            : { apiKeyId: scopeId }),
+        },
+      });
+      return count > 0;
+    } catch {
+      return false;
+    }
+  }
+
+  /** 记录预警事件(fire-and-forget) */
+  private async recordAlertEvent(
+    userId: string,
+    apiKeyId: string,
+    dimension: Dimension,
+    windowType: WindowType,
+    scopeType: ScopeType,
+    threshold: number,
+    current: bigint,
+  ): Promise<void> {
+    try {
+      await this.prisma.quotaEvent.create({
+        data: {
+          userId,
+          apiKeyId: scopeType === 'key' ? apiKeyId : null,
+          dimension,
+          windowType,
+          scopeType,
+          threshold,
+          current,
+          action: 'alert',
+        },
+      });
+
+      // [EMAIL] 预留 - 每日最多一封
+      this.logger.log(
+        `[EMAIL] Quota alert for user ${userId}: ${scopeType} ${dimension} ${windowType} at ${Number(current)}/${threshold}`,
+      );
+    } catch (err) {
+      this.logger.warn(`Failed to record alert event: ${String(err)}`);
+    }
+  }
+
+  /** 记录超限事件(Redis 节流:同一 scope+window+dim 5 分钟内仅一条) */
+  private async recordBlockEvent(
+    userId: string,
+    apiKeyId: string,
+    dimension: Dimension,
+    windowType: WindowType,
+    scopeType: ScopeType,
+    limit: number,
+    current: bigint,
+  ): Promise<void> {
+    const scopeId = scopeType === 'account' ? userId : apiKeyId;
+    const throttleKey = `quota:block:${scopeType}:${scopeId}:${windowType}:${dimension}`;
+
+    try {
+      // SET NX EX 300: 只有 key 不存在时才设置,5 分钟过期
+      const set = await this.redis.client.set(
+        throttleKey,
+        '1',
+        'EX',
+        300,
+        'NX',
+      );
+      if (set !== 'OK') return; // 已存在,跳过本次记录
+    } catch {
+      // Redis 不可用时仍记录(不节流)
+    }
+
+    try {
+      await this.prisma.quotaEvent.create({
+        data: {
+          userId,
+          apiKeyId: scopeType === 'key' ? apiKeyId : null,
+          dimension,
+          windowType,
+          scopeType,
+          threshold: limit,
+          current,
+          action: 'block',
+        },
+      });
+    } catch (err) {
+      this.logger.warn(`Failed to record block event: ${String(err)}`);
+    }
+  }
+
+  // ========== 限额管理 ==========
+
+  /** 设置用户(账号级)限额 */
+  async setAccountLimits(
+    userId: string,
+    limits: Partial<{
+      dailyRequestLimit: number | null;
+      dailyTokenLimit: number | null;
+      dailyCostCentsLimit: number | null;
+      totalRequestLimit: number | null;
+      totalTokenLimit: number | null;
+      totalCostCentsLimit: number | null;
+      alertPct: number;
+    }>,
+  ): Promise<User> {
+    const data: Record<string, unknown> = {};
+    for (const [key, val] of Object.entries(limits)) {
+      if (val !== undefined) {
+        data[key] = val;
+      }
+    }
+    return this.prisma.user.update({
+      where: { id: userId },
+      data,
+    });
+  }
+
+  /** 设置 API Key 限额(验证所有权) */
+  async setKeyLimits(
+    userId: string,
+    keyId: string,
+    limits: Partial<{
+      dailyRequestLimit: number | null;
+      dailyTokenLimit: number | null;
+      dailyCostCentsLimit: number | null;
+      totalRequestLimit: number | null;
+      totalTokenLimit: number | null;
+      totalCostCentsLimit: number | null;
+      alertPct: number;
+    }>,
+  ): Promise<ApiKey> {
+    // 验证所有权
+    const key = await this.prisma.apiKey.findUnique({
+      where: { id: keyId },
+    });
+    if (!key || key.userId !== userId) {
+      throw new Error('API Key 不存在或无权操作');
+    }
+
+    const data: Record<string, unknown> = {};
+    for (const [keyName, val] of Object.entries(limits)) {
+      if (val !== undefined) {
+        data[keyName] = val;
+      }
+    }
+
+    return this.prisma.apiKey.update({
+      where: { id: keyId },
+      data,
+    });
+  }
+
+  /** 管理员设置用户限额(跳过所有权检查) */
+  async adminSetUserLimits(
+    userId: string,
+    limits: Record<string, unknown>,
+  ): Promise<User> {
+    return this.prisma.user.update({
+      where: { id: userId },
+      data: limits,
+    });
+  }
+
+  /** 管理员设置 Key 限额(跳过所有权检查) */
+  async adminSetKeyLimits(
+    keyId: string,
+    limits: Record<string, unknown>,
+  ): Promise<ApiKey> {
+    return this.prisma.apiKey.update({
+      where: { id: keyId },
+      data: limits,
+    });
+  }
+
+  // ========== 查询 ==========
+
+  /** 获取用户配额状态(账号 + 所有 Key)- 供控制台进度条使用 */
+  async getQuotaStatus(userId: string): Promise<{
+    account: QuotaStatusEntry;
+    keys: QuotaStatusEntry[];
+  }> {
+    const user = await this.prisma.user.findUnique({
+      where: { id: userId },
+    });
+    if (!user) {
+      throw new Error('用户不存在');
+    }
+
+    const keys = await this.prisma.apiKey.findMany({
+      where: { userId, status: 'active' },
+    });
+
+    const accountStatus = await this.buildStatusEntry(
+      'account',
+      userId,
+      user.email,
+      user,
+    );
+
+    const keyStatuses = await Promise.all(
+      keys.map((k) =>
+        this.buildStatusEntry('key', k.id, k.name, k),
+      ),
+    );
+
+    return { account: accountStatus, keys: keyStatuses };
+  }
+
+  /** 构建单个 scope 的状态条目 */
+  private async buildStatusEntry(
+    scopeType: ScopeType,
+    scopeId: string,
+    scopeName: string,
+    entity: User | ApiKey,
+  ): Promise<QuotaStatusEntry> {
+    const usageItems: QuotaUsageItem[] = [];
+
+    for (const dim of DIMENSIONS) {
+      for (const win of WINDOWS) {
+        const limit = getLimitValue(entity, dim, win);
+        const current = await this.computeUsage(scopeType, scopeId, dim, win);
+        const pct =
+          limit !== null && limit !== undefined && limit > 0
+            ? Math.min(100, Math.floor((Number(current) * 100) / limit))
+            : 0;
+
+        usageItems.push({
+          dimension: dim,
+          window: win,
+          limit,
+          current: Number(current),
+          pct,
+        });
+      }
+    }
+
+    return {
+      scopeId,
+      scopeName,
+      limits: {
+        dailyRequestLimit: entity.dailyRequestLimit ?? null,
+        dailyTokenLimit:
+          typeof entity.dailyTokenLimit === 'bigint'
+            ? Number(entity.dailyTokenLimit)
+            : entity.dailyTokenLimit ?? null,
+        dailyCostCentsLimit: entity.dailyCostCentsLimit ?? null,
+        totalRequestLimit: entity.totalRequestLimit ?? null,
+        totalTokenLimit:
+          typeof entity.totalTokenLimit === 'bigint'
+            ? Number(entity.totalTokenLimit)
+            : entity.totalTokenLimit ?? null,
+        totalCostCentsLimit: entity.totalCostCentsLimit ?? null,
+        alertPct: entity.alertPct ?? 80,
+      },
+      usage: usageItems,
+    };
+  }
+
+  /** 管理后台:获取全局限额列表 */
+  async listQuotas(params: {
+    q?: string;
+    scope?: string;
+    page: number;
+    pageSize: number;
+  }): Promise<{
+    total: number;
+    page: number;
+    pageSize: number;
+    data: Array<{
+      id: string;
+      email?: string;
+      name?: string;
+      scope: string;
+      limits: Record<string, number | null>;
+      status: 'over' | 'alert' | 'ok';
+    }>;
+  }> {
+    const { q, scope, page, pageSize } = params;
+
+    if (scope === 'key') {
+      // 列出所有 Key(含限额配置)
+      const where: Record<string, unknown> = {};
+      if (q) {
+        where['name'] = { contains: q, mode: 'insensitive' };
+      }
+
+      const [keys, total] = await Promise.all([
+        this.prisma.apiKey.findMany({
+          where,
+          skip: (page - 1) * pageSize,
+          take: pageSize,
+          orderBy: { createdAt: 'desc' },
+          select: {
+            id: true,
+            userId: true,
+            name: true,
+            prefix: true,
+            status: true,
+            dailyRequestLimit: true,
+            dailyTokenLimit: true,
+            dailyCostCentsLimit: true,
+            totalRequestLimit: true,
+            totalTokenLimit: true,
+            totalCostCentsLimit: true,
+            alertPct: true,
+            createdAt: true,
+          },
+        }),
+        this.prisma.apiKey.count({ where }),
+      ]);
+
+      const data = await Promise.all(
+        keys.map(async (k) => {
+          const status = await this.computeScopeStatus('key', k);
+          return {
+            id: k.id,
+            name: k.name,
+            scope: 'key',
+            limits: {
+              dailyRequestLimit: k.dailyRequestLimit,
+              dailyTokenLimit: typeof k.dailyTokenLimit === 'bigint' ? Number(k.dailyTokenLimit) : k.dailyTokenLimit,
+              dailyCostCentsLimit: k.dailyCostCentsLimit,
+              totalRequestLimit: k.totalRequestLimit,
+              totalTokenLimit: typeof k.totalTokenLimit === 'bigint' ? Number(k.totalTokenLimit) : k.totalTokenLimit,
+              totalCostCentsLimit: k.totalCostCentsLimit,
+              alertPct: k.alertPct,
+            },
+            status,
+          };
+        }),
+      );
+
+      return { total, page, pageSize, data };
+    }
+
+    // 默认列出用户
+    const where: Record<string, unknown> = {};
+    if (q) {
+      where['email'] = { contains: q, mode: 'insensitive' };
+    }
+
+    const [users, total] = await Promise.all([
+      this.prisma.user.findMany({
+        where,
+        skip: (page - 1) * pageSize,
+        take: pageSize,
+        orderBy: { createdAt: 'desc' },
+        select: {
+          id: true,
+          email: true,
+          dailyRequestLimit: true,
+          dailyTokenLimit: true,
+          dailyCostCentsLimit: true,
+          totalRequestLimit: true,
+          totalTokenLimit: true,
+          totalCostCentsLimit: true,
+          alertPct: true,
+        },
+      }),
+      this.prisma.user.count({ where }),
+    ]);
+
+    const data = await Promise.all(
+      users.map(async (u) => {
+        const status = await this.computeScopeStatus('account', u);
+        return {
+          id: u.id,
+          email: u.email,
+          scope: 'user',
+          limits: {
+            dailyRequestLimit: u.dailyRequestLimit,
+            dailyTokenLimit: typeof u.dailyTokenLimit === 'bigint' ? Number(u.dailyTokenLimit) : u.dailyTokenLimit,
+            dailyCostCentsLimit: u.dailyCostCentsLimit,
+            totalRequestLimit: u.totalRequestLimit,
+            totalTokenLimit: typeof u.totalTokenLimit === 'bigint' ? Number(u.totalTokenLimit) : u.totalTokenLimit,
+            totalCostCentsLimit: u.totalCostCentsLimit,
+            alertPct: u.alertPct,
+          },
+          status,
+        };
+      }),
+    );
+
+    return { total, page, pageSize, data };
+  }
+
+  /** 判断单个 scope 的状态(over / alert / ok) */
+  private async computeScopeStatus(
+    scopeType: ScopeType,
+    entity: { id: string } & Record<string, unknown>,
+  ): Promise<'over' | 'alert' | 'ok'> {
+    const scopeId = entity.id as string;
+    let maxPct = 0;
+
+    for (const dim of DIMENSIONS) {
+      for (const win of WINDOWS) {
+        const limit = getLimitValue(
+          entity as unknown as User | ApiKey,
+          dim,
+          win,
+        );
+        if (!limit || limit <= 0) continue;
+
+        const usage = await this.computeUsage(scopeType, scopeId, dim, win);
+        const pct = Math.floor((Number(usage) * 100) / limit);
+
+        if (pct >= 100) return 'over';
+        if (pct > maxPct) maxPct = pct;
+      }
+    }
+
+    if (maxPct >= 80) return 'alert';
+    return 'ok';
+  }
+
+  /** 管理后台:获取配额事件列表 */
+  async listQuotaEvents(params: {
+    action?: string;
+    limit: number;
+  }): Promise<Array<{
+    id: string;
+    userId: string;
+    apiKeyId: string | null;
+    dimension: string;
+    windowType: string;
+    scopeType: string;
+    threshold: number;
+    current: string;
+    action: string;
+    createdAt: string;
+  }>> {
+    const where: Record<string, unknown> = {};
+    if (params.action) {
+      where['action'] = params.action;
+    }
+
+    const events = await this.prisma.quotaEvent.findMany({
+      where,
+      orderBy: { createdAt: 'desc' },
+      take: params.limit,
+    });
+
+    return events.map((e) => ({
+      id: e.id,
+      userId: e.userId,
+      apiKeyId: e.apiKeyId,
+      dimension: e.dimension,
+      windowType: e.windowType,
+      scopeType: e.scopeType,
+      threshold: e.threshold,
+      current: String(e.current),
+      action: e.action,
+      createdAt: e.createdAt.toISOString(),
+    }));
+  }
+}

+ 12 - 0
apps/api/src/redis/redis.module.ts

@@ -0,0 +1,12 @@
+/**
+ * Redis 模块 - 全局导出 RedisService,不阻断启动
+ */
+import { Global, Module } from '@nestjs/common';
+import { RedisService } from './redis.service';
+
+@Global()
+@Module({
+  providers: [RedisService],
+  exports: [RedisService],
+})
+export class RedisModule {}

+ 35 - 0
apps/api/src/redis/redis.service.ts

@@ -0,0 +1,35 @@
+/**
+ * Redis 服务 - ioredis 封装,延迟连接不阻断启动
+ */
+import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
+import Redis from 'ioredis';
+
+@Injectable()
+export class RedisService implements OnModuleDestroy {
+  private readonly logger = new Logger(RedisService.name);
+  private _client: Redis | null = null;
+
+  /** 获取 Redis 客户端(延迟初始化) */
+  get client(): Redis {
+    if (!this._client) {
+      const url = process.env['REDIS_URL'] ?? 'redis://localhost:6379';
+      this._client = new Redis(url, {
+        lazyConnect: true,
+        maxRetriesPerRequest: 3,
+      });
+      this._client.on('error', (err: Error) => {
+        this.logger.warn(`Redis connection error: ${err.message}`);
+      });
+      this._client.connect().catch((err: Error) => {
+        this.logger.warn(`Redis connect failed (non-blocking): ${err.message}`);
+      });
+    }
+    return this._client;
+  }
+
+  async onModuleDestroy() {
+    if (this._client) {
+      await this._client.quit();
+    }
+  }
+}

+ 34 - 0
apps/api/src/usage/usage.controller.ts

@@ -0,0 +1,34 @@
+/**
+ * 用量控制器 - GET /v1/usage 列表 + GET /v1/usage/summary 聚合(需 JWT)
+ */
+import { Controller, Get, Query, UseGuards } from '@nestjs/common';
+import { UsageService } from './usage.service';
+import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { CurrentUser } from '../auth/current-user.decorator';
+import type { JwtPayload } from '../auth/current-user.decorator';
+
+@Controller('usage')
+@UseGuards(JwtAuthGuard)
+export class UsageController {
+  constructor(private readonly usageService: UsageService) {}
+
+  @Get()
+  list(
+    @CurrentUser() user: JwtPayload,
+    @Query('limit') limit?: string,
+    @Query('offset') offset?: string,
+    @Query('model') model?: string,
+  ) {
+    return this.usageService.listUsage(
+      user.sub,
+      Math.min(Number(limit) || 20, 100),
+      Number(offset) || 0,
+      model,
+    );
+  }
+
+  @Get('summary')
+  summary(@CurrentUser() user: JwtPayload) {
+    return this.usageService.getSummary(user.sub);
+  }
+}

+ 14 - 0
apps/api/src/usage/usage.module.ts

@@ -0,0 +1,14 @@
+/**
+ * 用量模块
+ */
+import { Module } from '@nestjs/common';
+import { UsageService } from './usage.service';
+import { UsageController } from './usage.controller';
+import { AuthModule } from '../auth/auth.module';
+
+@Module({
+  imports: [AuthModule],
+  controllers: [UsageController],
+  providers: [UsageService],
+})
+export class UsageModule {}

+ 106 - 0
apps/api/src/usage/usage.service.ts

@@ -0,0 +1,106 @@
+/**
+ * 用量服务 - 查询用量记录、按日聚合统计
+ */
+import { Injectable } from '@nestjs/common';
+import { PrismaService } from '../prisma/prisma.service';
+
+@Injectable()
+export class UsageService {
+  constructor(private readonly prisma: PrismaService) {}
+
+  /** 分页查询用量记录(可选按模型过滤) */
+  async listUsage(
+    userId: string,
+    limit: number,
+    offset: number,
+    model?: string,
+  ) {
+    const where: Record<string, unknown> = { userId };
+    if (model) {
+      where['model'] = model;
+    }
+
+    const [records, total] = await Promise.all([
+      this.prisma.usageRecord.findMany({
+        where,
+        orderBy: { createdAt: 'desc' },
+        take: limit,
+        skip: offset,
+        select: {
+          id: true,
+          model: true,
+          promptTokens: true,
+          completionTokens: true,
+          costCents: true,
+          createdAt: true,
+        },
+      }),
+      this.prisma.usageRecord.count({ where }),
+    ]);
+
+    return {
+      total,
+      limit,
+      offset,
+      data: records.map((r) => ({
+        id: r.id,
+        model: r.model,
+        promptTokens: r.promptTokens,
+        completionTokens: r.completionTokens,
+        totalTokens: r.promptTokens + r.completionTokens,
+        costCents: r.costCents,
+        createdAt: r.createdAt.toISOString(),
+      })),
+    };
+  }
+
+  /** 最近 30 天按日聚合用量 */
+  async getSummary(userId: string) {
+    const thirtyDaysAgo = new Date();
+    thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
+
+    const rows = await this.prisma.$queryRawUnsafe<
+      Array<{
+        date: string;
+        cost_cents: bigint;
+        prompt_tokens: bigint;
+        completion_tokens: bigint;
+      }>
+    >(
+      `SELECT
+        DATE(created_at) AS date,
+        COALESCE(SUM(cost_cents), 0) AS cost_cents,
+        COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens,
+        COALESCE(SUM(completion_tokens), 0) AS completion_tokens
+      FROM usage_records
+      WHERE user_id = $1 AND created_at >= $2
+      GROUP BY DATE(created_at)
+      ORDER BY date DESC`,
+      userId,
+      thirtyDaysAgo,
+    );
+
+    let totalCostCents = 0;
+    let totalTokens = 0;
+
+    const daily = rows.map((row) => {
+      const cost = Number(row.cost_cents);
+      const pt = Number(row.prompt_tokens);
+      const ct = Number(row.completion_tokens);
+      totalCostCents += cost;
+      totalTokens += pt + ct;
+      return {
+        date: row.date,
+        costCents: cost,
+        promptTokens: pt,
+        completionTokens: ct,
+      };
+    });
+
+    return {
+      totalCostCents,
+      totalTokens,
+      daily,
+    };
+  }
+}

+ 17 - 0
apps/api/src/wallet/exceptions/insufficient-balance.exception.ts

@@ -0,0 +1,17 @@
+/**
+ * 余额不足异常 - 402 状态码(类似 Payment Required)
+ */
+import { HttpException, HttpStatus } from '@nestjs/common';
+
+export class InsufficientBalanceException extends HttpException {
+  constructor(
+    message = '余额不足',
+    public readonly required: bigint,
+    public readonly available: bigint,
+  ) {
+    super(
+      { statusCode: HttpStatus.PAYMENT_REQUIRED, message, required: Number(required), available: Number(available) },
+      HttpStatus.PAYMENT_REQUIRED,
+    );
+  }
+}

+ 33 - 0
apps/api/src/wallet/wallet.controller.ts

@@ -0,0 +1,33 @@
+/**
+ * 钱包控制器 - 余额查询 / 充值桩
+ */
+import { Controller, Get, Post, UseGuards, Res } from '@nestjs/common';
+import type { Response } from 'express';
+import { WalletService } from './wallet.service';
+import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { CurrentUser } from '../auth/current-user.decorator';
+import type { JwtPayload } from '../auth/current-user.decorator';
+
+@Controller('wallet')
+@UseGuards(JwtAuthGuard)
+export class WalletController {
+  constructor(private readonly walletService: WalletService) {}
+
+  @Get()
+  getWallet(@CurrentUser() user: JwtPayload) {
+    return this.walletService.getWallet(user.sub);
+  }
+
+  /**
+   * TODO: P4 阶段实现真实支付(支付宝/Stripe)
+   * 当前桩返回 501 Not Implemented
+   */
+  @Post('deposit')
+  deposit(@Res() res: Response) {
+    return res.status(501).json({
+      statusCode: 501,
+      message:
+        '充值功能尚未实现,将在 P4 阶段接入支付宝/Stripe 支付',
+    });
+  }
+}

+ 15 - 0
apps/api/src/wallet/wallet.module.ts

@@ -0,0 +1,15 @@
+/**
+ * 钱包模块
+ */
+import { Module } from '@nestjs/common';
+import { WalletService } from './wallet.service';
+import { WalletController } from './wallet.controller';
+import { AuthModule } from '../auth/auth.module';
+
+@Module({
+  imports: [AuthModule],
+  controllers: [WalletController],
+  providers: [WalletService],
+  exports: [WalletService],
+})
+export class WalletModule {}

+ 139 - 0
apps/api/src/wallet/wallet.service.ts

@@ -0,0 +1,139 @@
+/**
+ * 钱包服务 - 余额查询、扣费(原子事务)、充值、入账
+ */
+import { Injectable, Logger } from '@nestjs/common';
+import { PrismaService } from '../prisma/prisma.service';
+import { InsufficientBalanceException } from './exceptions/insufficient-balance.exception';
+
+@Injectable()
+export class WalletService {
+  private readonly logger = new Logger(WalletService.name);
+
+  constructor(private readonly prisma: PrismaService) {}
+
+  async getWallet(userId: string) {
+    const user = await this.prisma.user.findUnique({
+      where: { id: userId },
+      select: { balanceCents: true, currency: true },
+    });
+    if (!user) {
+      throw new Error('用户不存在');
+    }
+
+    const transactions = await this.prisma.balanceTransaction.findMany({
+      where: { userId },
+      orderBy: { createdAt: 'desc' },
+      take: 20,
+      select: {
+        id: true,
+        amountCents: true,
+        type: true,
+        refId: true,
+        createdAt: true,
+      },
+    });
+
+    return {
+      balanceCents: Number(user.balanceCents),
+      currency: user.currency,
+      transactions: transactions.map((t) => ({
+        ...t,
+        amountCents: Number(t.amountCents),
+        refId: t.refId ?? null,
+        createdAt: t.createdAt.toISOString(),
+      })),
+    };
+  }
+
+  /**
+   * 原子扣费 - 使用 UPDATE WHERE 保证余额充足
+   * 返回值 { count: 0 } 表示余额不足,抛出 InsufficientBalanceException
+   * 插入 BalanceTransaction type='usage'
+   */
+  async deduct(
+    userId: string,
+    amountCents: bigint,
+    refId: string,
+    _model?: string,
+  ): Promise<{ newBalance: bigint }> {
+    const result = await this.prisma.$transaction(async (tx) => {
+      // 原子扣减:仅当 balanceCents >= amountCents 时更新
+      const updateResult = await tx.user.updateMany({
+        where: {
+          id: userId,
+          balanceCents: { gte: amountCents },
+        },
+        data: {
+          balanceCents: { decrement: amountCents },
+        },
+      });
+
+      if (updateResult.count === 0) {
+        const user = await tx.user.findUnique({
+          where: { id: userId },
+          select: { balanceCents: true },
+        });
+        throw new InsufficientBalanceException(
+          '余额不足,无法完成扣费',
+          amountCents,
+          user?.balanceCents ?? 0n,
+        );
+      }
+
+      // 记录交易
+      await tx.balanceTransaction.create({
+        data: {
+          userId,
+          amountCents: -amountCents,
+          type: 'usage',
+          refId,
+        },
+      });
+
+      // 读取新余额
+      const updated = await tx.user.findUniqueOrThrow({
+        where: { id: userId },
+        select: { balanceCents: true },
+      });
+
+      return { newBalance: updated.balanceCents };
+    });
+
+    this.logger.debug(`Deducted user=${userId} amount=${amountCents} ref=${refId}`);
+    return result;
+  }
+
+  /**
+   * 入账(充值/退款)- 直接增加余额并记录交易
+   */
+  async credit(
+    userId: string,
+    amountCents: bigint,
+    refId: string,
+    type: string,
+  ): Promise<{ newBalance: bigint }> {
+    const result = await this.prisma.$transaction(async (tx) => {
+      const updated = await tx.user.update({
+        where: { id: userId },
+        data: {
+          balanceCents: { increment: amountCents },
+        },
+        select: { balanceCents: true },
+      });
+
+      await tx.balanceTransaction.create({
+        data: {
+          userId,
+          amountCents,
+          type,
+          refId,
+        },
+      });
+
+      return { newBalance: updated.balanceCents };
+    });
+
+    this.logger.debug(`Credited user=${userId} amount=${amountCents} ref=${refId} type=${type}`);
+    return result;
+  }
+}

+ 4 - 0
apps/api/tsconfig.build.json

@@ -0,0 +1,4 @@
+{
+  "extends": "./tsconfig.json",
+  "exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
+}

+ 27 - 0
apps/api/tsconfig.json

@@ -0,0 +1,27 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "CommonJS",
+    "moduleResolution": "node",
+    "lib": ["ES2022"],
+    "outDir": "./dist",
+    "rootDir": "./src",
+    "strict": true,
+    "experimentalDecorators": true,
+    "emitDecoratorMetadata": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true,
+    "forceConsistentCasingInFileNames": true,
+    "resolveJsonModule": true,
+    "declaration": true,
+    "declarationMap": true,
+    "sourceMap": true,
+    "noUncheckedIndexedAccess": true,
+    "noImplicitOverride": true,
+    "noFallthroughCasesInSwitch": true,
+    "useUnknownInCatchVariables": true
+  },
+  "include": ["src/**/*.ts"],
+  "exclude": ["node_modules", "dist"]
+}

+ 17 - 0
apps/web/messages/en.json

@@ -0,0 +1,17 @@
+{
+  "common": {
+    "locale": "en"
+  },
+  "nav": {
+    "home": "Home",
+    "pricing": "Pricing",
+    "docs": "Docs"
+  },
+  "hero": {
+    "title": "Your AI Compute Hub",
+    "cta": "Try Free"
+  },
+  "footer": {
+    "copyright": "© 2026 DoTouch.AI. All rights reserved."
+  }
+}

+ 17 - 0
apps/web/messages/vi.json

@@ -0,0 +1,17 @@
+{
+  "common": {
+    "locale": "vi"
+  },
+  "nav": {
+    "home": "Trang chủ",
+    "pricing": "Bảng giá",
+    "docs": "Tài liệu"
+  },
+  "hero": {
+    "title": "Trạm Trung Chuyển Sức Mạnh AI của Bạn",
+    "cta": "Dùng thử miễn phí"
+  },
+  "footer": {
+    "copyright": "© 2026 DoTouch.AI. Đã đăng ký bản quyền."
+  }
+}

+ 17 - 0
apps/web/messages/zh-CN.json

@@ -0,0 +1,17 @@
+{
+  "common": {
+    "locale": "zh-CN"
+  },
+  "nav": {
+    "home": "首页",
+    "pricing": "定价",
+    "docs": "文档"
+  },
+  "hero": {
+    "title": "你的 AI 算力中转站",
+    "cta": "免费试用"
+  },
+  "footer": {
+    "copyright": "© 2026 DoTouch.AI. 保留所有权利。"
+  }
+}

+ 17 - 0
apps/web/messages/zh-TW.json

@@ -0,0 +1,17 @@
+{
+  "common": {
+    "locale": "zh-TW"
+  },
+  "nav": {
+    "home": "首頁",
+    "pricing": "定價",
+    "docs": "文件"
+  },
+  "hero": {
+    "title": "你的 AI 算力中轉站",
+    "cta": "免費試用"
+  },
+  "footer": {
+    "copyright": "© 2026 DoTouch.AI. 保留所有權利。"
+  }
+}

+ 5 - 0
apps/web/next-env.d.ts

@@ -0,0 +1,5 @@
+/// <reference types="next" />
+/// <reference types="next/image-types/global" />
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

+ 12 - 0
apps/web/next.config.js

@@ -0,0 +1,12 @@
+/**
+ * next-intl Next.js 插件 - 启用国际化路由
+ */
+const createNextIntlPlugin = require('next-intl/plugin');
+const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
+
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+  reactStrictMode: true,
+};
+
+module.exports = withNextIntl(nextConfig);

+ 28 - 0
apps/web/package.json

@@ -0,0 +1,28 @@
+{
+  "name": "@dotouch/web",
+  "version": "0.0.1",
+  "private": true,
+  "description": "DoTouch.AI Landing Page & Dashboard",
+  "scripts": {
+    "dev": "next dev --port 3000",
+    "build": "next build",
+    "start": "next start",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@dotouch/shared": "workspace:*",
+    "next": "^14.2.0",
+    "next-intl": "^3.26.0",
+    "react": "^18.3.0",
+    "react-dom": "^18.3.0"
+  },
+  "devDependencies": {
+    "@types/node": "^22.0.0",
+    "@types/react": "^18.3.0",
+    "@types/react-dom": "^18.3.0",
+    "autoprefixer": "^10.4.0",
+    "postcss": "^8.4.0",
+    "tailwindcss": "^3.4.0",
+    "typescript": "^5.7.0"
+  }
+}

+ 6 - 0
apps/web/postcss.config.js

@@ -0,0 +1,6 @@
+module.exports = {
+  plugins: {
+    tailwindcss: {},
+    autoprefixer: {},
+  },
+};

+ 38 - 0
apps/web/src/app/[locale]/layout.tsx

@@ -0,0 +1,38 @@
+/**
+ * 根布局 - 全局布局包裹,设置默认语言
+ */
+import type { Metadata } from 'next';
+import { notFound } from 'next/navigation';
+import { NextIntlClientProvider } from 'next-intl';
+import { getMessages } from 'next-intl/server';
+import '../globals.css';
+
+type Props = {
+  children: React.ReactNode;
+  params: Promise<{ locale: string }>;
+};
+
+const locales = ['vi', 'en', 'zh-CN', 'zh-TW'] as const;
+
+export const metadata: Metadata = {
+  title: 'DoTouch.AI - AI Compute Hub',
+  description: 'AI API Reseller Platform for Vietnamese and overseas users.',
+};
+
+export default async function RootLayout({ children, params }: Props) {
+  const { locale } = await params;
+  if (!locales.includes(locale as (typeof locales)[number])) {
+    notFound();
+  }
+  const messages = await getMessages();
+
+  return (
+    <html lang={locale}>
+      <body className="bg-brand-bg text-brand-text antialiased">
+        <NextIntlClientProvider messages={messages}>
+          {children}
+        </NextIntlClientProvider>
+      </body>
+    </html>
+  );
+}

+ 19 - 0
apps/web/src/app/[locale]/page.tsx

@@ -0,0 +1,19 @@
+/**
+ * 首页 - 落地页主体内容
+ * 包含 Header / Hero / Footer 三段式布局
+ */
+import { Header } from '@/components/Header';
+import { Hero } from '@/components/Hero';
+import { Footer } from '@/components/Footer';
+
+export default function HomePage() {
+  return (
+    <div className="flex min-h-screen flex-col">
+      <Header />
+      <main className="flex-1">
+        <Hero />
+      </main>
+      <Footer />
+    </div>
+  );
+}

+ 13 - 0
apps/web/src/app/globals.css

@@ -0,0 +1,13 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+html {
+  scroll-behavior: smooth;
+}
+
+body {
+  background-color: #0B1020;
+  color: #E8ECF8;
+  font-family: system-ui, -apple-system, sans-serif;
+}

+ 16 - 0
apps/web/src/components/Footer.tsx

@@ -0,0 +1,16 @@
+/**
+ * 页脚 - 版权信息
+ */
+import { useTranslations } from 'next-intl';
+
+export function Footer() {
+  const t = useTranslations();
+
+  return (
+    <footer className="border-t border-brand-accent-purple/10 bg-brand-bg py-8">
+      <div className="mx-auto max-w-6xl px-6 text-center text-sm text-brand-text/40">
+        {t('footer.copyright')}
+      </div>
+    </footer>
+  );
+}

+ 32 - 0
apps/web/src/components/Header.tsx

@@ -0,0 +1,32 @@
+/**
+ * 页头导航栏 - Logo + 导航链接 + 语言切换
+ */
+import Link from 'next/link';
+import { useTranslations } from 'next-intl';
+import { LocaleSwitcher } from './LocaleSwitcher';
+
+export function Header() {
+  const t = useTranslations();
+
+  return (
+    <header className="sticky top-0 z-50 border-b border-brand-accent-purple/10 bg-brand-bg/80 backdrop-blur">
+      <div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
+        <Link href="/" className="text-xl font-bold tracking-tight">
+          <span className="text-brand-accent-purple">Do</span>
+          <span className="text-brand-accent-green">Touch</span>
+          <span className="text-brand-text">.AI</span>
+        </Link>
+
+        <nav className="flex items-center gap-6">
+          <Link href="/pricing" className="text-sm text-brand-text/70 hover:text-brand-accent-purple transition-colors">
+            {t('nav.pricing')}
+          </Link>
+          <Link href="/docs" className="text-sm text-brand-text/70 hover:text-brand-accent-purple transition-colors">
+            {t('nav.docs')}
+          </Link>
+          <LocaleSwitcher />
+        </nav>
+      </div>
+    </header>
+  );
+}

+ 29 - 0
apps/web/src/components/Hero.tsx

@@ -0,0 +1,29 @@
+/**
+ * 首页 Hero 区块 - 主标题 + CTA 按钮占位
+ */
+import { useTranslations } from 'next-intl';
+
+export function Hero() {
+  const t = useTranslations();
+
+  return (
+    <section className="flex items-center justify-center px-6 py-32">
+      <div className="mx-auto max-w-3xl text-center">
+        <h1 className="text-4xl font-bold leading-tight tracking-tight sm:text-5xl lg:text-6xl">
+          {t('hero.title')}
+        </h1>
+        <p className="mt-6 text-lg text-brand-text/60">
+          OpenAI-compatible API for Chinese LLMs like GLM 5.2 & DeepSeek V4.
+        </p>
+        <div className="mt-10">
+          <button
+            type="button"
+            className="rounded-lg bg-brand-accent-purple px-8 py-3 font-semibold text-white shadow-lg shadow-brand-accent-purple/25 transition-all hover:bg-brand-accent-purple/80"
+          >
+            {t('hero.cta')}
+          </button>
+        </div>
+      </div>
+    </section>
+  );
+}

+ 47 - 0
apps/web/src/components/LocaleSwitcher.tsx

@@ -0,0 +1,47 @@
+/**
+ * 全局语言切换组件 - 4 种语言快速切换
+ */
+'use client';
+
+import { useLocale } from 'next-intl';
+import { usePathname, useRouter } from 'next/navigation';
+import { useTransition } from 'react';
+
+const localeLabels: Record<string, string> = {
+  vi: 'Tiếng Việt',
+  en: 'English',
+  'zh-CN': '简体中文',
+  'zh-TW': '繁體中文',
+};
+
+export function LocaleSwitcher() {
+  const locale = useLocale();
+  const pathname = usePathname();
+  const router = useRouter();
+  const [isPending, startTransition] = useTransition();
+
+  // Build next path: /en/foo -> /vi/foo
+  const switchTo = (nextLocale: string) => {
+    const segments = pathname.split('/');
+    segments[1] = nextLocale;
+    const newPath = segments.join('/');
+    startTransition(() => {
+      router.replace(newPath);
+    });
+  };
+
+  return (
+    <select
+      value={locale}
+      onChange={(e) => switchTo(e.target.value)}
+      disabled={isPending}
+      className="rounded border border-brand-accent-purple/30 bg-brand-card px-2 py-1 text-sm text-brand-text focus:outline-none focus:ring-1 focus:ring-brand-accent-purple"
+    >
+      {Object.entries(localeLabels).map(([code, label]) => (
+        <option key={code} value={code}>
+          {label}
+        </option>
+      ))}
+    </select>
+  );
+}

+ 20 - 0
apps/web/src/i18n/request.ts

@@ -0,0 +1,20 @@
+/**
+ * 国际化请求配置 - next-intl 核心设置
+ * 处理 4 种语言: vi, en, zh-CN, zh-TW
+ */
+import { getRequestConfig } from 'next-intl/server';
+
+export default getRequestConfig(async ({ requestLocale }) => {
+  let locale = await requestLocale;
+
+  const supported = ['vi', 'en', 'zh-CN', 'zh-TW'] as const;
+  if (!locale || !supported.includes(locale as (typeof supported)[number])) {
+    locale = 'en';
+  }
+
+  return {
+    locale,
+    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+    messages: (await import(`../../messages/${locale}.json`)).default,
+  };
+});

+ 9 - 0
apps/web/src/middleware.ts

@@ -0,0 +1,9 @@
+/**
+ * next-intl 中间件 - 语言检测与路由
+ * 从 Accept-Language / Cookie 推断用户语言
+ */
+export { default } from 'next-intl/middleware';
+
+export const config = {
+  matcher: ['/((?!_next|_vercel|api|favicon.ico|.*\\..*).*)'],
+};

+ 22 - 0
apps/web/tailwind.config.js

@@ -0,0 +1,22 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+  content: [
+    './src/**/*.{js,ts,jsx,tsx,mdx}',
+  ],
+  theme: {
+    extend: {
+      colors: {
+        brand: {
+          bg: '#0B1020',
+          card: '#121831',
+          accent: {
+            purple: '#7C5CFF',
+            green: '#00E5A0',
+          },
+          text: '#E8ECF8',
+        },
+      },
+    },
+  },
+  plugins: [],
+};

+ 20 - 0
apps/web/tsconfig.json

@@ -0,0 +1,20 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "lib": ["ES2022", "DOM", "DOM.Iterable"],
+    "target": "ES2017",
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "jsx": "preserve",
+    "incremental": true,
+    "strict": true,
+    "plugins": [{ "name": "next" }],
+    "paths": {
+      "@/*": ["./src/*"]
+    },
+    "noEmit": true,
+    "allowJs": true
+  },
+  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+  "exclude": ["node_modules"]
+}

Some files were not shown because too many files changed in this diff